@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/.woodpecker/buildRelease.sh +13 -0
- package/.woodpecker/buildSlackNotify.sh +44 -0
- package/.woodpecker/release.yml +42 -0
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/build.log +15 -0
- package/custom/imageGenerator.vue +229 -0
- package/custom/package-lock.json +310 -0
- package/custom/package.json +16 -0
- package/custom/preview.vue +111 -0
- package/custom/tsconfig.json +19 -0
- package/custom/uploader.vue +304 -0
- package/dist/custom/imageGenerator.vue +229 -0
- package/dist/custom/package-lock.json +310 -0
- package/dist/custom/package.json +16 -0
- package/dist/custom/preview.vue +111 -0
- package/dist/custom/tsconfig.json +19 -0
- package/dist/custom/uploader.vue +304 -0
- package/dist/index.js +482 -0
- package/dist/types.js +1 -0
- package/index.ts +546 -0
- package/package.json +52 -0
- package/tsconfig.json +112 -0
- package/types.ts +162 -0
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
|
+
}
|