mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 05:29:26 +08:00
refactor(plugin-file-manager): refactor file url generating (#6472)
* refactor(plugin-file-manager): change file url generation to a better way * fix(plugin-file-manager): use url field first * fix(client): remove unused function call * fix(plugin-file-manager): fix test cases * fix: local getURL * fix: md5 * fix: getFileURL * fix(plugin-file-manager): fix FileModel class * fix: use DeleteObjectCommand * fix(plugin-file-manager): fix package version and require * test(plugin-file-manager): fix test cases * fix: storageInstance * fix: test case error * fix: url-join * fix: autoGenId false * fix: url * fix: file.url * fix: file preview * fix: test error * fix: isURL * fix: only --------- Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
parent
ec618b06c8
commit
5144d47eb9
@ -200,32 +200,11 @@ export interface URLReadPrettyProps {
|
|||||||
|
|
||||||
const ellipsisStyle = { textOverflow: 'ellipsis', overflow: 'hidden', whiteSpace: 'nowrap', display: 'block' };
|
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) => {
|
ReadPretty.URL = (props: URLReadPrettyProps) => {
|
||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
const prefixCls = usePrefixCls('description-url', props);
|
const prefixCls = usePrefixCls('description-url', props);
|
||||||
const content = props.value && (
|
const content = props.value && (
|
||||||
<a
|
<a style={props.ellipsis ? ellipsisStyle : undefined} target="_blank" rel="noopener noreferrer" href={props.value}>
|
||||||
style={props.ellipsis ? ellipsisStyle : undefined}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
href={encodeFileURL(props.value)}
|
|
||||||
>
|
|
||||||
{props.value}
|
{props.value}
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
@ -32,7 +32,6 @@ import {
|
|||||||
toValueItem as toValueItemDefault,
|
toValueItem as toValueItemDefault,
|
||||||
useBeforeUpload,
|
useBeforeUpload,
|
||||||
useUploadProps,
|
useUploadProps,
|
||||||
encodeFileURL,
|
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { useStyles } from './style';
|
import { useStyles } from './style';
|
||||||
import type { ComposedUpload, DraggerProps, DraggerV2Props, UploadProps } from './type';
|
import type { ComposedUpload, DraggerProps, DraggerV2Props, UploadProps } from './type';
|
||||||
@ -43,10 +42,10 @@ attachmentFileTypes.add({
|
|||||||
},
|
},
|
||||||
getThumbnailURL(file) {
|
getThumbnailURL(file) {
|
||||||
if (file.preview) {
|
if (file.preview) {
|
||||||
return encodeFileURL(file.preview);
|
return file.preview;
|
||||||
}
|
}
|
||||||
if (file.url) {
|
if (file.url) {
|
||||||
return encodeFileURL(`${file.url}${file.thumbnailRule || ''}`);
|
return file.url;
|
||||||
}
|
}
|
||||||
if (file.originFileObj) {
|
if (file.originFileObj) {
|
||||||
return URL.createObjectURL(file.originFileObj);
|
return URL.createObjectURL(file.originFileObj);
|
||||||
@ -65,9 +64,9 @@ attachmentFileTypes.add({
|
|||||||
return (
|
return (
|
||||||
<LightBox
|
<LightBox
|
||||||
// discourageDownloads={true}
|
// discourageDownloads={true}
|
||||||
mainSrc={encodeFileURL(list[index]?.url)}
|
mainSrc={list[index]?.url}
|
||||||
nextSrc={encodeFileURL(list[(index + 1) % list.length]?.url)}
|
nextSrc={list[(index + 1) % list.length]?.url}
|
||||||
prevSrc={encodeFileURL(list[(index + list.length - 1) % list.length]?.url)}
|
prevSrc={list[(index + list.length - 1) % list.length]?.url}
|
||||||
onCloseRequest={() => onSwitchIndex(null)}
|
onCloseRequest={() => onSwitchIndex(null)}
|
||||||
onMovePrevRequest={() => onSwitchIndex((index + list.length - 1) % list.length)}
|
onMovePrevRequest={() => onSwitchIndex((index + list.length - 1) % list.length)}
|
||||||
onMoveNextRequest={() => onSwitchIndex((index + 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 }) {
|
function IframePreviewer({ index, list, onSwitchIndex }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const file = list[index];
|
const file = list[index];
|
||||||
const url = encodeFileURL(file.url);
|
const url = file.url;
|
||||||
const onOpen = useCallback(
|
const onOpen = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -264,7 +263,7 @@ function AttachmentListItem(props) {
|
|||||||
) : null,
|
) : null,
|
||||||
];
|
];
|
||||||
const wrappedItem = file.url ? (
|
const wrappedItem = file.url ? (
|
||||||
<a target="_blank" rel="noopener noreferrer" href={encodeFileURL(file.url)} onClick={handleClick}>
|
<a target="_blank" rel="noopener noreferrer" href={file.url} onClick={handleClick}>
|
||||||
{item}
|
{item}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* 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 { useUploadStyles } from './style';
|
||||||
|
|
||||||
export * from './Upload';
|
export * from './Upload';
|
||||||
|
@ -267,20 +267,3 @@ export function useBeforeUpload(rules) {
|
|||||||
[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;
|
|
||||||
}
|
|
||||||
|
@ -7,14 +7,14 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { BaseInterface } from '@nocobase/database';
|
||||||
import { createMockServer, MockServer } from '@nocobase/test';
|
import { createMockServer, MockServer } from '@nocobase/test';
|
||||||
import { uid } from '@nocobase/utils';
|
import { uid } from '@nocobase/utils';
|
||||||
import { XlsxExporter } from '../services/xlsx-exporter';
|
|
||||||
import XLSX from 'xlsx';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
|
||||||
import { BaseInterface } from '@nocobase/database';
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import path from 'path';
|
||||||
|
import XLSX from 'xlsx';
|
||||||
|
import { XlsxExporter } from '../services/xlsx-exporter';
|
||||||
|
|
||||||
XLSX.set_fs(fs);
|
XLSX.set_fs(fs);
|
||||||
|
|
||||||
@ -391,13 +391,14 @@ describe('export to xlsx with preset', () => {
|
|||||||
extname: '.png',
|
extname: '.png',
|
||||||
mimetype: 'image/png',
|
mimetype: 'image/png',
|
||||||
url: 'https://nocobase.oss-cn-beijing.aliyuncs.com/test1.png',
|
url: 'https://nocobase.oss-cn-beijing.aliyuncs.com/test1.png',
|
||||||
|
storageId: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'nocobase-logo2',
|
title: 'nocobase-logo2',
|
||||||
filename: '682e5ad037dd02a0fe4800a3e91c283b.png',
|
filename: '682e5ad037dd02a0fe4800a3e91c283b.png',
|
||||||
extname: '.png',
|
extname: '.png',
|
||||||
mimetype: 'image/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];
|
const firstUser = sheetData[1];
|
||||||
expect(firstUser[1]).toEqual(
|
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 {
|
} finally {
|
||||||
fs.unlinkSync(xlsxFilePath);
|
fs.unlinkSync(xlsxFilePath);
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* 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 React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import Vditor from 'vditor';
|
import Vditor from 'vditor';
|
||||||
import { defaultToolbar } from '../interfaces/markdown-vditor';
|
import { defaultToolbar } from '../interfaces/markdown-vditor';
|
||||||
@ -81,7 +81,7 @@ export const Edit = withDynamicSchemaProps((props) => {
|
|||||||
data: {
|
data: {
|
||||||
errFiles: [],
|
errFiles: [],
|
||||||
succMap: {
|
succMap: {
|
||||||
[response.data.filename]: encodeFileURL(response.data.url),
|
[response.data.filename]: response.data.url,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
"homepage": "https://docs.nocobase.com/handbook/file-manager",
|
"homepage": "https://docs.nocobase.com/handbook/file-manager",
|
||||||
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/file-manager",
|
"homepage.zh-CN": "https://docs-cn.nocobase.com/handbook/file-manager",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.750.0",
|
"@aws-sdk/client-s3": "3.750.0",
|
||||||
"@formily/antd-v5": "1.x",
|
"@formily/antd-v5": "1.x",
|
||||||
"@formily/core": "2.x",
|
"@formily/core": "2.x",
|
||||||
"@formily/react": "2.x",
|
"@formily/react": "2.x",
|
||||||
@ -29,7 +29,8 @@
|
|||||||
"multer-s3": "^3.0.1",
|
"multer-s3": "^3.0.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-i18next": "^11.15.1",
|
"react-i18next": "^11.15.1",
|
||||||
"supertest": "^6.1.6"
|
"supertest": "^6.1.6",
|
||||||
|
"url-join": "4.0.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@nocobase/actions": "1.x",
|
"@nocobase/actions": "1.x",
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -12,6 +12,7 @@ import path from 'path';
|
|||||||
import querystring from 'querystring';
|
import querystring from 'querystring';
|
||||||
import { getApp } from '.';
|
import { getApp } from '.';
|
||||||
import { FILE_FIELD_NAME, FILE_SIZE_LIMIT_DEFAULT, STORAGE_TYPE_LOCAL } from '../../constants';
|
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;
|
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('create / upload', () => {
|
||||||
describe('default storage', () => {
|
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 () => {
|
it('upload file should be ok', async () => {
|
||||||
const { body } = await agent.resource('attachments').create({
|
const { body } = await agent.resource('attachments').create({
|
||||||
[FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'),
|
[FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'),
|
||||||
@ -125,10 +208,9 @@ describe('action', () => {
|
|||||||
// 文件上传和解析是否正常
|
// 文件上传和解析是否正常
|
||||||
expect(body.data).toMatchObject(matcher);
|
expect(body.data).toMatchObject(matcher);
|
||||||
// 文件的 url 是否正常生成
|
// 文件的 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 是否正常访问
|
// 文件的 url 是否正常访问
|
||||||
// TODO: mock-server is not start within gateway, static url can not be accessed
|
// 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}`);
|
// 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 () => {
|
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';
|
const urlPath = 'test/path';
|
||||||
|
|
||||||
// 动态添加 storage
|
// 动态添加 storage
|
||||||
@ -236,7 +318,7 @@ describe('action', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('path longer than 255', async () => {
|
it('path longer than 255', async () => {
|
||||||
const BASE_URL = `http://localhost:${APP_PORT}/storage/uploads/another`;
|
const BASE_URL = `/storage/uploads/another`;
|
||||||
const urlPath =
|
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';
|
'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';
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ import path from 'path';
|
|||||||
import { getApp } from '.';
|
import { getApp } from '.';
|
||||||
import PluginFileManagerServer from '../server';
|
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 { 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`;
|
const DEFAULT_LOCAL_BASE_URL = LOCAL_STORAGE_BASE_URL || `/storage/uploads`;
|
||||||
@ -131,23 +131,48 @@ describe('file manager > server', () => {
|
|||||||
describe('getFileURL', () => {
|
describe('getFileURL', () => {
|
||||||
it('local attachment without env', async () => {
|
it('local attachment without env', async () => {
|
||||||
const { body } = await agent.resource('attachments').create({
|
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);
|
const url = await plugin.getFileURL(body.data);
|
||||||
expect(url).toBe(`${process.env.APP_PUBLIC_PATH?.replace(/\/$/g, '') || ''}${body.data.url}`);
|
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;
|
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({
|
const { body } = await agent.resource('attachments').create({
|
||||||
[FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'),
|
[FILE_FIELD_NAME]: path.resolve(__dirname, './files/text.txt'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = await plugin.getFileURL(body.data);
|
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;
|
process.env.APP_PUBLIC_PATH = originalPath;
|
||||||
});
|
});
|
||||||
|
@ -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;
|
const { [FILE_FIELD_NAME]: file, storage } = ctx;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return ctx.throw(400, 'file validation failed');
|
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;
|
const { [StorageType.filenameKey || 'filename']: name } = file;
|
||||||
// make compatible filename across cloud service (with path)
|
// make compatible filename across cloud service (with path)
|
||||||
const filename = Path.basename(name);
|
const filename = Path.basename(name);
|
||||||
const extname = Path.extname(filename);
|
const extname = Path.extname(filename);
|
||||||
const path = (storage.path || '').replace(/^\/|\/$/g, '');
|
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, ''),
|
title: Buffer.from(file.originalname, 'latin1').toString('utf8').replace(extname, ''),
|
||||||
filename,
|
filename,
|
||||||
extname,
|
extname,
|
||||||
// TODO(feature): 暂时两者相同,后面 storage.path 模版化以后,这里只是 file 实际的 path
|
// TODO(feature): 暂时两者相同,后面 storage.path 模版化以后,这里只是 file 实际的 path
|
||||||
path,
|
path,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
// 直接缓存起来
|
|
||||||
url: `${baseUrl}/${pathname}`,
|
|
||||||
mimetype: file.mimetype,
|
mimetype: file.mimetype,
|
||||||
// @ts-ignore
|
|
||||||
meta: ctx.request.body,
|
meta: ctx.request.body,
|
||||||
storageId: storage.id,
|
storageId: storage.id,
|
||||||
...(storageInstance.getFileData ? storageInstance.getFileData(file) : {}),
|
...(storageInstance.getFileData ? storageInstance.getFileData(file) : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function multipart(ctx: Context, next: Next) {
|
async function multipart(ctx: Context, next: Next) {
|
||||||
@ -98,7 +101,7 @@ async function multipart(ctx: Context, next: Next) {
|
|||||||
return ctx.throw(500, err);
|
return ctx.throw(500, err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = getFileData(ctx);
|
const values = await getFileData(ctx);
|
||||||
|
|
||||||
ctx.action.mergeParams({
|
ctx.action.mergeParams({
|
||||||
values,
|
values,
|
||||||
|
@ -18,4 +18,22 @@ export default function ({ app }) {
|
|||||||
});
|
});
|
||||||
app.resourcer.use(createMiddleware, { tag: 'createMiddleware', after: 'auth' });
|
app.resourcer.use(createMiddleware, { tag: 'createMiddleware', after: 'auth' });
|
||||||
app.resourcer.registerActionHandler('upload', actions.create);
|
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' },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,14 +8,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Plugin } from '@nocobase/server';
|
import { Plugin } from '@nocobase/server';
|
||||||
import { Registry } from '@nocobase/utils';
|
import { isURL, Registry } from '@nocobase/utils';
|
||||||
|
|
||||||
import { basename } from 'path';
|
import { basename } from 'path';
|
||||||
|
|
||||||
import { Model, Transactionable } from '@nocobase/database';
|
import { Model, Transactionable } from '@nocobase/database';
|
||||||
import fs from 'fs';
|
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 { FileModel } from './FileModel';
|
|
||||||
import initActions from './actions';
|
import initActions from './actions';
|
||||||
import { getFileData } from './actions/attachments';
|
import { getFileData } from './actions/attachments';
|
||||||
import { AttachmentInterface } from './interfaces/attachment-interface';
|
import { AttachmentInterface } from './interfaces/attachment-interface';
|
||||||
@ -158,7 +157,6 @@ export class PluginFileManagerServer extends Plugin {
|
|||||||
for (const storage of storages) {
|
for (const storage of storages) {
|
||||||
this.storagesCache.set(storage.get('id'), this.parseStorage(storage));
|
this.storagesCache.set(storage.get('id'), this.parseStorage(storage));
|
||||||
}
|
}
|
||||||
this.db['_fileStorages'] = this.storagesCache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async install() {
|
async install() {
|
||||||
@ -201,7 +199,7 @@ export class PluginFileManagerServer extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async beforeLoad() {
|
async beforeLoad() {
|
||||||
this.db.registerModels({ FileModel });
|
this.db.registerModels({ FileModel: Model });
|
||||||
this.db.on('beforeDefineCollection', (options) => {
|
this.db.on('beforeDefineCollection', (options) => {
|
||||||
if (options.template === 'file') {
|
if (options.template === 'file') {
|
||||||
options.model = 'FileModel';
|
options.model = 'FileModel';
|
||||||
@ -279,12 +277,38 @@ export class PluginFileManagerServer extends Plugin {
|
|||||||
this.app.acl.addFixedParams('attachments', 'destroy', ownMerger);
|
this.app.acl.addFixedParams('attachments', 'destroy', ownMerger);
|
||||||
|
|
||||||
this.app.db.interfaceManager.registerInterfaceType('attachment', AttachmentInterface);
|
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);
|
const storage = this.storagesCache.get(file.storageId);
|
||||||
|
if (!storage) {
|
||||||
|
return file.url;
|
||||||
|
}
|
||||||
const storageType = this.storageTypes.get(storage.type);
|
const storageType = this.storageTypes.get(storage.type);
|
||||||
return new storageType(storage).getFileURL(file);
|
return new storageType(storage).getFileURL(file, preview ? storage.options.thumbnailRule : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,8 +7,9 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* 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 { StorageEngine } from 'multer';
|
||||||
|
import urlJoin from 'url-join';
|
||||||
|
|
||||||
export interface StorageModel {
|
export interface StorageModel {
|
||||||
id?: number;
|
id?: number;
|
||||||
@ -41,8 +42,21 @@ export abstract class StorageType {
|
|||||||
abstract delete(records: AttachmentModel[]): [number, AttachmentModel[]] | Promise<[number, AttachmentModel[]]>;
|
abstract delete(records: AttachmentModel[]): [number, AttachmentModel[]] | Promise<[number, AttachmentModel[]]>;
|
||||||
|
|
||||||
getFileData?(file: { [key: string]: any }): { [key: string]: any };
|
getFileData?(file: { [key: string]: any }): { [key: string]: any };
|
||||||
getFileURL(file: AttachmentModel): string | Promise<string> {
|
getFileURL(file: AttachmentModel, preview?: boolean): string | Promise<string> {
|
||||||
return file.url;
|
// 兼容历史数据
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,14 +7,18 @@
|
|||||||
* 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 fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import mkdirp from 'mkdirp';
|
import mkdirp from 'mkdirp';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import urlJoin from 'url-join';
|
||||||
import { AttachmentModel, StorageType } from '.';
|
import { AttachmentModel, StorageType } from '.';
|
||||||
import { FILE_SIZE_LIMIT_DEFAULT, STORAGE_TYPE_LOCAL } from '../../constants';
|
import { FILE_SIZE_LIMIT_DEFAULT, STORAGE_TYPE_LOCAL } from '../../constants';
|
||||||
import { getFilename } from '../utils';
|
import { getFilename } from '../utils';
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL = '/storage/uploads';
|
||||||
|
|
||||||
function getDocumentRoot(storage): string {
|
function getDocumentRoot(storage): string {
|
||||||
const { documentRoot = process.env.LOCAL_STORAGE_DEST || 'storage/uploads' } = storage.options || {};
|
const { documentRoot = process.env.LOCAL_STORAGE_DEST || 'storage/uploads' } = storage.options || {};
|
||||||
// TODO(feature): 后面考虑以字符串模板的方式使用,可注入 req/action 相关变量,以便于区分文件夹
|
// TODO(feature): 后面考虑以字符串模板的方式使用,可注入 req/action 相关变量,以便于区分文件夹
|
||||||
@ -27,7 +31,7 @@ export default class extends StorageType {
|
|||||||
title: 'Local storage',
|
title: 'Local storage',
|
||||||
type: STORAGE_TYPE_LOCAL,
|
type: STORAGE_TYPE_LOCAL,
|
||||||
name: `local`,
|
name: `local`,
|
||||||
baseUrl: '/storage/uploads',
|
baseUrl: DEFAULT_BASE_URL,
|
||||||
options: {
|
options: {
|
||||||
documentRoot: 'storage/uploads',
|
documentRoot: 'storage/uploads',
|
||||||
},
|
},
|
||||||
@ -72,7 +76,11 @@ export default class extends StorageType {
|
|||||||
|
|
||||||
return [count, undeleted];
|
return [count, undeleted];
|
||||||
}
|
}
|
||||||
getFileURL(file: AttachmentModel) {
|
async getFileURL(file: AttachmentModel, preview = false) {
|
||||||
return process.env.APP_PUBLIC_PATH ? `${process.env.APP_PUBLIC_PATH.replace(/\/$/g, '')}${file.url}` : file.url;
|
const url = await super.getFileURL(file, preview);
|
||||||
|
if (isURL(url)) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
return urlJoin(process.env.APP_PUBLIC_PATH, url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,8 +7,8 @@
|
|||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DeleteObjectsCommand } from '@aws-sdk/client-s3';
|
import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
||||||
import { md5 } from '@nocobase/utils';
|
import crypto from 'crypto';
|
||||||
import { AttachmentModel, StorageType } from '.';
|
import { AttachmentModel, StorageType } from '.';
|
||||||
import { STORAGE_TYPE_S3 } from '../../constants';
|
import { STORAGE_TYPE_S3 } from '../../constants';
|
||||||
import { cloudFilenameGetter, getFileKey } from '../utils';
|
import { cloudFilenameGetter, getFileKey } from '../utils';
|
||||||
@ -32,7 +32,6 @@ export default class extends StorageType {
|
|||||||
static filenameKey = 'key';
|
static filenameKey = 'key';
|
||||||
|
|
||||||
make() {
|
make() {
|
||||||
const { S3Client } = require('@aws-sdk/client-s3');
|
|
||||||
const multerS3 = require('multer-s3');
|
const multerS3 = require('multer-s3');
|
||||||
const { accessKeyId, secretAccessKey, bucket, acl = 'public-read', ...options } = this.storage.options;
|
const { accessKeyId, secretAccessKey, bucket, acl = 'public-read', ...options } = this.storage.options;
|
||||||
if (options.endpoint) {
|
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[]) {
|
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();
|
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[]]> {
|
async delete(records: AttachmentModel[]): Promise<[number, AttachmentModel[]]> {
|
||||||
|
@ -12,5 +12,6 @@ import { defineCollection } from '@nocobase/database';
|
|||||||
export default defineCollection({
|
export default defineCollection({
|
||||||
name: 'uiButtonSchemasRoles',
|
name: 'uiButtonSchemasRoles',
|
||||||
dumpRules: 'required',
|
dumpRules: 'required',
|
||||||
|
autoGenId: false,
|
||||||
migrationRules: ['overwrite', 'schema-only'],
|
migrationRules: ['overwrite', 'schema-only'],
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user