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);
|
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 () => {
|
it('path longer than 255', async () => {
|
||||||
const BASE_URL = `/storage/uploads/another`;
|
const BASE_URL = `/storage/uploads/another`;
|
||||||
const urlPath =
|
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) {
|
async function multipart(ctx: Context, next: Next) {
|
||||||
const { storage } = ctx;
|
const { storage } = ctx;
|
||||||
if (!storage) {
|
if (!storage) {
|
||||||
@ -101,7 +64,12 @@ async function multipart(ctx: Context, next: Next) {
|
|||||||
return ctx.throw(500, err);
|
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({
|
ctx.action.mergeParams({
|
||||||
values,
|
values,
|
||||||
@ -119,12 +87,10 @@ export async function createMiddleware(ctx: Context, next: Next) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plugin = ctx.app.pm.get(Plugin) as Plugin;
|
||||||
const storageName = ctx.db.getFieldByPath(attachmentField)?.options?.storage || collection.options.storage;
|
const storageName = ctx.db.getFieldByPath(attachmentField)?.options?.storage || collection.options.storage;
|
||||||
const StorageRepo = ctx.db.getRepository('storages');
|
const storages = Array.from(plugin.storagesCache.values());
|
||||||
const storage = await StorageRepo.findOne({ filter: storageName ? { name: storageName } : { default: true } });
|
ctx.storage = storages.find((item) => item.name === storageName) || storages.find((item) => item.default);
|
||||||
|
|
||||||
const plugin = ctx.app.pm.get(Plugin);
|
|
||||||
ctx.storage = plugin.parseStorage(storage);
|
|
||||||
|
|
||||||
if (ctx?.request.is('multipart/*')) {
|
if (ctx?.request.is('multipart/*')) {
|
||||||
await multipart(ctx, next);
|
await multipart(ctx, next);
|
||||||
|
@ -7,18 +7,16 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* 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 { Plugin } from '@nocobase/server';
|
||||||
import { isURL, Registry } from '@nocobase/utils';
|
import { isURL, Registry } from '@nocobase/utils';
|
||||||
|
|
||||||
import { basename } from 'path';
|
|
||||||
|
|
||||||
import { Collection, Model, Transactionable } from '@nocobase/database';
|
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 { STORAGE_TYPE_ALI_OSS, STORAGE_TYPE_LOCAL, STORAGE_TYPE_S3, STORAGE_TYPE_TX_COS } from '../constants';
|
||||||
import initActions from './actions';
|
import initActions from './actions';
|
||||||
import { getFileData } from './actions/attachments';
|
|
||||||
import { AttachmentInterface } from './interfaces/attachment-interface';
|
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 StorageTypeAliOss from './storages/ali-oss';
|
||||||
import StorageTypeLocal from './storages/local';
|
import StorageTypeLocal from './storages/local';
|
||||||
import StorageTypeS3 from './storages/s3';
|
import StorageTypeS3 from './storages/s3';
|
||||||
@ -106,39 +104,31 @@ export class PluginFileManagerServer extends Plugin {
|
|||||||
|
|
||||||
async uploadFile(options: UploadFileOptions) {
|
async uploadFile(options: UploadFileOptions) {
|
||||||
const { storageName, filePath, documentRoot } = options;
|
const { storageName, filePath, documentRoot } = options;
|
||||||
const storageRepository = this.db.getRepository('storages');
|
|
||||||
let storageInstance;
|
|
||||||
|
|
||||||
storageInstance = await storageRepository.findOne({
|
if (!this.storagesCache.size) {
|
||||||
filter: storageName
|
await this.loadStorages();
|
||||||
? {
|
}
|
||||||
name: storageName,
|
const storages = Array.from(this.storagesCache.values());
|
||||||
}
|
const storage = storages.find((item) => item.name === storageName) || storages.find((item) => item.default);
|
||||||
: {
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fileStream = fs.createReadStream(filePath);
|
if (!storage) {
|
||||||
|
|
||||||
if (!storageInstance) {
|
|
||||||
throw new Error('[file-manager] no linked or default storage provided');
|
throw new Error('[file-manager] no linked or default storage provided');
|
||||||
}
|
}
|
||||||
|
|
||||||
storageInstance = this.parseStorage(storageInstance);
|
const fileStream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
if (documentRoot) {
|
if (documentRoot) {
|
||||||
storageInstance.options['documentRoot'] = documentRoot;
|
storage.options['documentRoot'] = documentRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storageType = this.storageTypes.get(storageInstance.type);
|
const StorageType = this.storageTypes.get(storage.type);
|
||||||
const storage = new storageType(storageInstance);
|
const storageInstance = new StorageType(storage);
|
||||||
|
|
||||||
if (!storage) {
|
if (!storageInstance) {
|
||||||
throw new Error(`[file-manager] storage type "${storageInstance.type}" is not defined`);
|
throw new Error(`[file-manager] storage type "${storage.type}" is not defined`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const engine = storage.make();
|
const engine = storageInstance.make();
|
||||||
|
|
||||||
const file = {
|
const file = {
|
||||||
originalname: basename(filePath),
|
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 }) {
|
async loadStorages(options?: { transaction: any }) {
|
||||||
@ -195,17 +185,8 @@ export class PluginFileManagerServer extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleSyncMessage(message) {
|
async handleSyncMessage(message) {
|
||||||
if (message.type === 'storageChange') {
|
if (message.type === 'reloadStorages') {
|
||||||
const storage = await this.db.getRepository('storages').findOne({
|
await this.loadStorages();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,17 +225,11 @@ export class PluginFileManagerServer extends Plugin {
|
|||||||
this.storageTypes.register(STORAGE_TYPE_TX_COS, StorageTypeTxCos);
|
this.storageTypes.register(STORAGE_TYPE_TX_COS, StorageTypeTxCos);
|
||||||
|
|
||||||
const Storage = this.db.getModel('storages');
|
const Storage = this.db.getModel('storages');
|
||||||
Storage.afterSave((m, { transaction }) => {
|
Storage.afterSave(async (m, { transaction }) => {
|
||||||
this.storagesCache.set(m.id, m.toJSON());
|
await this.loadStorages({ transaction });
|
||||||
this.sendSyncMessage(
|
this.sendSyncMessage({ type: 'reloadStorages' }, { transaction });
|
||||||
{
|
|
||||||
type: 'storageChange',
|
|
||||||
storageId: m.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
Storage.afterDestroy((m, { transaction }) => {
|
Storage.afterDestroy(async (m, { transaction }) => {
|
||||||
for (const collection of this.db.collections.values()) {
|
for (const collection of this.db.collections.values()) {
|
||||||
if (collection?.options?.template === 'file' && collection?.options?.storage === m.name) {
|
if (collection?.options?.template === 'file' && collection?.options?.storage === m.name) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@ -264,14 +239,8 @@ export class PluginFileManagerServer extends Plugin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.storagesCache.delete(m.id);
|
await this.loadStorages({ transaction });
|
||||||
this.sendSyncMessage(
|
this.sendSyncMessage({ type: 'reloadStorages' }, { transaction });
|
||||||
{
|
|
||||||
type: 'storageRemove',
|
|
||||||
storageId: m.id,
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.acl.registerSnippet({
|
this.app.acl.registerSnippet({
|
||||||
|
@ -7,9 +7,10 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* 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 { StorageEngine } from 'multer';
|
||||||
import urlJoin from 'url-join';
|
import urlJoin from 'url-join';
|
||||||
|
import { isURL } from '@nocobase/utils';
|
||||||
import { encodeURL, ensureUrlEncoded, getFileKey } from '../utils';
|
import { encodeURL, ensureUrlEncoded, getFileKey } from '../utils';
|
||||||
|
|
||||||
export interface StorageModel {
|
export interface StorageModel {
|
||||||
@ -46,7 +47,28 @@ export abstract class StorageType {
|
|||||||
return getFileKey(record);
|
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> {
|
getFileURL(file: AttachmentModel, preview?: boolean): string | Promise<string> {
|
||||||
// 兼容历史数据
|
// 兼容历史数据
|
||||||
if (file.url && isURL(file.url)) {
|
if (file.url && isURL(file.url)) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user