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