@adminforth/upload
Version:
Plugin for uploading files for adminforth
397 lines (396 loc) • 22.1 kB
JavaScript
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],
};
}),
});
}
}