@adminforth/upload 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,546 @@
1
+
2
+ import { PluginOptions } from './types.js';
3
+ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
4
+ import { ExpirationStatus, GetObjectCommand, ObjectCannedACL, PutObjectCommand, S3 } from '@aws-sdk/client-s3';
5
+ import { AdminForthPlugin, AdminForthResourceColumn, AdminForthResource, Filters, IAdminForth, IHttpServer, suggestIfTypo } from "adminforth";
6
+ import { Readable } from "stream";
7
+ import { RateLimiter } from "adminforth";
8
+
9
+ const ADMINFORTH_NOT_YET_USED_TAG = 'adminforth-candidate-for-cleanup';
10
+
11
+ export default class UploadPlugin extends AdminForthPlugin {
12
+ options: PluginOptions;
13
+
14
+ adminforth!: IAdminForth;
15
+
16
+ constructor(options: PluginOptions) {
17
+ super(options, import.meta.url);
18
+ this.options = options;
19
+ }
20
+
21
+ instanceUniqueRepresentation(pluginOptions: any) : string {
22
+ return `${pluginOptions.pathColumnName}`;
23
+ }
24
+
25
+ async setupLifecycleRule() {
26
+ // check that lifecyle rule "adminforth-unused-cleaner" exists
27
+ const CLEANUP_RULE_ID = 'adminforth-unused-cleaner';
28
+
29
+ const s3 = new S3({
30
+ credentials: {
31
+ accessKeyId: this.options.s3AccessKeyId,
32
+ secretAccessKey: this.options.s3SecretAccessKey,
33
+ },
34
+ region: this.options.s3Region,
35
+ });
36
+
37
+ // check bucket exists
38
+ const bucketExists = s3.headBucket({ Bucket: this.options.s3Bucket })
39
+ if (!bucketExists) {
40
+ throw new Error(`Bucket ${this.options.s3Bucket} does not exist`);
41
+ }
42
+
43
+ // check that lifecycle rule exists
44
+ let ruleExists: boolean = false;
45
+
46
+ try {
47
+ const lifecycleConfig: any = await s3.getBucketLifecycleConfiguration({ Bucket: this.options.s3Bucket });
48
+ ruleExists = lifecycleConfig.Rules.some((rule: any) => rule.ID === CLEANUP_RULE_ID);
49
+ } catch (e: any) {
50
+ if (e.name !== 'NoSuchLifecycleConfiguration') {
51
+ console.error(`⛔ Error checking lifecycle configuration, please check keys have permissions to
52
+ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${this.options.s3Region}. Exception:`, e);
53
+ throw e;
54
+ } else {
55
+ ruleExists = false;
56
+ }
57
+ }
58
+
59
+ if (!ruleExists) {
60
+ // create
61
+ // rule deletes object has tag adminforth-candidate-for-cleanup = true after 2 days
62
+ const params = {
63
+ Bucket: this.options.s3Bucket,
64
+ LifecycleConfiguration: {
65
+ Rules: [
66
+ {
67
+ ID: CLEANUP_RULE_ID,
68
+ Status: ExpirationStatus.Enabled,
69
+ Filter: {
70
+ Tag: {
71
+ Key: ADMINFORTH_NOT_YET_USED_TAG,
72
+ Value: 'true'
73
+ }
74
+ },
75
+ Expiration: {
76
+ Days: 2
77
+ }
78
+ }
79
+ ]
80
+ }
81
+ };
82
+
83
+ await s3.putBucketLifecycleConfiguration(params);
84
+ }
85
+ }
86
+
87
+ async genPreviewUrl(record: any, s3: S3) {
88
+ if (this.options.preview?.previewUrl) {
89
+ record[`previewUrl_${this.pluginInstanceId}`] = this.options.preview.previewUrl({ s3Path: record[this.options.pathColumnName] });
90
+ return;
91
+ }
92
+ const previewUrl = await await getSignedUrl(s3, new GetObjectCommand({
93
+ Bucket: this.options.s3Bucket,
94
+ Key: record[this.options.pathColumnName],
95
+ }));
96
+
97
+ record[`previewUrl_${this.pluginInstanceId}`] = previewUrl;
98
+ }
99
+
100
+ async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
101
+ super.modifyResourceConfig(adminforth, resourceConfig);
102
+ // after column to store the path of the uploaded file, add new VirtualColumn,
103
+ // show only in edit and create views
104
+ // use component uploader.vue
105
+ const { pathColumnName } = this.options;
106
+ const pathColumnIndex = resourceConfig.columns.findIndex((column: any) => column.name === pathColumnName);
107
+ if (pathColumnIndex === -1) {
108
+ throw new Error(`Column with name "${pathColumnName}" not found in resource "${resourceConfig.label}"`);
109
+ }
110
+
111
+ if (this.options.generation?.fieldsForContext) {
112
+ this.options.generation?.fieldsForContext.forEach((field: string) => {
113
+ if (!resourceConfig.columns.find((column: any) => column.name === field)) {
114
+ const similar = suggestIfTypo(resourceConfig.columns.map((column: any) => column.name), field);
115
+ throw new Error(`Field "${field}" specified in fieldsForContext not found in
116
+ resource "${resourceConfig.label}". ${similar ? `Did you mean "${similar}"?` : ''}`);
117
+ }
118
+ });
119
+ }
120
+
121
+ const pluginFrontendOptions = {
122
+ allowedExtensions: this.options.allowedFileExtensions,
123
+ maxFileSize: this.options.maxFileSize,
124
+ pluginInstanceId: this.pluginInstanceId,
125
+ resourceLabel: resourceConfig.label,
126
+ generateImages: this.options.generation ? true : false,
127
+ pathColumnLabel: resourceConfig.columns[pathColumnIndex].label,
128
+ fieldsForContext: this.options.generation?.fieldsForContext,
129
+ maxWidth: this.options.preview?.maxWidth,
130
+ };
131
+ // define components which will be imported from other components
132
+ this.componentPath('imageGenerator.vue');
133
+
134
+ const virtualColumn: AdminForthResourceColumn = {
135
+ virtual: true,
136
+ name: `uploader_${this.pluginInstanceId}`,
137
+ components: {
138
+ edit: {
139
+ file: this.componentPath('uploader.vue'),
140
+ meta: pluginFrontendOptions,
141
+ },
142
+ create: {
143
+ file: this.componentPath('uploader.vue'),
144
+ meta: pluginFrontendOptions,
145
+ },
146
+ },
147
+ showIn: {
148
+ create: true,
149
+ edit: true,
150
+ list: false,
151
+ show: false,
152
+ filter: false,
153
+ }
154
+ };
155
+
156
+
157
+
158
+
159
+ if (!resourceConfig.columns[pathColumnIndex].components) {
160
+ resourceConfig.columns[pathColumnIndex].components = {};
161
+ }
162
+
163
+ if (this.options.preview?.showInList || this.options.preview?.showInList === undefined) {
164
+ // add preview column to list
165
+ resourceConfig.columns[pathColumnIndex].components.list = {
166
+ file: this.componentPath('preview.vue'),
167
+ meta: pluginFrontendOptions,
168
+ };
169
+ }
170
+
171
+ if (this.options.preview?.showInShow || this.options.preview?.showInShow === undefined) {
172
+ resourceConfig.columns[pathColumnIndex].components.show = {
173
+ file: this.componentPath('preview.vue'),
174
+ meta: pluginFrontendOptions,
175
+ };
176
+ }
177
+
178
+ // insert virtual column after path column if it is not already there
179
+ const virtualColumnIndex = resourceConfig.columns.findIndex((column: any) => column.name === virtualColumn.name);
180
+ if (virtualColumnIndex === -1) {
181
+ resourceConfig.columns.splice(pathColumnIndex + 1, 0, virtualColumn);
182
+ }
183
+
184
+ // if showIn of path column has 'create' or 'edit' remove it
185
+ const pathColumn = resourceConfig.columns[pathColumnIndex];
186
+ if (pathColumn.showIn && (pathColumn.showIn.create || pathColumn.showIn.edit)) {
187
+ pathColumn.showIn = { ...pathColumn.showIn, create: false, edit: false };
188
+ }
189
+
190
+ virtualColumn.required = pathColumn.required;
191
+ virtualColumn.label = pathColumn.label;
192
+ virtualColumn.editingNote = pathColumn.editingNote;
193
+
194
+ // ** HOOKS FOR CREATE **//
195
+
196
+ // add beforeSave hook to save virtual column to path column
197
+ resourceConfig.hooks.create.beforeSave.push(async ({ record }: { record: any }) => {
198
+ if (record[virtualColumn.name]) {
199
+ record[pathColumnName] = record[virtualColumn.name];
200
+ delete record[virtualColumn.name];
201
+ }
202
+ return { ok: true };
203
+ });
204
+
205
+ // in afterSave hook, aremove tag adminforth-not-yet-used from the file
206
+ resourceConfig.hooks.create.afterSave.push(async ({ record }: { record: any }) => {
207
+ process.env.HEAVY_DEBUG && console.log('💾💾 after save ', record?.id);
208
+
209
+ if (record[pathColumnName]) {
210
+ const s3 = new S3({
211
+ credentials: {
212
+ accessKeyId: this.options.s3AccessKeyId,
213
+ secretAccessKey: this.options.s3SecretAccessKey,
214
+ },
215
+
216
+ region: this.options.s3Region,
217
+ });
218
+ process.env.HEAVY_DEBUG && console.log('🪥🪥 remove ObjectTagging', record[pathColumnName]);
219
+ // let it crash if it fails: this is a new file which just was uploaded.
220
+ await s3.putObjectTagging({
221
+ Bucket: this.options.s3Bucket,
222
+ Key: record[pathColumnName],
223
+ Tagging: {
224
+ TagSet: []
225
+ }
226
+ });
227
+ }
228
+ return { ok: true };
229
+ });
230
+
231
+ // ** HOOKS FOR SHOW **//
232
+
233
+
234
+ // add show hook to get presigned URL
235
+ resourceConfig.hooks.show.afterDatasourceResponse.push(async ({ response }: { response: any }) => {
236
+ const record = response[0];
237
+ if (!record) {
238
+ return { ok: true };
239
+ }
240
+ if (record[pathColumnName]) {
241
+ const s3 = new S3({
242
+ credentials: {
243
+ accessKeyId: this.options.s3AccessKeyId,
244
+ secretAccessKey: this.options.s3SecretAccessKey,
245
+ },
246
+
247
+ region: this.options.s3Region,
248
+ });
249
+
250
+ await this.genPreviewUrl(record, s3);
251
+ }
252
+ return { ok: true };
253
+ });
254
+
255
+ // ** HOOKS FOR LIST **//
256
+
257
+
258
+ if (this.options.preview?.showInList || this.options.preview?.showInList === undefined) {
259
+ resourceConfig.hooks.list.afterDatasourceResponse.push(async ({ response }: { response: any }) => {
260
+ const s3 = new S3({
261
+ credentials: {
262
+ accessKeyId: this.options.s3AccessKeyId,
263
+ secretAccessKey: this.options.s3SecretAccessKey,
264
+ },
265
+
266
+ region: this.options.s3Region,
267
+ });
268
+
269
+ await Promise.all(response.map(async (record: any) => {
270
+ if (record[this.options.pathColumnName]) {
271
+ await this.genPreviewUrl(record, s3);
272
+ }
273
+ }));
274
+ return { ok: true };
275
+ })
276
+ }
277
+
278
+ // ** HOOKS FOR DELETE **//
279
+
280
+ // add delete hook which sets tag adminforth-candidate-for-cleanup to true
281
+ resourceConfig.hooks.delete.afterSave.push(async ({ record }: { record: any }) => {
282
+ if (record[pathColumnName]) {
283
+ const s3 = new S3({
284
+ credentials: {
285
+ accessKeyId: this.options.s3AccessKeyId,
286
+ secretAccessKey: this.options.s3SecretAccessKey,
287
+ },
288
+
289
+ region: this.options.s3Region,
290
+ });
291
+
292
+ try {
293
+ await s3.putObjectTagging({
294
+ Bucket: this.options.s3Bucket,
295
+ Key: record[pathColumnName],
296
+ Tagging: {
297
+ TagSet: [
298
+ {
299
+ Key: ADMINFORTH_NOT_YET_USED_TAG,
300
+ Value: 'true'
301
+ }
302
+ ]
303
+ }
304
+ });
305
+ } catch (e) {
306
+ // file might be e.g. already deleted, so we catch error
307
+ console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${record[pathColumnName]}. File will not be auto-cleaned up`, e);
308
+ }
309
+ }
310
+ return { ok: true };
311
+ });
312
+
313
+
314
+ // ** HOOKS FOR EDIT **//
315
+
316
+ // beforeSave
317
+ resourceConfig.hooks.edit.beforeSave.push(async ({ record }: { record: any }) => {
318
+ // null is when value is removed
319
+ if (record[virtualColumn.name] || record[virtualColumn.name] === null) {
320
+ record[pathColumnName] = record[virtualColumn.name];
321
+ }
322
+ return { ok: true };
323
+ })
324
+
325
+
326
+ // add edit postSave hook to delete old file and remove tag from new file
327
+ resourceConfig.hooks.edit.afterSave.push(async ({ updates, oldRecord }: { updates: any, oldRecord: any }) => {
328
+
329
+ if (updates[virtualColumn.name] || updates[virtualColumn.name] === null) {
330
+ const s3 = new S3({
331
+ credentials: {
332
+ accessKeyId: this.options.s3AccessKeyId,
333
+ secretAccessKey: this.options.s3SecretAccessKey,
334
+ },
335
+
336
+ region: this.options.s3Region,
337
+ });
338
+
339
+ if (oldRecord[pathColumnName]) {
340
+ // put tag to delete old file
341
+ try {
342
+ await s3.putObjectTagging({
343
+ Bucket: this.options.s3Bucket,
344
+ Key: oldRecord[pathColumnName],
345
+ Tagging: {
346
+ TagSet: [
347
+ {
348
+ Key: ADMINFORTH_NOT_YET_USED_TAG,
349
+ Value: 'true'
350
+ }
351
+ ]
352
+ }
353
+ });
354
+ } catch (e) {
355
+ // file might be e.g. already deleted, so we catch error
356
+ console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${oldRecord[pathColumnName]}. File will not be auto-cleaned up`, e);
357
+ }
358
+ }
359
+ if (updates[virtualColumn.name] !== null) {
360
+ // remove tag from new file
361
+ // in this case we let it crash if it fails: this is a new file which just was uploaded.
362
+ await s3.putObjectTagging({
363
+ Bucket: this.options.s3Bucket,
364
+ Key: updates[pathColumnName],
365
+ Tagging: {
366
+ TagSet: []
367
+ }
368
+ });
369
+ }
370
+ }
371
+ return { ok: true };
372
+ });
373
+
374
+
375
+ }
376
+
377
+ validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: any) {
378
+ this.adminforth = adminforth;
379
+ // called here because modifyResourceConfig can be called in build time where there is no environment and AWS secrets
380
+ this.setupLifecycleRule();
381
+ }
382
+
383
+ setupEndpoints(server: IHttpServer) {
384
+ server.endpoint({
385
+ method: 'POST',
386
+ path: `/plugin/${this.pluginInstanceId}/get_s3_upload_url`,
387
+ handler: async ({ body }) => {
388
+ const { originalFilename, contentType, size, originalExtension, recordPk } = body;
389
+
390
+ if (this.options.allowedFileExtensions && !this.options.allowedFileExtensions.includes(originalExtension)) {
391
+ return {
392
+ error: `File extension "${originalExtension}" is not allowed, allowed extensions are: ${this.options.allowedFileExtensions.join(', ')}`
393
+ };
394
+ }
395
+
396
+ let record = undefined;
397
+ if (recordPk) {
398
+ // get record by recordPk
399
+ const pkName = this.resourceConfig.columns.find((column: any) => column.primaryKey)?.name;
400
+ record = await this.adminforth.resource(this.resourceConfig.resourceId).get(
401
+ [Filters.EQ(pkName, recordPk)]
402
+ )
403
+ }
404
+
405
+ const s3Path: string = this.options.s3Path({ originalFilename, originalExtension, contentType, record });
406
+ if (s3Path.startsWith('/')) {
407
+ throw new Error('s3Path should not start with /, please adjust s3path function to not return / at the start of the path');
408
+ }
409
+ const s3 = new S3({
410
+ credentials: {
411
+ accessKeyId: this.options.s3AccessKeyId,
412
+ secretAccessKey: this.options.s3SecretAccessKey,
413
+ },
414
+
415
+ region: this.options.s3Region,
416
+ });
417
+
418
+ const tagline = `${ADMINFORTH_NOT_YET_USED_TAG}=true`;
419
+ const params = {
420
+ Bucket: this.options.s3Bucket,
421
+ Key: s3Path,
422
+ ContentType: contentType,
423
+ ACL: (this.options.s3ACL || 'private') as ObjectCannedACL,
424
+ Tagging: tagline,
425
+ };
426
+
427
+ const uploadUrl = await await getSignedUrl(s3, new PutObjectCommand(params), {
428
+ expiresIn: 1800,
429
+ unhoistableHeaders: new Set(['x-amz-tagging']),
430
+ });
431
+
432
+ let previewUrl;
433
+ if (this.options.preview?.previewUrl) {
434
+ previewUrl = this.options.preview.previewUrl({ s3Path });
435
+ } else if (this.options.s3ACL === 'public-read') {
436
+ previewUrl = `https://${this.options.s3Bucket}.s3.${this.options.s3Region}.amazonaws.com/${s3Path}`;
437
+ } else {
438
+ previewUrl = await getSignedUrl(s3, new GetObjectCommand({
439
+ Bucket: this.options.s3Bucket,
440
+ Key: s3Path,
441
+ }));
442
+ }
443
+
444
+ return {
445
+ uploadUrl,
446
+ s3Path,
447
+ tagline,
448
+ previewUrl,
449
+ };
450
+ }
451
+ });
452
+
453
+ // generation: {
454
+ // provider: 'openai-dall-e',
455
+ // countToGenerate: 3,
456
+ // openAiOptions: {
457
+ // model: 'dall-e-3',
458
+ // size: '1792x1024',
459
+ // apiKey: process.env.OPENAI_API_KEY as string,
460
+ // },
461
+ // },
462
+
463
+ // curl https://api.openai.com/v1/images/generations \
464
+ // -H "Content-Type: application/json" \
465
+ // -H "Authorization: Bearer $OPENAI_API_KEY" \
466
+ // -d '{
467
+ // "model": "dall-e-3",
468
+ // "prompt": "A cute baby sea otter",
469
+ // "n": 1,
470
+ // "size": "1024x1024"
471
+ // }'
472
+
473
+ server.endpoint({
474
+ method: 'POST',
475
+ path: `/plugin/${this.pluginInstanceId}/generate_images`,
476
+ handler: async ({ body, headers }) => {
477
+ const { prompt } = body;
478
+
479
+ if (this.options.generation.provider !== 'openai-dall-e') {
480
+ throw new Error(`Provider ${this.options.generation.provider} is not supported`);
481
+ }
482
+
483
+ if (this.options.generation.rateLimit?.limit) {
484
+ // rate limit
485
+ const { error } = RateLimiter.checkRateLimit(
486
+ this.pluginInstanceId,
487
+ this.options.generation.rateLimit?.limit,
488
+ this.adminforth.auth.getClientIp(headers),
489
+ );
490
+ if (error) {
491
+ return { error: this.options.generation.rateLimit.errorMessage };
492
+ }
493
+ }
494
+
495
+ const { model, size, apiKey } = this.options.generation.openAiOptions;
496
+ const url = 'https://api.openai.com/v1/images/generations';
497
+
498
+ let error = null;
499
+ const images = await Promise.all(
500
+ (new Array(this.options.generation.countToGenerate)).fill(0).map(async () => {
501
+ const response = await fetch(url, {
502
+ method: 'POST',
503
+ headers: {
504
+ 'Content-Type': 'application/json',
505
+ 'Authorization': `Bearer ${apiKey}`,
506
+ },
507
+ body: JSON.stringify({
508
+ model,
509
+ prompt,
510
+ n: 1,
511
+ size,
512
+ })
513
+ });
514
+
515
+ const json = await response.json();
516
+ if (json.error) {
517
+ console.error('Error generating image', json.error);
518
+ error = json.error;
519
+ return;
520
+ }
521
+
522
+ return json;
523
+
524
+ })
525
+ );
526
+
527
+ return { error, images };
528
+ }
529
+ });
530
+
531
+ server.endpoint({
532
+ method: 'GET',
533
+ path: `/plugin/${this.pluginInstanceId}/cors-proxy`,
534
+ handler: async ({ query, response }) => {
535
+ const { url } = query;
536
+ const resp = await fetch(url);
537
+ response.setHeader('Content-Type', resp.headers.get('Content-Type'));
538
+ //@ts-ignore
539
+ Readable.fromWeb( resp.body ).pipe( response.blobStream() );
540
+ return null
541
+ }
542
+ });
543
+
544
+ }
545
+
546
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@adminforth/upload",
3
+ "version": "1.0.0",
4
+ "description": "Plugin for uploading files for adminforth",
5
+ "main": "dist/index.js",
6
+ "types": "dist/index.d.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/devforth/adminforth-upload.git"
10
+ },
11
+ "scripts": {
12
+ "prepare": "npm link adminforth",
13
+ "build": "tsc && rsync -av --exclude 'node_modules' custom dist/"
14
+ },
15
+ "type": "module",
16
+ "author": "devforth",
17
+ "license": "ISC",
18
+ "dependencies": {
19
+ "@aws-sdk/client-s3": "^3.629.0",
20
+ "@aws-sdk/s3-request-presigner": "^3.629.0"
21
+ },
22
+ "release": {
23
+ "plugins": [
24
+ "@semantic-release/commit-analyzer",
25
+ "@semantic-release/release-notes-generator",
26
+ "@semantic-release/npm",
27
+ "@semantic-release/github",
28
+ [
29
+ "semantic-release-slack-bot",
30
+ {
31
+ "notifyOnSuccess": true,
32
+ "notifyOnFail": true,
33
+ "slackIcon": ":package:",
34
+ "markdownReleaseNotes": true
35
+ }
36
+ ]
37
+ ],
38
+ "branches": [
39
+ "main",
40
+ {
41
+ "name": "next",
42
+ "prerelease": true
43
+ }
44
+ ]
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.10.7",
48
+ "semantic-release": "^24.2.1",
49
+ "semantic-release-slack-bot": "^4.0.2",
50
+ "typescript": "^5.7.3"
51
+ }
52
+ }