@adminforth/upload 1.4.6 → 1.5.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/build.log CHANGED
@@ -11,5 +11,5 @@ custom/preview.vue
11
11
  custom/tsconfig.json
12
12
  custom/uploader.vue
13
13
 
14
- sent 42,982 bytes received 134 bytes 86,232.00 bytes/sec
15
- total size is 42,493 speedup is 0.99
14
+ sent 43,165 bytes received 134 bytes 86,598.00 bytes/sec
15
+ total size is 42,676 speedup is 0.99
@@ -50,7 +50,7 @@
50
50
  :minValue="0"
51
51
  :maxValue="historicalAverage"
52
52
  :showValues="false"
53
- :progressFormatter="(value: number, percentage: number) => `${ formatTime(loadingTimer) } ( ${ Math.floor( (
53
+ :progressFormatter="(value: number, percentage: number) => `${ formatTime(loadingTimer) } ( ~ ${ Math.floor( (
54
54
  loadingTimer < historicalAverage ? loadingTimer : historicalAverage
55
55
  ) / historicalAverage * 100) }% )`"
56
56
  />
@@ -252,15 +252,10 @@ async function confirmImage() {
252
252
 
253
253
  const loadingTimer: Ref<number | null> = ref(null);
254
254
 
255
- const historicalRuns: Ref<number[]> = ref([]);
256
255
 
257
256
  const errorMessage: Ref<string | null> = ref(null);
258
257
 
259
- const historicalAverage: Ref<number | null> = computed(() => {
260
- if (historicalRuns.value.length === 0) return null;
261
- const sum = historicalRuns.value.reduce((a, b) => a + b, 0);
262
- return Math.floor(sum / historicalRuns.value.length);
263
- });
258
+ const historicalAverage: Ref<number | null> = ref(null);
264
259
 
265
260
 
266
261
  function formatTime(seconds: number): string {
@@ -269,6 +264,14 @@ function formatTime(seconds: number): string {
269
264
  }
270
265
 
271
266
 
267
+ async function getHistoricalAverage() {
268
+ const resp = await callAdminForthApi({
269
+ path: `/plugin/${props.meta.pluginInstanceId}/averageDuration`,
270
+ method: 'GET',
271
+ });
272
+ historicalAverage.value = resp?.averageDuration || null;
273
+ }
274
+
272
275
  async function generateImages() {
273
276
  errorMessage.value = null;
274
277
  loading.value = true;
@@ -279,7 +282,8 @@ async function generateImages() {
279
282
  loadingTimer.value = elapsed;
280
283
  }, 100);
281
284
  const currentIndex = caurosel.value?.getActiveItem()?.position || 0;
282
-
285
+
286
+ await getHistoricalAverage();
283
287
  let resp = null;
284
288
  let error = null;
285
289
  try {
@@ -294,7 +298,6 @@ async function generateImages() {
294
298
  } catch (e) {
295
299
  console.error(e);
296
300
  } finally {
297
- historicalRuns.value.push(loadingTimer.value);
298
301
  clearInterval(ticker);
299
302
  loadingTimer.value = null;
300
303
  loading.value = false;
@@ -330,12 +333,17 @@ async function generateImages() {
330
333
  // ];
331
334
  await nextTick();
332
335
 
336
+
333
337
  caurosel.value = new Carousel(
334
338
  document.getElementById('gallery'),
335
- images.value.map((img, index) => ({
336
- el: document.getElementById('gallery').querySelector(`[data-carousel-item]:nth-child(${index + 1})`),
337
- position: index,
338
- })),
339
+ images.value.map((img, index) => {
340
+ console.log('mapping image', img, index);
341
+ return {
342
+ image: img,
343
+ el: document.getElementById('gallery').querySelector(`[data-carousel-item]:nth-child(${index + 1})`),
344
+ position: index,
345
+ };
346
+ }),
339
347
  {
340
348
  internal: 0,
341
349
  defaultPosition: currentIndex,
@@ -345,6 +353,7 @@ async function generateImages() {
345
353
  }
346
354
  );
347
355
  await nextTick();
356
+
348
357
  loading.value = false;
349
358
  }
350
359
 
@@ -7,7 +7,6 @@
7
7
  class="rounded-md"
8
8
  :style="[maxWidth, minWidth]"
9
9
  ref="img"
10
- data-zoomable
11
10
  @click.stop="zoom.open()"
12
11
  />
13
12
  <video
@@ -229,8 +229,8 @@ const onFileChange = async (e) => {
229
229
  reader.readAsDataURL(file);
230
230
  }
231
231
 
232
- const { uploadUrl, tagline, s3Path, error } = await callAdminForthApi({
233
- path: `/plugin/${props.meta.pluginInstanceId}/get_s3_upload_url`,
232
+ const { uploadUrl, uploadExtraParams, filePath, error } = await callAdminForthApi({
233
+ path: `/plugin/${props.meta.pluginInstanceId}/get_file_upload_url`,
234
234
  method: 'POST',
235
235
  body: {
236
236
  originalFilename: nameNoExtension,
@@ -266,7 +266,9 @@ const onFileChange = async (e) => {
266
266
  });
267
267
  xhr.open('PUT', uploadUrl, true);
268
268
  xhr.setRequestHeader('Content-Type', type);
269
- xhr.setRequestHeader('x-amz-tagging', tagline);
269
+ uploadExtraParams && Object.entries(uploadExtraParams).forEach(([key, value]: [string, string]) => {
270
+ xhr.setRequestHeader(key, value);
271
+ })
270
272
  xhr.send(file);
271
273
  });
272
274
  if (!success) {
@@ -284,7 +286,7 @@ const onFileChange = async (e) => {
284
286
  return;
285
287
  }
286
288
  uploaded.value = true;
287
- emit('update:value', s3Path);
289
+ emit('update:value', filePath);
288
290
  } catch (error) {
289
291
  console.error('Error uploading file:', error);
290
292
  adminforth.alert({
@@ -50,7 +50,7 @@
50
50
  :minValue="0"
51
51
  :maxValue="historicalAverage"
52
52
  :showValues="false"
53
- :progressFormatter="(value: number, percentage: number) => `${ formatTime(loadingTimer) } ( ${ Math.floor( (
53
+ :progressFormatter="(value: number, percentage: number) => `${ formatTime(loadingTimer) } ( ~ ${ Math.floor( (
54
54
  loadingTimer < historicalAverage ? loadingTimer : historicalAverage
55
55
  ) / historicalAverage * 100) }% )`"
56
56
  />
@@ -252,15 +252,10 @@ async function confirmImage() {
252
252
 
253
253
  const loadingTimer: Ref<number | null> = ref(null);
254
254
 
255
- const historicalRuns: Ref<number[]> = ref([]);
256
255
 
257
256
  const errorMessage: Ref<string | null> = ref(null);
258
257
 
259
- const historicalAverage: Ref<number | null> = computed(() => {
260
- if (historicalRuns.value.length === 0) return null;
261
- const sum = historicalRuns.value.reduce((a, b) => a + b, 0);
262
- return Math.floor(sum / historicalRuns.value.length);
263
- });
258
+ const historicalAverage: Ref<number | null> = ref(null);
264
259
 
265
260
 
266
261
  function formatTime(seconds: number): string {
@@ -269,6 +264,14 @@ function formatTime(seconds: number): string {
269
264
  }
270
265
 
271
266
 
267
+ async function getHistoricalAverage() {
268
+ const resp = await callAdminForthApi({
269
+ path: `/plugin/${props.meta.pluginInstanceId}/averageDuration`,
270
+ method: 'GET',
271
+ });
272
+ historicalAverage.value = resp?.averageDuration || null;
273
+ }
274
+
272
275
  async function generateImages() {
273
276
  errorMessage.value = null;
274
277
  loading.value = true;
@@ -279,7 +282,8 @@ async function generateImages() {
279
282
  loadingTimer.value = elapsed;
280
283
  }, 100);
281
284
  const currentIndex = caurosel.value?.getActiveItem()?.position || 0;
282
-
285
+
286
+ await getHistoricalAverage();
283
287
  let resp = null;
284
288
  let error = null;
285
289
  try {
@@ -294,7 +298,6 @@ async function generateImages() {
294
298
  } catch (e) {
295
299
  console.error(e);
296
300
  } finally {
297
- historicalRuns.value.push(loadingTimer.value);
298
301
  clearInterval(ticker);
299
302
  loadingTimer.value = null;
300
303
  loading.value = false;
@@ -330,12 +333,17 @@ async function generateImages() {
330
333
  // ];
331
334
  await nextTick();
332
335
 
336
+
333
337
  caurosel.value = new Carousel(
334
338
  document.getElementById('gallery'),
335
- images.value.map((img, index) => ({
336
- el: document.getElementById('gallery').querySelector(`[data-carousel-item]:nth-child(${index + 1})`),
337
- position: index,
338
- })),
339
+ images.value.map((img, index) => {
340
+ console.log('mapping image', img, index);
341
+ return {
342
+ image: img,
343
+ el: document.getElementById('gallery').querySelector(`[data-carousel-item]:nth-child(${index + 1})`),
344
+ position: index,
345
+ };
346
+ }),
339
347
  {
340
348
  internal: 0,
341
349
  defaultPosition: currentIndex,
@@ -345,6 +353,7 @@ async function generateImages() {
345
353
  }
346
354
  );
347
355
  await nextTick();
356
+
348
357
  loading.value = false;
349
358
  }
350
359
 
@@ -7,7 +7,6 @@
7
7
  class="rounded-md"
8
8
  :style="[maxWidth, minWidth]"
9
9
  ref="img"
10
- data-zoomable
11
10
  @click.stop="zoom.open()"
12
11
  />
13
12
  <video
@@ -229,8 +229,8 @@ const onFileChange = async (e) => {
229
229
  reader.readAsDataURL(file);
230
230
  }
231
231
 
232
- const { uploadUrl, tagline, s3Path, error } = await callAdminForthApi({
233
- path: `/plugin/${props.meta.pluginInstanceId}/get_s3_upload_url`,
232
+ const { uploadUrl, uploadExtraParams, filePath, error } = await callAdminForthApi({
233
+ path: `/plugin/${props.meta.pluginInstanceId}/get_file_upload_url`,
234
234
  method: 'POST',
235
235
  body: {
236
236
  originalFilename: nameNoExtension,
@@ -266,7 +266,9 @@ const onFileChange = async (e) => {
266
266
  });
267
267
  xhr.open('PUT', uploadUrl, true);
268
268
  xhr.setRequestHeader('Content-Type', type);
269
- xhr.setRequestHeader('x-amz-tagging', tagline);
269
+ uploadExtraParams && Object.entries(uploadExtraParams).forEach(([key, value]: [string, string]) => {
270
+ xhr.setRequestHeader(key, value);
271
+ })
270
272
  xhr.send(file);
271
273
  });
272
274
  if (!success) {
@@ -284,7 +286,7 @@ const onFileChange = async (e) => {
284
286
  return;
285
287
  }
286
288
  uploaded.value = true;
287
- emit('update:value', s3Path);
289
+ emit('update:value', filePath);
288
290
  } catch (error) {
289
291
  console.error('Error uploading file:', error);
290
292
  adminforth.alert({
package/dist/index.js CHANGED
@@ -7,8 +7,6 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
- import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
11
- import { ExpirationStatus, GetObjectCommand, PutObjectCommand, S3 } from '@aws-sdk/client-s3';
12
10
  import { AdminForthPlugin, Filters, suggestIfTypo } from "adminforth";
13
11
  import { Readable } from "stream";
14
12
  import { RateLimiter } from "adminforth";
@@ -17,80 +15,26 @@ export default class UploadPlugin extends AdminForthPlugin {
17
15
  constructor(options) {
18
16
  super(options, import.meta.url);
19
17
  this.options = options;
18
+ // for calcualting average time
19
+ this.totalCalls = 0;
20
+ this.totalDuration = 0;
20
21
  }
21
22
  instanceUniqueRepresentation(pluginOptions) {
22
23
  return `${pluginOptions.pathColumnName}`;
23
24
  }
24
25
  setupLifecycleRule() {
25
26
  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
- }
27
+ this.options.storage.adapter.setupLifecycle();
81
28
  });
82
29
  }
83
- genPreviewUrl(record, s3) {
30
+ genPreviewUrl(record) {
84
31
  return __awaiter(this, void 0, void 0, function* () {
85
32
  var _a;
86
33
  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] });
34
+ record[`previewUrl_${this.pluginInstanceId}`] = this.options.preview.previewUrl({ filePath: record[this.options.pathColumnName] });
88
35
  return;
89
36
  }
90
- const previewUrl = yield yield getSignedUrl(s3, new GetObjectCommand({
91
- Bucket: this.options.s3Bucket,
92
- Key: record[this.options.pathColumnName],
93
- }));
37
+ const previewUrl = yield this.options.storage.adapter.getDownloadUrl(record[this.options.pathColumnName], 1800);
94
38
  record[`previewUrl_${this.pluginInstanceId}`] = previewUrl;
95
39
  });
96
40
  }
@@ -202,22 +146,9 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
202
146
  resourceConfig.hooks.create.afterSave.push((_a) => __awaiter(this, [_a], void 0, function* ({ record }) {
203
147
  process.env.HEAVY_DEBUG && console.log('💾💾 after save ', record === null || record === void 0 ? void 0 : record.id);
204
148
  if (record[pathColumnName]) {
205
- const s3 = new S3({
206
- credentials: {
207
- accessKeyId: this.options.s3AccessKeyId,
208
- secretAccessKey: this.options.s3SecretAccessKey,
209
- },
210
- region: this.options.s3Region,
211
- });
212
149
  process.env.HEAVY_DEBUG && console.log('🪥🪥 remove ObjectTagging', record[pathColumnName]);
213
150
  // let it crash if it fails: this is a new file which just was uploaded.
214
- yield s3.putObjectTagging({
215
- Bucket: this.options.s3Bucket,
216
- Key: record[pathColumnName],
217
- Tagging: {
218
- TagSet: []
219
- }
220
- });
151
+ yield this.options.storage.adapter.markKeyForNotDeletation(record[pathColumnName]);
221
152
  }
222
153
  return { ok: true };
223
154
  }));
@@ -230,14 +161,7 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
230
161
  return { ok: true };
231
162
  }
232
163
  if (record[pathColumnName]) {
233
- const s3 = new S3({
234
- credentials: {
235
- accessKeyId: this.options.s3AccessKeyId,
236
- secretAccessKey: this.options.s3SecretAccessKey,
237
- },
238
- region: this.options.s3Region,
239
- });
240
- yield this.genPreviewUrl(record, s3);
164
+ yield this.genPreviewUrl(record);
241
165
  }
242
166
  return { ok: true };
243
167
  }));
@@ -245,16 +169,9 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
245
169
  // ** HOOKS FOR LIST **//
246
170
  if (pathColumn.showIn.list) {
247
171
  resourceConfig.hooks.list.afterDatasourceResponse.push((_a) => __awaiter(this, [_a], void 0, function* ({ response }) {
248
- const s3 = new S3({
249
- credentials: {
250
- accessKeyId: this.options.s3AccessKeyId,
251
- secretAccessKey: this.options.s3SecretAccessKey,
252
- },
253
- region: this.options.s3Region,
254
- });
255
172
  yield Promise.all(response.map((record) => __awaiter(this, void 0, void 0, function* () {
256
173
  if (record[this.options.pathColumnName]) {
257
- yield this.genPreviewUrl(record, s3);
174
+ yield this.genPreviewUrl(record);
258
175
  }
259
176
  })));
260
177
  return { ok: true };
@@ -264,26 +181,8 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
264
181
  // add delete hook which sets tag adminforth-candidate-for-cleanup to true
265
182
  resourceConfig.hooks.delete.afterSave.push((_a) => __awaiter(this, [_a], void 0, function* ({ record }) {
266
183
  if (record[pathColumnName]) {
267
- const s3 = new S3({
268
- credentials: {
269
- accessKeyId: this.options.s3AccessKeyId,
270
- secretAccessKey: this.options.s3SecretAccessKey,
271
- },
272
- region: this.options.s3Region,
273
- });
274
184
  try {
275
- yield s3.putObjectTagging({
276
- Bucket: this.options.s3Bucket,
277
- Key: record[pathColumnName],
278
- Tagging: {
279
- TagSet: [
280
- {
281
- Key: ADMINFORTH_NOT_YET_USED_TAG,
282
- Value: 'true'
283
- }
284
- ]
285
- }
286
- });
185
+ yield this.options.storage.adapter.markKeyForDeletation(record[pathColumnName]);
287
186
  }
288
187
  catch (e) {
289
188
  // file might be e.g. already deleted, so we catch error
@@ -304,28 +203,10 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
304
203
  // add edit postSave hook to delete old file and remove tag from new file
305
204
  resourceConfig.hooks.edit.afterSave.push((_a) => __awaiter(this, [_a], void 0, function* ({ updates, oldRecord }) {
306
205
  if (updates[virtualColumn.name] || updates[virtualColumn.name] === null) {
307
- const s3 = new S3({
308
- credentials: {
309
- accessKeyId: this.options.s3AccessKeyId,
310
- secretAccessKey: this.options.s3SecretAccessKey,
311
- },
312
- region: this.options.s3Region,
313
- });
314
206
  if (oldRecord[pathColumnName]) {
315
207
  // put tag to delete old file
316
208
  try {
317
- yield s3.putObjectTagging({
318
- Bucket: this.options.s3Bucket,
319
- Key: oldRecord[pathColumnName],
320
- Tagging: {
321
- TagSet: [
322
- {
323
- Key: ADMINFORTH_NOT_YET_USED_TAG,
324
- Value: 'true'
325
- }
326
- ]
327
- }
328
- });
209
+ yield this.options.storage.adapter.markKeyForDeletation(oldRecord[pathColumnName]);
329
210
  }
330
211
  catch (e) {
331
212
  // file might be e.g. already deleted, so we catch error
@@ -335,13 +216,7 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
335
216
  if (updates[virtualColumn.name] !== null) {
336
217
  // remove tag from new file
337
218
  // in this case we let it crash if it fails: this is a new file which just was uploaded.
338
- yield s3.putObjectTagging({
339
- Bucket: this.options.s3Bucket,
340
- Key: updates[pathColumnName],
341
- Tagging: {
342
- TagSet: []
343
- }
344
- });
219
+ yield this.options.storage.adapter.markKeyForNotDeletation(updates[pathColumnName]);
345
220
  }
346
221
  }
347
222
  return { ok: true };
@@ -354,9 +229,20 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
354
229
  this.setupLifecycleRule();
355
230
  }
356
231
  setupEndpoints(server) {
232
+ server.endpoint({
233
+ method: 'GET',
234
+ path: `/plugin/${this.pluginInstanceId}/averageDuration`,
235
+ handler: () => __awaiter(this, void 0, void 0, function* () {
236
+ return {
237
+ totalCalls: this.totalCalls,
238
+ totalDuration: this.totalDuration,
239
+ averageDuration: this.totalCalls ? this.totalDuration / this.totalCalls : null,
240
+ };
241
+ })
242
+ });
357
243
  server.endpoint({
358
244
  method: 'POST',
359
- path: `/plugin/${this.pluginInstanceId}/get_s3_upload_url`,
245
+ path: `/plugin/${this.pluginInstanceId}/get_file_upload_url`,
360
246
  handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body }) {
361
247
  var _b, _c;
362
248
  const { originalFilename, contentType, size, originalExtension, recordPk } = body;
@@ -371,46 +257,22 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
371
257
  const pkName = (_b = this.resourceConfig.columns.find((column) => column.primaryKey)) === null || _b === void 0 ? void 0 : _b.name;
372
258
  record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(pkName, recordPk)]);
373
259
  }
374
- const s3Path = this.options.s3Path({ originalFilename, originalExtension, contentType, record });
375
- if (s3Path.startsWith('/')) {
260
+ const filePath = this.options.filePath({ originalFilename, originalExtension, contentType, record });
261
+ if (filePath.startsWith('/')) {
376
262
  throw new Error('s3Path should not start with /, please adjust s3path function to not return / at the start of the path');
377
263
  }
378
- const s3 = new S3({
379
- credentials: {
380
- accessKeyId: this.options.s3AccessKeyId,
381
- secretAccessKey: this.options.s3SecretAccessKey,
382
- },
383
- region: this.options.s3Region,
384
- });
385
- const tagline = `${ADMINFORTH_NOT_YET_USED_TAG}=true`;
386
- const params = {
387
- Bucket: this.options.s3Bucket,
388
- Key: s3Path,
389
- ContentType: contentType,
390
- ACL: (this.options.s3ACL || 'private'),
391
- Tagging: tagline,
392
- };
393
- const uploadUrl = yield yield getSignedUrl(s3, new PutObjectCommand(params), {
394
- expiresIn: 1800,
395
- unhoistableHeaders: new Set(['x-amz-tagging']),
396
- });
264
+ const { uploadUrl, uploadExtraParams } = yield this.options.storage.adapter.getUploadSignedUrl(filePath, contentType, 1800);
397
265
  let previewUrl;
398
266
  if ((_c = this.options.preview) === null || _c === void 0 ? void 0 : _c.previewUrl) {
399
- previewUrl = this.options.preview.previewUrl({ s3Path });
400
- }
401
- else if (this.options.s3ACL === 'public-read') {
402
- previewUrl = `https://${this.options.s3Bucket}.s3.${this.options.s3Region}.amazonaws.com/${s3Path}`;
267
+ previewUrl = this.options.preview.previewUrl({ filePath });
403
268
  }
404
269
  else {
405
- previewUrl = yield getSignedUrl(s3, new GetObjectCommand({
406
- Bucket: this.options.s3Bucket,
407
- Key: s3Path,
408
- }));
270
+ previewUrl = yield this.options.storage.adapter.getDownloadUrl(filePath, 1800);
409
271
  }
410
272
  return {
411
273
  uploadUrl,
412
- s3Path,
413
- tagline,
274
+ filePath,
275
+ uploadExtraParams,
414
276
  previewUrl,
415
277
  };
416
278
  })
@@ -467,6 +329,7 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
467
329
  yield new Promise((resolve) => setTimeout(resolve, 2000));
468
330
  return `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
469
331
  }
332
+ const start = +new Date();
470
333
  const resp = yield this.options.generation.adapter.generate({
471
334
  prompt,
472
335
  inputFiles: attachmentFiles,
@@ -478,6 +341,8 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
478
341
  error = resp.error;
479
342
  return;
480
343
  }
344
+ this.totalCalls++;
345
+ this.totalDuration += (+new Date() - start) / 1000;
481
346
  return resp.imageURLs[0];
482
347
  })));
483
348
  return { error, images };
package/index.ts CHANGED
@@ -1,7 +1,5 @@
1
1
 
2
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
3
  import { AdminForthPlugin, AdminForthResourceColumn, AdminForthResource, Filters, IAdminForth, IHttpServer, suggestIfTypo } from "adminforth";
6
4
  import { Readable } from "stream";
7
5
  import { RateLimiter } from "adminforth";
@@ -13,9 +11,16 @@ export default class UploadPlugin extends AdminForthPlugin {
13
11
 
14
12
  adminforth!: IAdminForth;
15
13
 
14
+ totalCalls: number;
15
+ totalDuration: number;
16
+
16
17
  constructor(options: PluginOptions) {
17
18
  super(options, import.meta.url);
18
19
  this.options = options;
20
+
21
+ // for calcualting average time
22
+ this.totalCalls = 0;
23
+ this.totalDuration = 0;
19
24
  }
20
25
 
21
26
  instanceUniqueRepresentation(pluginOptions: any) : string {
@@ -23,76 +28,15 @@ export default class UploadPlugin extends AdminForthPlugin {
23
28
  }
24
29
 
25
30
  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
- }
31
+ this.options.storage.adapter.setupLifecycle();
85
32
  }
86
33
 
87
- async genPreviewUrl(record: any, s3: S3) {
34
+ async genPreviewUrl(record: any) {
88
35
  if (this.options.preview?.previewUrl) {
89
- record[`previewUrl_${this.pluginInstanceId}`] = this.options.preview.previewUrl({ s3Path: record[this.options.pathColumnName] });
36
+ record[`previewUrl_${this.pluginInstanceId}`] = this.options.preview.previewUrl({ filePath: record[this.options.pathColumnName] });
90
37
  return;
91
38
  }
92
- const previewUrl = await await getSignedUrl(s3, new GetObjectCommand({
93
- Bucket: this.options.s3Bucket,
94
- Key: record[this.options.pathColumnName],
95
- }));
39
+ const previewUrl = await this.options.storage.adapter.getDownloadUrl(record[this.options.pathColumnName], 1800);
96
40
 
97
41
  record[`previewUrl_${this.pluginInstanceId}`] = previewUrl;
98
42
  }
@@ -215,23 +159,9 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
215
159
  process.env.HEAVY_DEBUG && console.log('💾💾 after save ', record?.id);
216
160
 
217
161
  if (record[pathColumnName]) {
218
- const s3 = new S3({
219
- credentials: {
220
- accessKeyId: this.options.s3AccessKeyId,
221
- secretAccessKey: this.options.s3SecretAccessKey,
222
- },
223
-
224
- region: this.options.s3Region,
225
- });
226
162
  process.env.HEAVY_DEBUG && console.log('🪥🪥 remove ObjectTagging', record[pathColumnName]);
227
163
  // let it crash if it fails: this is a new file which just was uploaded.
228
- await s3.putObjectTagging({
229
- Bucket: this.options.s3Bucket,
230
- Key: record[pathColumnName],
231
- Tagging: {
232
- TagSet: []
233
- }
234
- });
164
+ await this.options.storage.adapter.markKeyForNotDeletation(record[pathColumnName]);
235
165
  }
236
166
  return { ok: true };
237
167
  });
@@ -248,16 +178,7 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
248
178
  return { ok: true };
249
179
  }
250
180
  if (record[pathColumnName]) {
251
- const s3 = new S3({
252
- credentials: {
253
- accessKeyId: this.options.s3AccessKeyId,
254
- secretAccessKey: this.options.s3SecretAccessKey,
255
- },
256
-
257
- region: this.options.s3Region,
258
- });
259
-
260
- await this.genPreviewUrl(record, s3);
181
+ await this.genPreviewUrl(record)
261
182
  }
262
183
  return { ok: true };
263
184
  });
@@ -268,18 +189,9 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
268
189
 
269
190
  if (pathColumn.showIn.list) {
270
191
  resourceConfig.hooks.list.afterDatasourceResponse.push(async ({ response }: { response: any }) => {
271
- const s3 = new S3({
272
- credentials: {
273
- accessKeyId: this.options.s3AccessKeyId,
274
- secretAccessKey: this.options.s3SecretAccessKey,
275
- },
276
-
277
- region: this.options.s3Region,
278
- });
279
-
280
192
  await Promise.all(response.map(async (record: any) => {
281
193
  if (record[this.options.pathColumnName]) {
282
- await this.genPreviewUrl(record, s3);
194
+ await this.genPreviewUrl(record)
283
195
  }
284
196
  }));
285
197
  return { ok: true };
@@ -291,28 +203,8 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
291
203
  // add delete hook which sets tag adminforth-candidate-for-cleanup to true
292
204
  resourceConfig.hooks.delete.afterSave.push(async ({ record }: { record: any }) => {
293
205
  if (record[pathColumnName]) {
294
- const s3 = new S3({
295
- credentials: {
296
- accessKeyId: this.options.s3AccessKeyId,
297
- secretAccessKey: this.options.s3SecretAccessKey,
298
- },
299
-
300
- region: this.options.s3Region,
301
- });
302
-
303
206
  try {
304
- await s3.putObjectTagging({
305
- Bucket: this.options.s3Bucket,
306
- Key: record[pathColumnName],
307
- Tagging: {
308
- TagSet: [
309
- {
310
- Key: ADMINFORTH_NOT_YET_USED_TAG,
311
- Value: 'true'
312
- }
313
- ]
314
- }
315
- });
207
+ await this.options.storage.adapter.markKeyForDeletation(record[pathColumnName]);
316
208
  } catch (e) {
317
209
  // file might be e.g. already deleted, so we catch error
318
210
  console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${record[pathColumnName]}. File will not be auto-cleaned up`, e);
@@ -338,30 +230,10 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
338
230
  resourceConfig.hooks.edit.afterSave.push(async ({ updates, oldRecord }: { updates: any, oldRecord: any }) => {
339
231
 
340
232
  if (updates[virtualColumn.name] || updates[virtualColumn.name] === null) {
341
- const s3 = new S3({
342
- credentials: {
343
- accessKeyId: this.options.s3AccessKeyId,
344
- secretAccessKey: this.options.s3SecretAccessKey,
345
- },
346
-
347
- region: this.options.s3Region,
348
- });
349
-
350
233
  if (oldRecord[pathColumnName]) {
351
234
  // put tag to delete old file
352
235
  try {
353
- await s3.putObjectTagging({
354
- Bucket: this.options.s3Bucket,
355
- Key: oldRecord[pathColumnName],
356
- Tagging: {
357
- TagSet: [
358
- {
359
- Key: ADMINFORTH_NOT_YET_USED_TAG,
360
- Value: 'true'
361
- }
362
- ]
363
- }
364
- });
236
+ await this.options.storage.adapter.markKeyForDeletation(oldRecord[pathColumnName]);
365
237
  } catch (e) {
366
238
  // file might be e.g. already deleted, so we catch error
367
239
  console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${oldRecord[pathColumnName]}. File will not be auto-cleaned up`, e);
@@ -370,13 +242,7 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
370
242
  if (updates[virtualColumn.name] !== null) {
371
243
  // remove tag from new file
372
244
  // in this case we let it crash if it fails: this is a new file which just was uploaded.
373
- await s3.putObjectTagging({
374
- Bucket: this.options.s3Bucket,
375
- Key: updates[pathColumnName],
376
- Tagging: {
377
- TagSet: []
378
- }
379
- });
245
+ await this.options.storage.adapter.markKeyForNotDeletation(updates[pathColumnName]);
380
246
  }
381
247
  }
382
248
  return { ok: true };
@@ -390,11 +256,24 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
390
256
  // called here because modifyResourceConfig can be called in build time where there is no environment and AWS secrets
391
257
  this.setupLifecycleRule();
392
258
  }
259
+
393
260
 
394
261
  setupEndpoints(server: IHttpServer) {
262
+ server.endpoint({
263
+ method: 'GET',
264
+ path: `/plugin/${this.pluginInstanceId}/averageDuration`,
265
+ handler: async () => {
266
+ return {
267
+ totalCalls: this.totalCalls,
268
+ totalDuration: this.totalDuration,
269
+ averageDuration: this.totalCalls ? this.totalDuration / this.totalCalls : null,
270
+ };
271
+ }
272
+ });
273
+
395
274
  server.endpoint({
396
275
  method: 'POST',
397
- path: `/plugin/${this.pluginInstanceId}/get_s3_upload_url`,
276
+ path: `/plugin/${this.pluginInstanceId}/get_file_upload_url`,
398
277
  handler: async ({ body }) => {
399
278
  const { originalFilename, contentType, size, originalExtension, recordPk } = body;
400
279
 
@@ -413,49 +292,22 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
413
292
  )
414
293
  }
415
294
 
416
- const s3Path: string = this.options.s3Path({ originalFilename, originalExtension, contentType, record });
417
- if (s3Path.startsWith('/')) {
295
+ const filePath: string = this.options.filePath({ originalFilename, originalExtension, contentType, record });
296
+ if (filePath.startsWith('/')) {
418
297
  throw new Error('s3Path should not start with /, please adjust s3path function to not return / at the start of the path');
419
298
  }
420
- const s3 = new S3({
421
- credentials: {
422
- accessKeyId: this.options.s3AccessKeyId,
423
- secretAccessKey: this.options.s3SecretAccessKey,
424
- },
425
-
426
- region: this.options.s3Region,
427
- });
428
-
429
- const tagline = `${ADMINFORTH_NOT_YET_USED_TAG}=true`;
430
- const params = {
431
- Bucket: this.options.s3Bucket,
432
- Key: s3Path,
433
- ContentType: contentType,
434
- ACL: (this.options.s3ACL || 'private') as ObjectCannedACL,
435
- Tagging: tagline,
436
- };
437
-
438
- const uploadUrl = await await getSignedUrl(s3, new PutObjectCommand(params), {
439
- expiresIn: 1800,
440
- unhoistableHeaders: new Set(['x-amz-tagging']),
441
- });
442
-
299
+ const { uploadUrl, uploadExtraParams } = await this.options.storage.adapter.getUploadSignedUrl(filePath, contentType, 1800);
443
300
  let previewUrl;
444
301
  if (this.options.preview?.previewUrl) {
445
- previewUrl = this.options.preview.previewUrl({ s3Path });
446
- } else if (this.options.s3ACL === 'public-read') {
447
- previewUrl = `https://${this.options.s3Bucket}.s3.${this.options.s3Region}.amazonaws.com/${s3Path}`;
302
+ previewUrl = this.options.preview.previewUrl({ filePath });
448
303
  } else {
449
- previewUrl = await getSignedUrl(s3, new GetObjectCommand({
450
- Bucket: this.options.s3Bucket,
451
- Key: s3Path,
452
- }));
304
+ previewUrl = await this.options.storage.adapter.getDownloadUrl(filePath, 1800);
453
305
  }
454
306
 
455
307
  return {
456
308
  uploadUrl,
457
- s3Path,
458
- tagline,
309
+ filePath,
310
+ uploadExtraParams,
459
311
  previewUrl,
460
312
  };
461
313
  }
@@ -527,6 +379,7 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
527
379
  await new Promise((resolve) => setTimeout(resolve, 2000));
528
380
  return `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`;
529
381
  }
382
+ const start = +new Date();
530
383
  const resp = await this.options.generation.adapter.generate(
531
384
  {
532
385
  prompt,
@@ -541,6 +394,9 @@ getBucketLifecycleConfiguration on bucket ${this.options.s3Bucket} in region ${t
541
394
  error = resp.error;
542
395
  return;
543
396
  }
397
+
398
+ this.totalCalls++;
399
+ this.totalDuration += (+new Date() - start) / 1000;
544
400
 
545
401
  return resp.imageURLs[0]
546
402
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/upload",
3
- "version": "1.4.6",
3
+ "version": "1.5.0",
4
4
  "description": "Plugin for uploading files for adminforth",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { AdminUser, ImageGenerationAdapter } from "adminforth";
1
+ import { AdminUser, ImageGenerationAdapter, StorageAdapter } from "adminforth";
2
2
 
3
3
  export type PluginOptions = {
4
4
 
@@ -18,32 +18,6 @@ export type PluginOptions = {
18
18
  */
19
19
  maxFileSize?: number;
20
20
 
21
- /**
22
- * S3 bucket name where we will upload the files, e.g. 'my-bucket'
23
- */
24
- s3Bucket: string,
25
-
26
- /**
27
- * S3 region, e.g. 'us-east-1'
28
- */
29
- s3Region: string,
30
-
31
- /**
32
- * S3 access key id
33
- */
34
- s3AccessKeyId: string,
35
-
36
- /**
37
- * S3 secret access key
38
- */
39
- s3SecretAccessKey: string,
40
-
41
- /**
42
- * ACL which will be set to uploaded file, e.g. 'public-read'.
43
- * 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.
44
- */
45
- s3ACL?: string,
46
-
47
21
  /**
48
22
  * The path where the file will be uploaded to the S3 bucket, same path will be stored in the database
49
23
  * in the column specified in {@link pathColumnName}
@@ -55,7 +29,7 @@ export type PluginOptions = {
55
29
  * ```
56
30
  *
57
31
  */
58
- s3Path: ({originalFilename, originalExtension, contentType, record }: {
32
+ filePath: ({originalFilename, originalExtension, contentType, record }: {
59
33
  originalFilename: string,
60
34
  originalExtension: string,
61
35
  contentType: string,
@@ -113,7 +87,7 @@ export type PluginOptions = {
113
87
  * ```
114
88
  *
115
89
  */
116
- previewUrl?: ({s3Path}) => string,
90
+ previewUrl?: ({filePath}) => string,
117
91
  }
118
92
 
119
93
 
@@ -181,4 +155,12 @@ export type PluginOptions = {
181
155
 
182
156
  }
183
157
 
158
+ storage?: {
159
+ /**
160
+ * The adapter used to store the files.
161
+ * For now only S3 adapter is supported.
162
+ */
163
+ adapter: StorageAdapter,
164
+ }
165
+
184
166
  }