@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/custom/package-lock.json +21 -0
- package/custom/package.json +15 -0
- package/custom/preview.vue +105 -0
- package/custom/uploader.vue +221 -0
- package/dist/custom/package-lock.json +21 -0
- package/dist/custom/package.json +15 -0
- package/dist/custom/preview.vue +105 -0
- package/dist/custom/uploader.vue +221 -0
- package/dist/package-lock.json +21 -0
- package/dist/package.json +15 -0
- package/dist/preview.vue +56 -0
- package/dist/uploader.vue +163 -0
- package/index.ts +349 -0
- package/package.json +16 -0
- package/types.ts +81 -0
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
|
+
}
|