diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/common/constants.ts b/packages/plugins/@nocobase/plugin-file-manager/src/common/constants.ts new file mode 100644 index 0000000000..a40cc65d15 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-file-manager/src/common/constants.ts @@ -0,0 +1,10 @@ +/** + * 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. + */ + +export const INVALID_FILENAME_CHARS = '<>?*~\\/'; diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/action.test.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/action.test.ts index a11639db3d..a42adfaba2 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/action.test.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/action.test.ts @@ -317,6 +317,50 @@ describe('action', () => { expect(content.text.includes('Hello world!')).toBe(true); }); + it('path with heading or tailing slash', async () => { + const BASE_URL = `/storage/uploads/another`; + const urlPath = 'test/path'; + + // 动态添加 storage + const storage = await StorageRepo.create({ + values: { + name: 'local_private', + type: STORAGE_TYPE_LOCAL, + rules: { + mimetype: ['text/*'], + }, + path: `/${urlPath}//`, + baseUrl: BASE_URL, + options: { + documentRoot: 'storage/uploads/another', + }, + }, + }); + + db.collection({ + name: 'customers', + fields: [ + { + name: 'file', + type: 'belongsTo', + target: 'attachments', + storage: storage.name, + }, + ], + }); + + const { body } = await agent.resource('attachments').create({ + attachmentField: 'customers.file', + file: path.resolve(__dirname, './files/text.txt'), + }); + + // 文件的 url 是否正常生成 + expect(body.data.url).toBe(`${BASE_URL}/${urlPath}/${body.data.filename}`); + const url = body.data.url.replace(`http://localhost:${APP_PORT}`, ''); + const content = await agent.get(url); + expect(content.text.includes('Hello world!')).toBe(true); + }); + it('path longer than 255', async () => { const BASE_URL = `/storage/uploads/another`; const urlPath = diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/attachments.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/attachments.ts index e21544fd43..b84c5175f1 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/attachments.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/attachments.ts @@ -28,43 +28,6 @@ function getFileFilter(storage) { }; } -export async function getFileData(ctx: Context) { - const { [FILE_FIELD_NAME]: file, storage } = ctx; - if (!file) { - return ctx.throw(400, 'file validation failed'); - } - - const plugin = ctx.app.pm.get(Plugin); - const StorageType = plugin.storageTypes.get(storage.type) as StorageClassType; - const { [StorageType.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, ''); - - let storageInstance = plugin.storagesCache.get(storage.id); - - if (!storageInstance) { - await plugin.loadStorages(); - storageInstance = plugin.storagesCache.get(storage.id); - } - - const data = { - title: Buffer.from(file.originalname, 'latin1').toString('utf8').replace(extname, ''), - filename, - extname, - // TODO(feature): 暂时两者相同,后面 storage.path 模版化以后,这里只是 file 实际的 path - path, - size: file.size, - mimetype: file.mimetype, - meta: ctx.request.body, - storageId: storage.id, - ...StorageType?.['getFileData']?.(file), - }; - - return data; -} - async function multipart(ctx: Context, next: Next) { const { storage } = ctx; if (!storage) { @@ -101,7 +64,12 @@ async function multipart(ctx: Context, next: Next) { return ctx.throw(500, err); } - const values = await getFileData(ctx); + const { [FILE_FIELD_NAME]: file } = ctx; + if (!file) { + return ctx.throw(400, 'file validation failed'); + } + + const values = storageInstance.getFileData(file, ctx.request.body); ctx.action.mergeParams({ values, @@ -119,12 +87,10 @@ export async function createMiddleware(ctx: Context, next: Next) { return next(); } + const plugin = ctx.app.pm.get(Plugin) as Plugin; 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 } }); - - const plugin = ctx.app.pm.get(Plugin); - ctx.storage = plugin.parseStorage(storage); + const storages = Array.from(plugin.storagesCache.values()); + ctx.storage = storages.find((item) => item.name === storageName) || storages.find((item) => item.default); if (ctx?.request.is('multipart/*')) { await multipart(ctx, next); diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/server.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/server.ts index a5dc7f2a30..aaef4c1e79 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/server.ts @@ -7,18 +7,16 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { basename } from 'path'; +import fs from 'fs'; + import { Plugin } from '@nocobase/server'; import { isURL, Registry } from '@nocobase/utils'; - -import { basename } from 'path'; - import { Collection, Model, Transactionable } from '@nocobase/database'; -import fs from 'fs'; import { STORAGE_TYPE_ALI_OSS, STORAGE_TYPE_LOCAL, STORAGE_TYPE_S3, STORAGE_TYPE_TX_COS } from '../constants'; import initActions from './actions'; -import { getFileData } from './actions/attachments'; import { AttachmentInterface } from './interfaces/attachment-interface'; -import { AttachmentModel, StorageClassType, StorageModel, StorageType } from './storages'; +import { AttachmentModel, StorageClassType, StorageModel } from './storages'; import StorageTypeAliOss from './storages/ali-oss'; import StorageTypeLocal from './storages/local'; import StorageTypeS3 from './storages/s3'; @@ -106,39 +104,31 @@ export class PluginFileManagerServer extends Plugin { async uploadFile(options: UploadFileOptions) { const { storageName, filePath, documentRoot } = options; - const storageRepository = this.db.getRepository('storages'); - let storageInstance; - storageInstance = await storageRepository.findOne({ - filter: storageName - ? { - name: storageName, - } - : { - default: true, - }, - }); + if (!this.storagesCache.size) { + await this.loadStorages(); + } + const storages = Array.from(this.storagesCache.values()); + const storage = storages.find((item) => item.name === storageName) || storages.find((item) => item.default); - const fileStream = fs.createReadStream(filePath); - - if (!storageInstance) { + if (!storage) { throw new Error('[file-manager] no linked or default storage provided'); } - storageInstance = this.parseStorage(storageInstance); + const fileStream = fs.createReadStream(filePath); if (documentRoot) { - storageInstance.options['documentRoot'] = documentRoot; + storage.options['documentRoot'] = documentRoot; } - const storageType = this.storageTypes.get(storageInstance.type); - const storage = new storageType(storageInstance); + const StorageType = this.storageTypes.get(storage.type); + const storageInstance = new StorageType(storage); - if (!storage) { - throw new Error(`[file-manager] storage type "${storageInstance.type}" is not defined`); + if (!storageInstance) { + throw new Error(`[file-manager] storage type "${storage.type}" is not defined`); } - const engine = storage.make(); + const engine = storageInstance.make(); const file = { originalname: basename(filePath), @@ -156,7 +146,7 @@ export class PluginFileManagerServer extends Plugin { }); }); - return getFileData({ app: this.app, file, storage: storageInstance, request: { body: {} } } as any); + return storageInstance.getFileData(file, {}); } async loadStorages(options?: { transaction: any }) { @@ -195,17 +185,8 @@ export class PluginFileManagerServer extends Plugin { } async handleSyncMessage(message) { - if (message.type === 'storageChange') { - const storage = await this.db.getRepository('storages').findOne({ - filterByTk: message.storageId, - }); - if (storage) { - this.storagesCache.set(storage.id, this.parseStorage(storage)); - } - } - if (message.type === 'storageRemove') { - const id = message.storageId; - this.storagesCache.delete(id); + if (message.type === 'reloadStorages') { + await this.loadStorages(); } } @@ -244,17 +225,11 @@ export class PluginFileManagerServer extends Plugin { this.storageTypes.register(STORAGE_TYPE_TX_COS, StorageTypeTxCos); const Storage = this.db.getModel('storages'); - Storage.afterSave((m, { transaction }) => { - this.storagesCache.set(m.id, m.toJSON()); - this.sendSyncMessage( - { - type: 'storageChange', - storageId: m.id, - }, - { transaction }, - ); + Storage.afterSave(async (m, { transaction }) => { + await this.loadStorages({ transaction }); + this.sendSyncMessage({ type: 'reloadStorages' }, { transaction }); }); - Storage.afterDestroy((m, { transaction }) => { + Storage.afterDestroy(async (m, { transaction }) => { for (const collection of this.db.collections.values()) { if (collection?.options?.template === 'file' && collection?.options?.storage === m.name) { throw new Error( @@ -264,14 +239,8 @@ export class PluginFileManagerServer extends Plugin { ); } } - this.storagesCache.delete(m.id); - this.sendSyncMessage( - { - type: 'storageRemove', - storageId: m.id, - }, - { transaction }, - ); + await this.loadStorages({ transaction }); + this.sendSyncMessage({ type: 'reloadStorages' }, { transaction }); }); this.app.acl.registerSnippet({ diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/index.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/index.ts index 355fb4ea00..6c6cc825d9 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/index.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/index.ts @@ -7,9 +7,10 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { isURL } from '@nocobase/utils'; +import Path from 'path'; import { StorageEngine } from 'multer'; import urlJoin from 'url-join'; +import { isURL } from '@nocobase/utils'; import { encodeURL, ensureUrlEncoded, getFileKey } from '../utils'; export interface StorageModel { @@ -46,7 +47,28 @@ export abstract class StorageType { return getFileKey(record); } - getFileData?(file: { [key: string]: any }): { [key: string]: any }; + getFileData(file, meta = {}) { + const { [(this.constructor as typeof StorageType).filenameKey || 'filename']: name } = file; + // make compatible filename across cloud service (with path) + const filename = Path.basename(name); + const extname = Path.extname(filename); + const path = (this.storage.path || '').replace(/^\/|\/$/g, ''); + + const data = { + title: Buffer.from(file.originalname, 'latin1').toString('utf8').replace(extname, ''), + filename, + extname, + // TODO(feature): 暂时两者相同,后面 storage.path 模版化以后,这里只是 file 实际的 path + path, + size: file.size, + mimetype: file.mimetype, + meta, + storageId: this.storage.id, + }; + + return data; + } + getFileURL(file: AttachmentModel, preview?: boolean): string | Promise { // 兼容历史数据 if (file.url && isURL(file.url)) {