UNPKG

@adminforth/upload

Version:
397 lines (396 loc) 22.1 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { AdminForthPlugin, Filters, suggestIfTypo } from "adminforth"; import { Readable } from "stream"; import { RateLimiter } from "adminforth"; const ADMINFORTH_NOT_YET_USED_TAG = 'adminforth-candidate-for-cleanup'; export default class UploadPlugin extends AdminForthPlugin { constructor(options) { super(options, import.meta.url); this.options = options; // for calcualting average time this.totalCalls = 0; this.totalDuration = 0; } instanceUniqueRepresentation(pluginOptions) { return `${pluginOptions.pathColumnName}`; } setupLifecycleRule() { return __awaiter(this, void 0, void 0, function* () { const adapterUserUniqueRepresentation = `${this.resourceConfig.resourceId}-${this.pluginInstanceId}`; this.options.storageAdapter.setupLifecycle(adapterUserUniqueRepresentation); }); } genPreviewUrl(record) { return __awaiter(this, void 0, void 0, function* () { var _a; if ((_a = this.options.preview) === null || _a === void 0 ? void 0 : _a.previewUrl) { record[`previewUrl_${this.pluginInstanceId}`] = this.options.preview.previewUrl({ filePath: record[this.options.pathColumnName] }); return; } const previewUrl = yield this.options.storageAdapter.getDownloadUrl(record[this.options.pathColumnName], 1800); record[`previewUrl_${this.pluginInstanceId}`] = previewUrl; }); } modifyResourceConfig(adminforth, resourceConfig) { const _super = Object.create(null, { modifyResourceConfig: { get: () => super.modifyResourceConfig } }); return __awaiter(this, void 0, void 0, function* () { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l; _super.modifyResourceConfig.call(this, adminforth, resourceConfig); this.resourceConfig = resourceConfig; // after column to store the path of the uploaded file, add new VirtualColumn, // show only in edit and create views // use component uploader.vue const { pathColumnName } = this.options; const pathColumnIndex = resourceConfig.columns.findIndex((column) => column.name === pathColumnName); if (pathColumnIndex === -1) { throw new Error(`Column with name "${pathColumnName}" not found in resource "${resourceConfig.label}"`); } if ((_a = this.options.generation) === null || _a === void 0 ? void 0 : _a.fieldsForContext) { (_b = this.options.generation) === null || _b === void 0 ? void 0 : _b.fieldsForContext.forEach((field) => { if (!resourceConfig.columns.find((column) => column.name === field)) { const similar = suggestIfTypo(resourceConfig.columns.map((column) => column.name), field); throw new Error(`Field "${field}" specified in fieldsForContext not found in resource "${resourceConfig.label}". ${similar ? `Did you mean "${similar}"?` : ''}`); } }); } const pluginFrontendOptions = { allowedExtensions: this.options.allowedFileExtensions, maxFileSize: this.options.maxFileSize, pluginInstanceId: this.pluginInstanceId, resourceLabel: resourceConfig.label, generateImages: this.options.generation ? true : false, pathColumnLabel: resourceConfig.columns[pathColumnIndex].label, maxWidth: (_c = this.options.preview) === null || _c === void 0 ? void 0 : _c.maxWidth, maxListWidth: (_d = this.options.preview) === null || _d === void 0 ? void 0 : _d.maxListWidth, maxShowWidth: (_e = this.options.preview) === null || _e === void 0 ? void 0 : _e.maxShowWidth, minWidth: (_f = this.options.preview) === null || _f === void 0 ? void 0 : _f.minWidth, minListWidth: (_g = this.options.preview) === null || _g === void 0 ? void 0 : _g.minListWidth, minShowWidth: (_h = this.options.preview) === null || _h === void 0 ? void 0 : _h.minShowWidth, generationPrompt: (_j = this.options.generation) === null || _j === void 0 ? void 0 : _j.generationPrompt, recorPkFieldName: (_k = this.resourceConfig.columns.find((column) => column.primaryKey)) === null || _k === void 0 ? void 0 : _k.name, }; // define components which will be imported from other components this.componentPath('imageGenerator.vue'); const virtualColumn = { virtual: true, name: `uploader_${this.pluginInstanceId}`, components: { edit: { file: this.componentPath('uploader.vue'), meta: pluginFrontendOptions, }, create: { file: this.componentPath('uploader.vue'), meta: pluginFrontendOptions, }, }, showIn: { create: true, edit: true, list: false, show: false, filter: false, }, }; if (!resourceConfig.columns[pathColumnIndex].components) { resourceConfig.columns[pathColumnIndex].components = {}; } const pathColumn = resourceConfig.columns[pathColumnIndex]; // add preview column to list if (((_l = this.options.preview) === null || _l === void 0 ? void 0 : _l.usePreviewComponents) !== false) { resourceConfig.columns[pathColumnIndex].components.list = { file: this.componentPath('preview.vue'), meta: pluginFrontendOptions, }; resourceConfig.columns[pathColumnIndex].components.show = { file: this.componentPath('preview.vue'), meta: pluginFrontendOptions, }; } // insert virtual column after path column if it is not already there const virtualColumnIndex = resourceConfig.columns.findIndex((column) => column.name === virtualColumn.name); if (virtualColumnIndex === -1) { resourceConfig.columns.splice(pathColumnIndex + 1, 0, virtualColumn); } // if showIn of path column has 'create' or 'edit' remove it but use it for virtual column if (pathColumn.showIn && (pathColumn.showIn.create !== undefined)) { virtualColumn.showIn = Object.assign(Object.assign({}, virtualColumn.showIn), { create: pathColumn.showIn.create }); pathColumn.showIn = Object.assign(Object.assign({}, pathColumn.showIn), { create: false }); } if (pathColumn.showIn && (pathColumn.showIn.edit !== undefined)) { virtualColumn.showIn = Object.assign(Object.assign({}, virtualColumn.showIn), { edit: pathColumn.showIn.edit }); pathColumn.showIn = Object.assign(Object.assign({}, pathColumn.showIn), { edit: false }); } virtualColumn.required = pathColumn.required; virtualColumn.label = pathColumn.label; virtualColumn.editingNote = pathColumn.editingNote; // ** HOOKS FOR CREATE **// // add beforeSave hook to save virtual column to path column resourceConfig.hooks.create.beforeSave.push((_a) => __awaiter(this, [_a], void 0, function* ({ record }) { if (record[virtualColumn.name]) { record[pathColumnName] = record[virtualColumn.name]; delete record[virtualColumn.name]; } return { ok: true }; })); // in afterSave hook, aremove tag adminforth-not-yet-used from the file resourceConfig.hooks.create.afterSave.push((_a) => __awaiter(this, [_a], void 0, function* ({ record }) { process.env.HEAVY_DEBUG && console.log('💾💾 after save ', record === null || record === void 0 ? void 0 : record.id); if (record[pathColumnName]) { process.env.HEAVY_DEBUG && console.log('🪥🪥 remove ObjectTagging', record[pathColumnName]); // let it crash if it fails: this is a new file which just was uploaded. yield this.options.storageAdapter.markKeyForNotDeletation(record[pathColumnName]); } return { ok: true }; })); // ** HOOKS FOR SHOW **// // add show hook to get presigned URL if (pathColumn.showIn.show) { resourceConfig.hooks.show.afterDatasourceResponse.push((_a) => __awaiter(this, [_a], void 0, function* ({ response }) { const record = response[0]; if (!record) { return { ok: true }; } if (record[pathColumnName]) { yield this.genPreviewUrl(record); } return { ok: true }; })); } // ** HOOKS FOR LIST **// if (pathColumn.showIn.list) { resourceConfig.hooks.list.afterDatasourceResponse.push((_a) => __awaiter(this, [_a], void 0, function* ({ response }) { yield Promise.all(response.map((record) => __awaiter(this, void 0, void 0, function* () { if (record[this.options.pathColumnName]) { yield this.genPreviewUrl(record); } }))); return { ok: true }; })); } // ** HOOKS FOR DELETE **// // add delete hook which sets tag adminforth-candidate-for-cleanup to true resourceConfig.hooks.delete.afterSave.push((_a) => __awaiter(this, [_a], void 0, function* ({ record }) { if (record[pathColumnName]) { try { yield this.options.storageAdapter.markKeyForDeletation(record[pathColumnName]); } catch (e) { // file might be e.g. already deleted, so we catch error console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${record[pathColumnName]}. File will not be auto-cleaned up`, e); } } return { ok: true }; })); // ** HOOKS FOR EDIT **// // beforeSave resourceConfig.hooks.edit.beforeSave.push((_a) => __awaiter(this, [_a], void 0, function* ({ record }) { // null is when value is removed if (record[virtualColumn.name] || record[virtualColumn.name] === null) { record[pathColumnName] = record[virtualColumn.name]; } return { ok: true }; })); // add edit postSave hook to delete old file and remove tag from new file resourceConfig.hooks.edit.afterSave.push((_a) => __awaiter(this, [_a], void 0, function* ({ updates, oldRecord }) { if (updates[virtualColumn.name] || updates[virtualColumn.name] === null) { if (oldRecord[pathColumnName]) { // put tag to delete old file try { yield this.options.storageAdapter.markKeyForDeletation(oldRecord[pathColumnName]); } catch (e) { // file might be e.g. already deleted, so we catch error console.error(`Error setting tag ${ADMINFORTH_NOT_YET_USED_TAG} to true for object ${oldRecord[pathColumnName]}. File will not be auto-cleaned up`, e); } } if (updates[virtualColumn.name] !== null) { // remove tag from new file // in this case we let it crash if it fails: this is a new file which just was uploaded. yield this.options.storageAdapter.markKeyForNotDeletation(updates[pathColumnName]); } } return { ok: true }; })); }); } validateConfigAfterDiscover(adminforth, resourceConfig) { this.adminforth = adminforth; // called here because modifyResourceConfig can be called in build time where there is no environment and AWS secrets this.setupLifecycleRule(); } setupEndpoints(server) { server.endpoint({ method: 'GET', path: `/plugin/${this.pluginInstanceId}/averageDuration`, handler: () => __awaiter(this, void 0, void 0, function* () { return { totalCalls: this.totalCalls, totalDuration: this.totalDuration, averageDuration: this.totalCalls ? this.totalDuration / this.totalCalls : null, }; }) }); server.endpoint({ method: 'POST', path: `/plugin/${this.pluginInstanceId}/get_file_upload_url`, handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body }) { var _b, _c; const { originalFilename, contentType, size, originalExtension, recordPk } = body; if (this.options.allowedFileExtensions && !this.options.allowedFileExtensions.includes(originalExtension)) { return { error: `File extension "${originalExtension}" is not allowed, allowed extensions are: ${this.options.allowedFileExtensions.join(', ')}` }; } let record = undefined; if (recordPk) { // get record by recordPk const pkName = (_b = this.resourceConfig.columns.find((column) => column.primaryKey)) === null || _b === void 0 ? void 0 : _b.name; record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ(pkName, recordPk)]); } const filePath = this.options.filePath({ originalFilename, originalExtension, contentType, record }); if (filePath.startsWith('/')) { throw new Error('s3Path should not start with /, please adjust s3path function to not return / at the start of the path'); } const { uploadUrl, uploadExtraParams } = yield this.options.storageAdapter.getUploadSignedUrl(filePath, contentType, 1800); let previewUrl; if ((_c = this.options.preview) === null || _c === void 0 ? void 0 : _c.previewUrl) { previewUrl = this.options.preview.previewUrl({ filePath }); } else { previewUrl = yield this.options.storageAdapter.getDownloadUrl(filePath, 1800); } const tagline = `${ADMINFORTH_NOT_YET_USED_TAG}=true`; return { uploadUrl, tagline, filePath, uploadExtraParams, previewUrl, }; }) }); // generation: { // provider: 'openai-dall-e', // countToGenerate: 3, // openAiOptions: { // model: 'dall-e-3', // size: '1792x1024', // apiKey: process.env.OPENAI_API_KEY as string, // }, // }, // curl https://api.openai.com/v1/images/generations \ // -H "Content-Type: application/json" \ // -H "Authorization: Bearer $OPENAI_API_KEY" \ // -d '{ // "model": "dall-e-3", // "prompt": "A cute baby sea otter", // "n": 1, // "size": "1024x1024" // }' server.endpoint({ method: 'POST', path: `/plugin/${this.pluginInstanceId}/generate_images`, handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser, headers }) { var _b, _c, _d; const { prompt, recordId } = body; if ((_b = this.options.generation.rateLimit) === null || _b === void 0 ? void 0 : _b.limit) { // rate limit const { error } = RateLimiter.checkRateLimit(this.pluginInstanceId, (_c = this.options.generation.rateLimit) === null || _c === void 0 ? void 0 : _c.limit, this.adminforth.auth.getClientIp(headers)); if (error) { return { error: this.options.generation.rateLimit.errorMessage }; } } let attachmentFiles = []; if (this.options.generation.attachFiles) { // TODO - does it require additional allowed action to check this record id has access to get the image? // or should we mention in docs that user should do validation in method itself const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([Filters.EQ((_d = this.resourceConfig.columns.find((column) => column.primaryKey)) === null || _d === void 0 ? void 0 : _d.name, recordId)]); if (!record) { return { error: `Record with id ${recordId} not found` }; } attachmentFiles = yield this.options.generation.attachFiles({ record, adminUser }); // if files is not array, make it array if (!Array.isArray(attachmentFiles)) { attachmentFiles = [attachmentFiles]; } } let error = undefined; const STUB_MODE = false; const images = yield Promise.all((new Array(this.options.generation.countToGenerate)).fill(0).map(() => __awaiter(this, void 0, void 0, function* () { if (STUB_MODE) { yield new Promise((resolve) => setTimeout(resolve, 2000)); return `https://picsum.photos/200/300?random=${Math.floor(Math.random() * 1000)}`; } const start = +new Date(); let resp; try { resp = yield this.options.generation.adapter.generate({ prompt, inputFiles: attachmentFiles, n: 1, size: this.options.generation.outputSize, }); } catch (e) { error = `No response from image generation provider: ${e.message}. Please check your prompt or try again later.`; return; } if (resp.error) { console.error('Error generating image', resp.error); error = resp.error; return; } this.totalCalls++; this.totalDuration += (+new Date() - start) / 1000; return resp.imageURLs[0]; }))); return { error, images }; }) }); server.endpoint({ method: 'GET', path: `/plugin/${this.pluginInstanceId}/cors-proxy`, handler: (_a) => __awaiter(this, [_a], void 0, function* ({ query, response }) { const { url } = query; const resp = yield fetch(url); response.setHeader('Content-Type', resp.headers.get('Content-Type')); //@ts-ignore Readable.fromWeb(resp.body).pipe(response.blobStream()); return null; }) }); server.endpoint({ method: 'POST', path: `/plugin/${this.pluginInstanceId}/get_attachment_files`, handler: (_a) => __awaiter(this, [_a], void 0, function* ({ body, adminUser }) { var _b; const { recordId } = body; if (!recordId) return { error: 'Missing recordId' }; const record = yield this.adminforth.resource(this.resourceConfig.resourceId).get([ Filters.EQ((_b = this.resourceConfig.columns.find((col) => col.primaryKey)) === null || _b === void 0 ? void 0 : _b.name, recordId), ]); if (!record) return { error: 'Record not found' }; if (!this.options.generation.attachFiles) return { files: [] }; const files = yield this.options.generation.attachFiles({ record, adminUser }); return { files: Array.isArray(files) ? files : [files], }; }), }); } }