Merge branch 'next' into develop

This commit is contained in:
nocobase[bot] 2025-03-25 13:35:32 +00:00
commit 016a996b5e
5 changed files with 113 additions and 102 deletions

View File

@ -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 = '<>?*~\\/';

View File

@ -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 =

View File

@ -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);

View File

@ -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,
if (!this.storagesCache.size) {
await this.loadStorages();
}
: {
default: true,
},
});
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({

View File

@ -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<string> {
// 兼容历史数据
if (file.url && isURL(file.url)) {