mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-09 15:39:24 +08:00
Merge branch 'next' into develop
This commit is contained in:
commit
016a996b5e
@ -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 = '<>?*~\\/';
|
@ -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 =
|
||||
|
@ -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);
|
||||
|
@ -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({
|
||||
|
@ -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)) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user