198 lines
5.7 KiB
TypeScript

/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Context, Next } from '@nocobase/actions';
import { koaMulter as multer } from '@nocobase/utils';
import Path from 'path';
import Plugin from '..';
import {
FILE_FIELD_NAME,
FILE_SIZE_LIMIT_DEFAULT,
FILE_SIZE_LIMIT_MAX,
FILE_SIZE_LIMIT_MIN,
LIMIT_FILES,
} from '../../constants';
import * as Rules from '../rules';
// TODO(optimize): 需要优化错误处理,计算失败后需要抛出对应错误,以便程序处理
function getFileFilter(storage) {
return (req, file, cb) => {
// size 交给 limits 处理
const { size, ...rules } = storage.rules;
const ruleKeys = Object.keys(rules);
const result =
!ruleKeys.length || !ruleKeys.some((key) => typeof Rules[key] !== 'function' || !Rules[key](file, rules[key]));
cb(null, result);
};
}
export function getFileData(ctx: Context) {
const { [FILE_FIELD_NAME]: file, storage } = ctx;
if (!file) {
return ctx.throw(400, 'file validation failed');
}
const storageConfig = ctx.app.pm.get(Plugin).storageTypes.get(storage.type);
const { [storageConfig.filenameKey || 'filename']: name } = file;
// make compatible filename across cloud service (with path)
const filename = Path.basename(name);
const extname = Path.extname(filename);
const path = (storage.path || '').replace(/^\/|\/$/g, '');
const baseUrl = storage.baseUrl.replace(/\/+$/, '');
const pathname = [path, filename].filter(Boolean).join('/');
return {
title: Buffer.from(file.originalname, 'latin1').toString('utf8').replace(extname, ''),
filename,
extname,
// TODO(feature): 暂时两者相同,后面 storage.path 模版化以后,这里只是 file 实际的 path
path,
size: file.size,
// 直接缓存起来
url: `${baseUrl}/${pathname}`,
mimetype: file.mimetype,
// @ts-ignore
meta: ctx.request.body,
storageId: storage.id,
...(storageConfig.getFileData ? storageConfig.getFileData(file) : {}),
};
}
async function multipart(ctx: Context, next: Next) {
const { storage } = ctx;
if (!storage) {
ctx.logger.error('[file-manager] no linked or default storage provided');
return ctx.throw(500);
}
const storageConfig = ctx.app.pm.get(Plugin).storageTypes.get(storage.type);
if (!storageConfig) {
ctx.logger.error(`[file-manager] storage type "${storage.type}" is not defined`);
return ctx.throw(500);
}
const multerOptions = {
fileFilter: getFileFilter(storage),
limits: {
// 每次只允许提交一个文件
files: LIMIT_FILES,
},
storage: storageConfig.make(storage),
};
multerOptions.limits['fileSize'] = Math.min(
Math.max(FILE_SIZE_LIMIT_MIN, storage.rules.size ?? FILE_SIZE_LIMIT_DEFAULT),
FILE_SIZE_LIMIT_MAX,
);
const upload = multer(multerOptions).single(FILE_FIELD_NAME);
try {
// NOTE: empty next and invoke after success
await upload(ctx, () => {});
} catch (err) {
if (err.name === 'MulterError') {
return ctx.throw(400, err);
}
ctx.logger.error(err);
return ctx.throw(500);
}
const values = getFileData(ctx);
ctx.action.mergeParams({
values,
});
await next();
}
export async function createMiddleware(ctx: Context, next: Next) {
const { resourceName, actionName } = ctx.action;
const { attachmentField } = ctx.action.params;
const collection = ctx.db.getCollection(resourceName);
if (collection?.options?.template !== 'file' || !['upload', 'create'].includes(actionName)) {
return next();
}
const storageName = ctx.db.getFieldByPath(attachmentField)?.options?.storage || collection.options.storage;
const StorageRepo = ctx.db.getRepository('storages');
const storage = await StorageRepo.findOne({ filter: storageName ? { name: storageName } : { default: true } });
ctx.storage = storage;
await multipart(ctx, next);
}
export async function destroyMiddleware(ctx: Context, next: Next) {
const { resourceName, actionName, sourceId } = ctx.action;
const collection = ctx.db.getCollection(resourceName);
if (collection?.options?.template !== 'file' || actionName !== 'destroy') {
return next();
}
const repository = ctx.db.getRepository(resourceName, sourceId);
const { filterByTk, filter } = ctx.action.params;
const records = await repository.find({
filterByTk,
filter,
context: ctx,
});
const storageIds = new Set(records.map((record) => record.storageId));
const storageGroupedRecords = records.reduce((result, record) => {
const storageId = record.storageId;
if (!result[storageId]) {
result[storageId] = [];
}
result[storageId].push(record);
return result;
}, {});
const storages = await ctx.db.getRepository('storages').find({
filter: {
id: [...storageIds] as any[],
paranoid: {
$ne: true,
},
},
});
let count = 0;
const undeleted = [];
await storages.reduce(
(promise, storage) =>
promise.then(async () => {
const storageConfig = ctx.app.pm.get(Plugin).storageTypes.get(storage.type);
const result = await storageConfig.delete(storage, storageGroupedRecords[storage.id]);
count += result[0];
undeleted.push(...result[1]);
}),
Promise.resolve(),
);
if (undeleted.length) {
const ids = undeleted.map((record) => record.id);
ctx.action.mergeParams({
filter: {
id: {
$notIn: ids,
},
},
});
ctx.logger.error('[file-manager] some of attachment files are not successfully deleted: ', { ids });
}
await next();
}