@adminforth/upload 1.0.5

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,349 @@
1
+
2
+ import { PluginOptions } from './types.js';
3
+ import AWS from 'aws-sdk';
4
+ import { AdminForthPlugin, AdminForthResourceColumn, AdminForthResourcePages, IAdminForth, IHttpServer } from "adminforth";
5
+
6
+ const ADMINFORTH_NOT_YET_USED_TAG = 'adminforth-candidate-for-cleanup';
7
+
8
+ export default class UploadPlugin extends AdminForthPlugin {
9
+ options: PluginOptions;
10
+
11
+ constructor(options: PluginOptions) {
12
+ super(options, import.meta.url);
13
+
14
+ this.options = options;
15
+ }
16
+
17
+ async setupLifecycleRule() {
18
+ // check that lifecyle rule "adminforth-unused-cleaner" exists
19
+ const CLEANUP_RULE_ID = 'adminforth-unused-cleaner';
20
+ const s3 = new AWS.S3({
21
+ accessKeyId: this.options.s3AccessKeyId,
22
+ secretAccessKey: this.options.s3SecretAccessKey,
23
+ region: this.options.s3Region
24
+ });
25
+ // check bucket exists
26
+ const bucketExists = s3.headBucket({ Bucket: this.options.s3Bucket }).promise()
27
+ if (!bucketExists) {
28
+ throw new Error(`Bucket ${this.options.s3Bucket} does not exist`);
29
+ }
30
+
31
+ // check that lifecycle rule exists
32
+ let ruleExists: boolean = false;
33
+
34
+ try {
35
+ const lifecycleConfig: any = await s3.getBucketLifecycleConfiguration({ Bucket: this.options.s3Bucket }).promise();
36
+ ruleExists = lifecycleConfig.Rules.some((rule: any) => rule.ID === CLEANUP_RULE_ID);
37
+ } catch (e: any) {
38
+ if (e.code !== 'NoSuchLifecycleConfiguration') {
39
+ throw e;
40
+ } else {
41
+ ruleExists = false;
42
+ }
43
+ }
44
+
45
+ if (!ruleExists) {
46
+ // create
47
+ // rule deletes object has tag adminforth-candidate-for-cleanup = true after 2 days
48
+ const params = {
49
+ Bucket: this.options.s3Bucket,
50
+ LifecycleConfiguration: {
51
+ Rules: [
52
+ {
53
+ ID: CLEANUP_RULE_ID,
54
+ Status: 'Enabled',
55
+ Filter: {
56
+ Tag: {
57
+ Key: ADMINFORTH_NOT_YET_USED_TAG,
58
+ Value: 'true'
59
+ }
60
+ },
61
+ Expiration: {
62
+ Days: 2
63
+ }
64
+ }
65
+ ]
66
+ }
67
+ };
68
+
69
+ await s3.putBucketLifecycleConfiguration(params).promise();
70
+ }
71
+ }
72
+
73
+ async genPreviewUrl(record: any, s3: AWS.S3) {
74
+ if (this.options.preview?.previewUrl) {
75
+ record[`previewUrl_${this.pluginInstanceId}`] = this.options.preview.previewUrl({ s3Path: record[this.options.pathColumnName] });
76
+ return;
77
+ }
78
+ const previewUrl = await s3.getSignedUrl('getObject', {
79
+ Bucket: this.options.s3Bucket,
80
+ Key: record[this.options.pathColumnName],
81
+ });
82
+
83
+ record[`previewUrl_${this.pluginInstanceId}`] = previewUrl;
84
+ }
85
+
86
+ async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: any) {
87
+ this.setupLifecycleRule();
88
+
89
+ super.modifyResourceConfig(adminforth, resourceConfig);
90
+ // after column to store the path of the uploaded file, add new VirtualColumn,
91
+ // show only in edit and create views
92
+ // use component uploader.vue
93
+ const { pathColumnName } = this.options;
94
+
95
+ const pluginFrontendOptions = {
96
+ allowedExtensions: this.options.allowedFileExtensions,
97
+ maxFileSize: this.options.maxFileSize,
98
+ pluginInstanceId: this.pluginInstanceId,
99
+ };
100
+ const virtualColumn: AdminForthResourceColumn = {
101
+ virtual: true,
102
+ name: `uploader_${this.pluginInstanceId}`,
103
+ components: {
104
+ edit: {
105
+ file: this.componentPath('uploader.vue'),
106
+ meta: pluginFrontendOptions,
107
+ },
108
+ create: {
109
+ file: this.componentPath('uploader.vue'),
110
+ meta: pluginFrontendOptions,
111
+ },
112
+ show: {
113
+ file: this.componentPath('preview.vue'),
114
+ meta: pluginFrontendOptions,
115
+ },
116
+ ...(
117
+ this.options.preview?.showInList ? {
118
+ list: {
119
+ file: this.componentPath('preview.vue'),
120
+ meta: pluginFrontendOptions,
121
+ }
122
+ } : {}
123
+ ),
124
+ },
125
+ showIn: ['edit', 'create', 'show', ...(this.options.preview?.showInList ? [
126
+ AdminForthResourcePages.list
127
+ ] : [])],
128
+ };
129
+
130
+
131
+
132
+ const pathColumnIndex = resourceConfig.columns.findIndex((column: any) => column.name === pathColumnName);
133
+ if (pathColumnIndex === -1) {
134
+ throw new Error(`Column with name "${pathColumnName}" not found in resource "${resourceConfig.name}"`);
135
+ }
136
+
137
+ // insert virtual column after path column if it is not already there
138
+ const virtualColumnIndex = resourceConfig.columns.findIndex((column: any) => column.name === virtualColumn.name);
139
+ if (virtualColumnIndex === -1) {
140
+ resourceConfig.columns.splice(pathColumnIndex + 1, 0, virtualColumn);
141
+ }
142
+
143
+ // if showIn of path column has 'create' or 'edit' remove it
144
+ const pathColumn = resourceConfig.columns[pathColumnIndex];
145
+ if (pathColumn.showIn && (pathColumn.showIn.includes('create') || pathColumn.showIn.includes('edit'))) {
146
+ pathColumn.showIn = pathColumn.showIn.filter((view: string) => !['create', 'edit'].includes(view));
147
+ }
148
+
149
+ virtualColumn.required = pathColumn.required;
150
+ virtualColumn.label = pathColumn.label;
151
+ virtualColumn.editingNote = pathColumn.editingNote;
152
+
153
+ // ** HOOKS FOR CREATE **//
154
+
155
+ // add beforeSave hook to save virtual column to path column
156
+ resourceConfig.hooks.create.beforeSave.push(async ({ record }: { record: any }) => {
157
+ if (record[virtualColumn.name]) {
158
+ record[pathColumnName] = record[virtualColumn.name];
159
+ delete record[virtualColumn.name];
160
+ }
161
+ return { ok: true };
162
+ });
163
+
164
+ // in afterSave hook, aremove tag adminforth-not-yet-used from the file
165
+ resourceConfig.hooks.create.afterSave.push(async ({ record }: { record: any }) => {
166
+ if (record[pathColumnName]) {
167
+ const s3 = new AWS.S3({
168
+ accessKeyId: this.options.s3AccessKeyId,
169
+ secretAccessKey: this.options.s3SecretAccessKey,
170
+ region: this.options.s3Region
171
+ });
172
+
173
+ await s3.putObjectTagging({
174
+ Bucket: this.options.s3Bucket,
175
+ Key: record[pathColumnName],
176
+ Tagging: {
177
+ TagSet: []
178
+ }
179
+ }).promise();
180
+ }
181
+ return { ok: true };
182
+ });
183
+
184
+ // ** HOOKS FOR SHOW **//
185
+
186
+
187
+ // add show hook to get presigned URL
188
+ resourceConfig.hooks.show.afterDatasourceResponse.push(async ({ response }: { response: any }) => {
189
+ const record = response[0];
190
+ if (!record) {
191
+ return { ok: true };
192
+ }
193
+ if (record[pathColumnName]) {
194
+ const s3 = new AWS.S3({
195
+ accessKeyId: this.options.s3AccessKeyId,
196
+ secretAccessKey: this.options.s3SecretAccessKey,
197
+ region: this.options.s3Region
198
+ });
199
+
200
+ await this.genPreviewUrl(record, s3);
201
+ }
202
+ return { ok: true };
203
+ });
204
+
205
+ // ** HOOKS FOR LIST **//
206
+
207
+
208
+ if (this.options.preview?.showInList) {
209
+ resourceConfig.hooks.list.afterDatasourceResponse.push(async ({ response }: { response: any }) => {
210
+ const s3 = new AWS.S3({
211
+ accessKeyId: this.options.s3AccessKeyId,
212
+ secretAccessKey: this.options.s3SecretAccessKey,
213
+ region: this.options.s3Region
214
+ });
215
+
216
+ await Promise.all(response.map(async (record: any) => {
217
+ if (record[this.options.pathColumnName]) {
218
+ await this.genPreviewUrl(record, s3);
219
+ }
220
+ }));
221
+ return { ok: true };
222
+ })
223
+ }
224
+
225
+ // ** HOOKS FOR DELETE **//
226
+
227
+ // add delete hook which sets tag adminforth-candidate-for-cleanup to true
228
+ resourceConfig.hooks.delete.beforeSave.push(async ({ record }: { record: any }) => {
229
+ if (record[pathColumnName]) {
230
+ const s3 = new AWS.S3({
231
+ accessKeyId: this.options.s3AccessKeyId,
232
+ secretAccessKey: this.options.s3SecretAccessKey,
233
+ region: this.options.s3Region
234
+ });
235
+
236
+ await s3.putObjectTagging({
237
+ Bucket: this.options.s3Bucket,
238
+ Key: record[pathColumnName],
239
+ Tagging: {
240
+ TagSet: [
241
+ {
242
+ Key: ADMINFORTH_NOT_YET_USED_TAG,
243
+ Value: 'true'
244
+ }
245
+ ]
246
+ }
247
+ }).promise();
248
+ }
249
+ return { ok: true };
250
+ });
251
+
252
+
253
+ // ** HOOKS FOR EDIT **//
254
+
255
+ // beforeSave
256
+ resourceConfig.hooks.edit.beforeSave.push(async ({ record }: { record: any }) => {
257
+ // null is when value is removed
258
+ if (record[virtualColumn.name] || record[virtualColumn.name] === null) {
259
+ record[pathColumnName] = record[virtualColumn.name];
260
+ }
261
+ return { ok: true };
262
+ })
263
+
264
+
265
+ // add edit postSave hook to delete old file and remove tag from new file
266
+ resourceConfig.hooks.edit.afterSave.push(async ({ record, oldRecord }: { record: any, oldRecord: any }) => {
267
+
268
+ if (record[virtualColumn.name] || record[virtualColumn.name] === null) {
269
+ const s3 = new AWS.S3({
270
+ accessKeyId: this.options.s3AccessKeyId,
271
+ secretAccessKey: this.options.s3SecretAccessKey,
272
+ region: this.options.s3Region
273
+ });
274
+
275
+ if (oldRecord[pathColumnName]) {
276
+ // put tag to delete old file
277
+ await s3.putObjectTagging({
278
+ Bucket: this.options.s3Bucket,
279
+ Key: oldRecord[pathColumnName],
280
+ Tagging: {
281
+ TagSet: [
282
+ {
283
+ Key: ADMINFORTH_NOT_YET_USED_TAG,
284
+ Value: 'true'
285
+ }
286
+ ]
287
+ }
288
+ }).promise();
289
+ }
290
+ if (record[virtualColumn.name] !== null) {
291
+ // remove tag from new file
292
+ await s3.putObjectTagging({
293
+ Bucket: this.options.s3Bucket,
294
+ Key: record[pathColumnName],
295
+ Tagging: {
296
+ TagSet: []
297
+ }
298
+ }).promise();
299
+ }
300
+ }
301
+ return { ok: true };
302
+ });
303
+
304
+
305
+ }
306
+
307
+
308
+
309
+ setupEndpoints(server: IHttpServer) {
310
+ server.endpoint({
311
+ method: 'POST',
312
+ path: `/plugin/${this.pluginInstanceId}/get_s3_upload_url`,
313
+ handler: async ({ body }) => {
314
+ const { originalFilename, contentType, size, originalExtension } = body;
315
+
316
+ if (this.options.allowedFileExtensions && !this.options.allowedFileExtensions.includes(originalExtension)) {
317
+ throw new Error(`File extension "${originalExtension}" is not allowed, allowed extensions are: ${this.options.allowedFileExtensions.join(', ')}`);
318
+ }
319
+
320
+ const s3Path: string = this.options.s3Path({ originalFilename, originalExtension, contentType });
321
+ if (s3Path.startsWith('/')) {
322
+ throw new Error('s3Path should not start with /, please adjust s3path function to not return / at the start of the path');
323
+ }
324
+ const s3 = new AWS.S3({
325
+ accessKeyId: this.options.s3AccessKeyId,
326
+ secretAccessKey: this.options.s3SecretAccessKey,
327
+ region: this.options.s3Region
328
+ });
329
+
330
+ const params = {
331
+ Bucket: this.options.s3Bucket,
332
+ Key: s3Path,
333
+ ContentType: contentType,
334
+ ACL: this.options.s3ACL || 'private',
335
+ Tagging: `${ADMINFORTH_NOT_YET_USED_TAG}=true`,
336
+ };
337
+
338
+ const uploadUrl = await s3.getSignedUrl('putObject', params,)
339
+
340
+
341
+ return {
342
+ uploadUrl,
343
+ s3Path,
344
+ };
345
+ }
346
+ });
347
+ }
348
+
349
+ }
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@adminforth/upload",
3
+ "version": "1.0.5",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "rollout": "tsc && cp -rf custom dist/ && npm version patch && npm publish --access public",
8
+ "postinstall": "npm link adminforth"
9
+ },
10
+ "type": "module",
11
+ "author": "",
12
+ "license": "ISC",
13
+ "dependencies": {
14
+ "aws-sdk": "^2.1654.0"
15
+ }
16
+ }
package/types.ts ADDED
@@ -0,0 +1,81 @@
1
+
2
+ export type PluginOptions = {
3
+
4
+ /**
5
+ * The name of the column where the path to the uploaded file is stored.
6
+ * On place of this column, a file upload field will be shown.
7
+ */
8
+ pathColumnName: string;
9
+
10
+ /**
11
+ * the list of allowed file extensions
12
+ */
13
+ allowedFileExtensions?: string[]; // allowed file extensions
14
+
15
+ /**
16
+ * the maximum file size in bytes
17
+ */
18
+ maxFileSize?: number;
19
+
20
+ /**
21
+ * S3 bucket name where we will upload the files, e.g. 'my-bucket'
22
+ */
23
+ s3Bucket: string,
24
+
25
+ /**
26
+ * S3 region, e.g. 'us-east-1'
27
+ */
28
+ s3Region: string,
29
+
30
+ /**
31
+ * S3 access key id
32
+ */
33
+ s3AccessKeyId: string,
34
+
35
+ /**
36
+ * S3 secret access key
37
+ */
38
+ s3SecretAccessKey: string,
39
+
40
+ /**
41
+ * ACL which will be set to uploaded file, e.g. 'public-read'.
42
+ * If you want to use 'public-read', it is your responsibility to set the "ACL Enabled" to true in the S3 bucket policy and Uncheck "Block all public access" in the bucket settings.
43
+ */
44
+ s3ACL?: string,
45
+
46
+ /**
47
+ * The path where the file will be uploaded to the S3 bucket, same path will be stored in the database
48
+ * in the column specified in {@link pathColumnName}
49
+ *
50
+ * example:
51
+ *
52
+ * ```typescript
53
+ * s3Path: ({record, originalFilename}) => `/aparts/${record.id}/${originalFilename}`
54
+ * ```
55
+ *
56
+ */
57
+ s3Path: ({originalFilename, originalExtension, contentType}) => string,
58
+
59
+
60
+ preview: {
61
+
62
+ /**
63
+ * By default preview is shown in the show view only. If you want to show it in the list view as well, set this to true
64
+ */
65
+ showInList: boolean,
66
+
67
+ /**
68
+ * Used to display preview (if it is image) in list and show views.
69
+ * Defaulted to the AWS S3 presigned URL if resource is private or public URL if resource is public.
70
+ * Can be used to generate custom e.g. CDN(e.g. Cloudflare) URL to worm up cache and deliver preview faster.
71
+ *
72
+ * Example:
73
+ *
74
+ * ```typescript
75
+ * previewUrl: ({record, path}) => `https://my-bucket.s3.amazonaws.com/${path}`,
76
+ * ```
77
+ *
78
+ */
79
+ previewUrl?: ({s3Path}) => string,
80
+ }
81
+ }