diff --git a/packages/core/client/src/schema-component/antd/input/ReadPretty.tsx b/packages/core/client/src/schema-component/antd/input/ReadPretty.tsx index db05abb50e..b6cf3976f6 100644 --- a/packages/core/client/src/schema-component/antd/input/ReadPretty.tsx +++ b/packages/core/client/src/schema-component/antd/input/ReadPretty.tsx @@ -200,32 +200,11 @@ export interface URLReadPrettyProps { const ellipsisStyle = { textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', display: 'block' }; -function encodeFileURL(url: string): string { - if (!url) { - return url; - } - - if (url.includes('X-Amz-Content-Sha256')) { - return url; - } - - const parts = url.split('/'); - const filename = parts.pop(); - parts.push(encodeURIComponent(filename)); - const encodedURL = parts.join('/'); - return encodedURL; -} - ReadPretty.URL = (props: URLReadPrettyProps) => { // eslint-disable-next-line react-hooks/rules-of-hooks const prefixCls = usePrefixCls('description-url', props); const content = props.value && ( - + {props.value} ); diff --git a/packages/core/client/src/schema-component/antd/upload/Upload.tsx b/packages/core/client/src/schema-component/antd/upload/Upload.tsx index 37a2f1ca8e..728e1f70ad 100644 --- a/packages/core/client/src/schema-component/antd/upload/Upload.tsx +++ b/packages/core/client/src/schema-component/antd/upload/Upload.tsx @@ -32,7 +32,6 @@ import { toValueItem as toValueItemDefault, useBeforeUpload, useUploadProps, - encodeFileURL, } from './shared'; import { useStyles } from './style'; import type { ComposedUpload, DraggerProps, DraggerV2Props, UploadProps } from './type'; @@ -43,10 +42,10 @@ attachmentFileTypes.add({ }, getThumbnailURL(file) { if (file.preview) { - return encodeFileURL(file.preview); + return file.preview; } if (file.url) { - return encodeFileURL(`${file.url}${file.thumbnailRule || ''}`); + return file.url; } if (file.originFileObj) { return URL.createObjectURL(file.originFileObj); @@ -65,9 +64,9 @@ attachmentFileTypes.add({ return ( onSwitchIndex(null)} onMovePrevRequest={() => onSwitchIndex((index + list.length - 1) % list.length)} onMoveNextRequest={() => onSwitchIndex((index + 1) % list.length)} @@ -95,7 +94,7 @@ const iframePreviewSupportedTypes = ['application/pdf', 'audio/*', 'image/*', 'v function IframePreviewer({ index, list, onSwitchIndex }) { const { t } = useTranslation(); const file = list[index]; - const url = encodeFileURL(file.url); + const url = file.url; const onOpen = useCallback( (e) => { e.preventDefault(); @@ -264,7 +263,7 @@ function AttachmentListItem(props) { ) : null, ]; const wrappedItem = file.url ? ( - + {item} ) : ( diff --git a/packages/core/client/src/schema-component/antd/upload/index.ts b/packages/core/client/src/schema-component/antd/upload/index.ts index 9fc60dfbce..cd0ff5dd7f 100644 --- a/packages/core/client/src/schema-component/antd/upload/index.ts +++ b/packages/core/client/src/schema-component/antd/upload/index.ts @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -export { attachmentFileTypes, encodeFileURL } from './shared'; +export { attachmentFileTypes } from './shared'; export { useUploadStyles } from './style'; export * from './Upload'; diff --git a/packages/core/client/src/schema-component/antd/upload/shared.ts b/packages/core/client/src/schema-component/antd/upload/shared.ts index b48a71054b..5bc559f988 100644 --- a/packages/core/client/src/schema-component/antd/upload/shared.ts +++ b/packages/core/client/src/schema-component/antd/upload/shared.ts @@ -267,20 +267,3 @@ export function useBeforeUpload(rules) { [rules], ); } - -export function encodeFileURL(url: string): string { - if (!url) { - return url; - } - - if (url.includes('X-Amz-Content-Sha256')) { - return url; - } - - const [base, search = ''] = url.split('?'); - const parts = base.split('/'); - const filename = parts.pop(); - parts.push(encodeURIComponent(filename)); - const encodedURL = `${parts.join('/')}${search ? `?${search}` : ''}`; - return encodedURL; -} diff --git a/packages/plugins/@nocobase/plugin-action-export/src/server/__tests__/export-to-xlsx.test.ts b/packages/plugins/@nocobase/plugin-action-export/src/server/__tests__/export-to-xlsx.test.ts index 43844ca361..dea15554cc 100644 --- a/packages/plugins/@nocobase/plugin-action-export/src/server/__tests__/export-to-xlsx.test.ts +++ b/packages/plugins/@nocobase/plugin-action-export/src/server/__tests__/export-to-xlsx.test.ts @@ -7,14 +7,14 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { BaseInterface } from '@nocobase/database'; import { createMockServer, MockServer } from '@nocobase/test'; import { uid } from '@nocobase/utils'; -import { XlsxExporter } from '../services/xlsx-exporter'; -import XLSX from 'xlsx'; import fs from 'fs'; -import path from 'path'; -import { BaseInterface } from '@nocobase/database'; import moment from 'moment'; +import path from 'path'; +import XLSX from 'xlsx'; +import { XlsxExporter } from '../services/xlsx-exporter'; XLSX.set_fs(fs); @@ -391,13 +391,14 @@ describe('export to xlsx with preset', () => { extname: '.png', mimetype: 'image/png', url: 'https://nocobase.oss-cn-beijing.aliyuncs.com/test1.png', + storageId: 1, }, { title: 'nocobase-logo2', filename: '682e5ad037dd02a0fe4800a3e91c283b.png', extname: '.png', mimetype: 'image/png', - url: 'https://nocobase.oss-cn-beijing.aliyuncs.com/test2.png', + storageId: 1, }, ], }, @@ -432,7 +433,7 @@ describe('export to xlsx with preset', () => { const firstUser = sheetData[1]; expect(firstUser[1]).toEqual( - 'https://nocobase.oss-cn-beijing.aliyuncs.com/test1.png,https://nocobase.oss-cn-beijing.aliyuncs.com/test2.png', + 'https://nocobase.oss-cn-beijing.aliyuncs.com/test1.png,/storage/uploads/682e5ad037dd02a0fe4800a3e91c283b.png', ); } finally { fs.unlinkSync(xlsxFilePath); diff --git a/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client/components/Edit.tsx b/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client/components/Edit.tsx index ba70f6bc97..51adb9b37b 100644 --- a/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client/components/Edit.tsx +++ b/packages/plugins/@nocobase/plugin-field-markdown-vditor/src/client/components/Edit.tsx @@ -7,7 +7,7 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { encodeFileURL, useAPIClient, useApp, withDynamicSchemaProps } from '@nocobase/client'; +import { useAPIClient, useApp, withDynamicSchemaProps } from '@nocobase/client'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import Vditor from 'vditor'; import { defaultToolbar } from '../interfaces/markdown-vditor'; @@ -81,7 +81,7 @@ export const Edit = withDynamicSchemaProps((props) => { data: { errFiles: [], succMap: { - [response.data.filename]: encodeFileURL(response.data.url), + [response.data.filename]: response.data.url, }, }, }; diff --git a/packages/plugins/@nocobase/plugin-file-manager/package.json b/packages/plugins/@nocobase/plugin-file-manager/package.json index 3a89c17f12..ac7d84f3cd 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/package.json +++ b/packages/plugins/@nocobase/plugin-file-manager/package.json @@ -10,7 +10,7 @@ "homepage": "https://docs.nocobase.com/handbook/file-manager", "homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/file-manager", "devDependencies": { - "@aws-sdk/client-s3": "^3.750.0", + "@aws-sdk/client-s3": "3.750.0", "@formily/antd-v5": "1.x", "@formily/core": "2.x", "@formily/react": "2.x", @@ -29,7 +29,8 @@ "multer-s3": "^3.0.1", "react": "^18.2.0", "react-i18next": "^11.15.1", - "supertest": "^6.1.6" + "supertest": "^6.1.6", + "url-join": "4.0.1" }, "peerDependencies": { "@nocobase/actions": "1.x", diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/FileModel.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/FileModel.ts deleted file mode 100644 index 98586bff8f..0000000000 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/FileModel.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * 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 { Model } from '@nocobase/database'; -import { STORAGE_TYPE_ALI_OSS, STORAGE_TYPE_LOCAL, STORAGE_TYPE_S3, STORAGE_TYPE_TX_COS } from '../constants'; - -const currentStorage = [STORAGE_TYPE_LOCAL, STORAGE_TYPE_ALI_OSS, STORAGE_TYPE_S3, STORAGE_TYPE_TX_COS]; - -export class FileModel extends Model { - public toJSON() { - const json = super.toJSON(); - const fileStorages = this.constructor['database']?.['_fileStorages']; - if (json.storageId && fileStorages && fileStorages.has(json.storageId)) { - const storage = fileStorages.get(json.storageId); - // 当前文件管理器内的存储类型拼接生成预览链接,其他文件存储自行处理 - if (currentStorage.includes(storage?.type) && storage?.options?.thumbnailRule) { - json['preview'] = `${json['url']}${storage?.options?.thumbnailRule || ''}`; - } - if (storage?.options?.thumbnailRule) { - json['thumbnailRule'] = storage?.options?.thumbnailRule; - } - if (storage?.type === 'local' && process.env.APP_PUBLIC_PATH) { - json['url'] = process.env.APP_PUBLIC_PATH.replace(/\/$/g, '') + json.url; - } - } - return json; - } -} 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 ec8dffe40e..a11639db3d 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 @@ -12,6 +12,7 @@ import path from 'path'; import querystring from 'querystring'; import { getApp } from '.'; import { FILE_FIELD_NAME, FILE_SIZE_LIMIT_DEFAULT, STORAGE_TYPE_LOCAL } from '../../constants'; +import PluginFileManagerServer from '../server'; const { LOCAL_STORAGE_BASE_URL, LOCAL_STORAGE_DEST = 'storage/uploads', APP_PORT = '13000' } = process.env; @@ -52,6 +53,88 @@ describe('action', () => { describe('create / upload', () => { describe('default storage', () => { + it('should be create file record', async () => { + const Plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer; + const model = await Plugin.createFileRecord({ + collectionName: 'attachments', + filePath: path.resolve(__dirname, './files/text.txt'), + }); + const matcher = { + title: 'text', + extname: '.txt', + path: '', + // size: 13, + meta: {}, + storageId: 1, + }; + expect(model.toJSON()).toMatchObject(matcher); + }); + + it('should be local2 storage', async () => { + const storage = await StorageRepo.create({ + values: { + name: 'local2', + type: STORAGE_TYPE_LOCAL, + baseUrl: DEFAULT_LOCAL_BASE_URL, + rules: { + size: 1024, + }, + paranoid: true, + }, + }); + const Plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer; + const model = await Plugin.createFileRecord({ + collectionName: 'attachments', + storageName: 'local2', + filePath: path.resolve(__dirname, './files/text.txt'), + }); + const matcher = { + title: 'text', + extname: '.txt', + path: '', + // size: 13, + meta: {}, + storageId: storage.id, + }; + expect(model.toJSON()).toMatchObject(matcher); + }); + + it('should be custom values', async () => { + const Plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer; + const model = await Plugin.createFileRecord({ + collectionName: 'attachments', + filePath: path.resolve(__dirname, './files/text.txt'), + values: { + size: 22, + }, + }); + const matcher = { + title: 'text', + extname: '.txt', + path: '', + size: 22, + meta: {}, + storageId: 1, + }; + expect(model.toJSON()).toMatchObject(matcher); + }); + + it('should be upload file', async () => { + const Plugin = app.pm.get(PluginFileManagerServer) as PluginFileManagerServer; + const data = await Plugin.uploadFile({ + filePath: path.resolve(__dirname, './files/text.txt'), + documentRoot: 'storage/backups/test', + }); + const matcher = { + title: 'text', + extname: '.txt', + path: '', + meta: {}, + storageId: 1, + }; + expect(data).toMatchObject(matcher); + }); + it('upload file should be ok', async () => { const { body } = await agent.resource('attachments').create({ [FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'), @@ -125,10 +208,9 @@ describe('action', () => { // 文件上传和解析是否正常 expect(body.data).toMatchObject(matcher); // 文件的 url 是否正常生成 - expect(body.data.url).toBe(`${DEFAULT_LOCAL_BASE_URL}${body.data.path}/${body.data.filename}`); + const encodedFilename = querystring.escape(rawText); + expect(body.data.url).toContain(`${DEFAULT_LOCAL_BASE_URL}${body.data.path}/${encodedFilename}`); - const encodedFilename = querystring.escape(rawFilename); - // console.log('-----------', body.data, encodedFilename); // 文件的 url 是否正常访问 // TODO: mock-server is not start within gateway, static url can not be accessed // const res2 = await agent.get(`${DEFAULT_LOCAL_BASE_URL}${body.data.path}/${encodedFilename}`); @@ -192,7 +274,7 @@ describe('action', () => { }); it('upload to storage which is not default', async () => { - const BASE_URL = `http://localhost:${APP_PORT}/storage/uploads/another`; + const BASE_URL = `/storage/uploads/another`; const urlPath = 'test/path'; // 动态添加 storage @@ -236,7 +318,7 @@ describe('action', () => { }); it('path longer than 255', async () => { - const BASE_URL = `http://localhost:${APP_PORT}/storage/uploads/another`; + const BASE_URL = `/storage/uploads/another`; const urlPath = 'extreme-test/max-long-path-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890'; diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/server.test.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/server.test.ts index bfc30a4477..4866763b75 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/server.test.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/__tests__/server.test.ts @@ -12,7 +12,7 @@ import path from 'path'; import { getApp } from '.'; import PluginFileManagerServer from '../server'; -import { STORAGE_TYPE_LOCAL, FILE_FIELD_NAME } from '../../constants'; +import { FILE_FIELD_NAME, STORAGE_TYPE_LOCAL } from '../../constants'; const { LOCAL_STORAGE_BASE_URL, LOCAL_STORAGE_DEST = 'storage/uploads', APP_PORT = '13000' } = process.env; const DEFAULT_LOCAL_BASE_URL = LOCAL_STORAGE_BASE_URL || `/storage/uploads`; @@ -131,23 +131,48 @@ describe('file manager > server', () => { describe('getFileURL', () => { it('local attachment without env', async () => { const { body } = await agent.resource('attachments').create({ - [FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'), + [FILE_FIELD_NAME]: path.resolve(__dirname, './files/[]中文报告! 1%~50.4% (123) {$#}.txt'), }); const url = await plugin.getFileURL(body.data); expect(url).toBe(`${process.env.APP_PUBLIC_PATH?.replace(/\/$/g, '') || ''}${body.data.url}`); }); - it('local attachment with env', async () => { + it('local (default with base url) attachment with env', async () => { const originalPath = process.env.APP_PUBLIC_PATH; - process.env.APP_PUBLIC_PATH = 'http://localhost'; + process.env.APP_PUBLIC_PATH = '/app'; const { body } = await agent.resource('attachments').create({ [FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'), }); const url = await plugin.getFileURL(body.data); - expect(url).toBe(`http://localhost${body.data.url}`); + expect(url).toBe(`${process.env.APP_PUBLIC_PATH}/storage/uploads/${body.data.filename}`); + + process.env.APP_PUBLIC_PATH = originalPath; + }); + + it('local (default without base url) attachment with env', async () => { + const storage = await StorageRepo.create({ + values: { + name: 'local2', + type: STORAGE_TYPE_LOCAL, + rules: { + size: 1024, + }, + default: true, + }, + }); + + const originalPath = process.env.APP_PUBLIC_PATH; + process.env.APP_PUBLIC_PATH = '/nocobase/'; + + const { body } = await agent.resource('attachments').create({ + [FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'), + }); + + const url = await plugin.getFileURL(body.data); + expect(url).toBe(`/nocobase/${body.data.filename}`); process.env.APP_PUBLIC_PATH = originalPath; }); 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 a0897cbcd3..7dd2c025f9 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,38 +28,41 @@ function getFileFilter(storage) { }; } -export function getFileData(ctx: Context) { +export async function getFileData(ctx: Context) { const { [FILE_FIELD_NAME]: file, storage } = ctx; if (!file) { return ctx.throw(400, 'file validation failed'); } - const StorageType = ctx.app.pm.get(Plugin).storageTypes.get(storage.type) as StorageClassType; + 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, ''); - const baseUrl = storage.baseUrl.replace(/\/+$/, ''); - const pathname = [path, filename].filter(Boolean).join('/'); - const storageInstance = new StorageType(storage); + let storageInstance = plugin.storagesCache.get(storage.id); - return { + 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, - // 直接缓存起来 - url: `${baseUrl}/${pathname}`, mimetype: file.mimetype, - // @ts-ignore meta: ctx.request.body, storageId: storage.id, ...(storageInstance.getFileData ? storageInstance.getFileData(file) : {}), }; + + return data; } async function multipart(ctx: Context, next: Next) { @@ -98,7 +101,7 @@ async function multipart(ctx: Context, next: Next) { return ctx.throw(500, err); } - const values = getFileData(ctx); + const values = await getFileData(ctx); ctx.action.mergeParams({ values, diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/index.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/index.ts index 5d078ad08f..bca927d511 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/index.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/actions/index.ts @@ -18,4 +18,22 @@ export default function ({ app }) { }); app.resourcer.use(createMiddleware, { tag: 'createMiddleware', after: 'auth' }); app.resourcer.registerActionHandler('upload', actions.create); + app.resourcer.use( + async (ctx, next) => { + await next(); + try { + const { resourceName, actionName } = ctx.action; + const collection = ctx.db.getCollection(resourceName); + if (collection?.options?.template !== 'file') { + return; + } + if (actionName === 'create' || actionName === 'upload') { + ctx.body = await ctx.body.reload(); + } + } catch (error) { + console.log(error); + } + }, + { before: 'dataWrapping' }, + ); } 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 4fd7eed295..5f097569b8 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/server.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/server.ts @@ -8,14 +8,13 @@ */ import { Plugin } from '@nocobase/server'; -import { Registry } from '@nocobase/utils'; +import { isURL, Registry } from '@nocobase/utils'; import { basename } from 'path'; import { 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 { FileModel } from './FileModel'; import initActions from './actions'; import { getFileData } from './actions/attachments'; import { AttachmentInterface } from './interfaces/attachment-interface'; @@ -158,7 +157,6 @@ export class PluginFileManagerServer extends Plugin { for (const storage of storages) { this.storagesCache.set(storage.get('id'), this.parseStorage(storage)); } - this.db['_fileStorages'] = this.storagesCache; } async install() { @@ -201,7 +199,7 @@ export class PluginFileManagerServer extends Plugin { } async beforeLoad() { - this.db.registerModels({ FileModel }); + this.db.registerModels({ FileModel: Model }); this.db.on('beforeDefineCollection', (options) => { if (options.template === 'file') { options.model = 'FileModel'; @@ -279,12 +277,38 @@ export class PluginFileManagerServer extends Plugin { this.app.acl.addFixedParams('attachments', 'destroy', ownMerger); this.app.db.interfaceManager.registerInterfaceType('attachment', AttachmentInterface); + + this.db.on('afterFind', async (instances) => { + if (!instances) { + return; + } + const records = Array.isArray(instances) ? instances : [instances]; + const name = records[0]?.constructor?.name; + if (name) { + const collection = this.db.getCollection(name); + if (collection?.name === 'attachments' || collection?.options?.template === 'file') { + for (const record of records) { + const url = await this.getFileURL(record); + const previewUrl = await this.getFileURL(record, true); + record.set('url', url); + record.set('preview', previewUrl); + record.dataValues.preview = previewUrl; // 强制添加preview,在附件字段时,通过set设置无效 + } + } + } + }); } - getFileURL(file: AttachmentModel) { + async getFileURL(file: AttachmentModel, preview = false) { + if (!file.storageId) { + return file.url; + } const storage = this.storagesCache.get(file.storageId); + if (!storage) { + return file.url; + } const storageType = this.storageTypes.get(storage.type); - return new storageType(storage).getFileURL(file); + return new storageType(storage).getFileURL(file, preview ? storage.options.thumbnailRule : ''); } } 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 e3ed53f4c6..07a3289ffb 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,8 +7,9 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import Application from '@nocobase/server'; +import { isURL } from '@nocobase/utils'; import { StorageEngine } from 'multer'; +import urlJoin from 'url-join'; export interface StorageModel { id?: number; @@ -41,8 +42,21 @@ export abstract class StorageType { abstract delete(records: AttachmentModel[]): [number, AttachmentModel[]] | Promise<[number, AttachmentModel[]]>; getFileData?(file: { [key: string]: any }): { [key: string]: any }; - getFileURL(file: AttachmentModel): string | Promise { - return file.url; + getFileURL(file: AttachmentModel, preview?: boolean): string | Promise { + // 兼容历史数据 + if (file.url && isURL(file.url)) { + if (preview) { + return file.url + (this.storage.options.thumbnailRule || ''); + } + return file.url; + } + const keys = [ + this.storage.baseUrl, + file.path && encodeURI(file.path), + encodeURIComponent(file.filename), + preview && this.storage.options.thumbnailRule, + ].filter(Boolean); + return urlJoin(keys); } } diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/local.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/local.ts index 78d77e53ff..0a35462962 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/local.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/local.ts @@ -7,14 +7,18 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { isURL } from '@nocobase/utils'; import fs from 'fs/promises'; import mkdirp from 'mkdirp'; import multer from 'multer'; import path from 'path'; +import urlJoin from 'url-join'; import { AttachmentModel, StorageType } from '.'; import { FILE_SIZE_LIMIT_DEFAULT, STORAGE_TYPE_LOCAL } from '../../constants'; import { getFilename } from '../utils'; +const DEFAULT_BASE_URL = '/storage/uploads'; + function getDocumentRoot(storage): string { const { documentRoot = process.env.LOCAL_STORAGE_DEST || 'storage/uploads' } = storage.options || {}; // TODO(feature): 后面考虑以字符串模板的方式使用,可注入 req/action 相关变量,以便于区分文件夹 @@ -27,7 +31,7 @@ export default class extends StorageType { title: 'Local storage', type: STORAGE_TYPE_LOCAL, name: `local`, - baseUrl: '/storage/uploads', + baseUrl: DEFAULT_BASE_URL, options: { documentRoot: 'storage/uploads', }, @@ -72,7 +76,11 @@ export default class extends StorageType { return [count, undeleted]; } - getFileURL(file: AttachmentModel) { - return process.env.APP_PUBLIC_PATH ? `${process.env.APP_PUBLIC_PATH.replace(/\/$/g, '')}${file.url}` : file.url; + async getFileURL(file: AttachmentModel, preview = false) { + const url = await super.getFileURL(file, preview); + if (isURL(url)) { + return url; + } + return urlJoin(process.env.APP_PUBLIC_PATH, url); } } diff --git a/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/s3.ts b/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/s3.ts index 0c3c686050..98113ce410 100644 --- a/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/s3.ts +++ b/packages/plugins/@nocobase/plugin-file-manager/src/server/storages/s3.ts @@ -7,8 +7,8 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { DeleteObjectsCommand } from '@aws-sdk/client-s3'; -import { md5 } from '@nocobase/utils'; +import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import crypto from 'crypto'; import { AttachmentModel, StorageType } from '.'; import { STORAGE_TYPE_S3 } from '../../constants'; import { cloudFilenameGetter, getFileKey } from '../utils'; @@ -32,7 +32,6 @@ export default class extends StorageType { static filenameKey = 'key'; make() { - const { S3Client } = require('@aws-sdk/client-s3'); const multerS3 = require('multer-s3'); const { accessKeyId, secretAccessKey, bucket, acl = 'public-read', ...options } = this.storage.options; if (options.endpoint) { @@ -64,27 +63,25 @@ export default class extends StorageType { }); } + calculateContentMD5(body) { + const hash = crypto.createHash('md5').update(body).digest('base64'); + return hash; + } + async deleteS3Objects(bucketName: string, objects: string[]) { - const deleteBody = JSON.stringify({ - Objects: objects.map((objectKey) => ({ Key: objectKey })), - }); - - const contentMD5 = md5(deleteBody); - - const deleteCommand = new DeleteObjectsCommand({ - Bucket: bucketName, - Delete: JSON.parse(deleteBody), - }); - - deleteCommand.middlewareStack.add( - (next, context) => async (args) => { - args.request['headers']['Content-Md5'] = contentMD5; - return next(args); - }, - { step: 'build' }, - ); const { s3 } = this.make(); - return await s3.send(deleteCommand); + const Deleted = []; + for (const Key of objects) { + const deleteCommand = new DeleteObjectCommand({ + Bucket: bucketName, + Key, + }); + await s3.send(deleteCommand); + Deleted.push({ Key }); + } + return { + Deleted, + }; } async delete(records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> { diff --git a/packages/plugins/@nocobase/plugin-ui-schema-storage/src/server/collections/uiButtonSchemasRoles.ts b/packages/plugins/@nocobase/plugin-ui-schema-storage/src/server/collections/uiButtonSchemasRoles.ts index 27e6f9b8b5..18348b9d24 100644 --- a/packages/plugins/@nocobase/plugin-ui-schema-storage/src/server/collections/uiButtonSchemasRoles.ts +++ b/packages/plugins/@nocobase/plugin-ui-schema-storage/src/server/collections/uiButtonSchemasRoles.ts @@ -12,5 +12,6 @@ import { defineCollection } from '@nocobase/database'; export default defineCollection({ name: 'uiButtonSchemasRoles', dumpRules: 'required', + autoGenId: false, migrationRules: ['overwrite', 'schema-only'], });