mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
fix(plugin-ai): resolve internal file URLs without copying
This commit is contained in:
@@ -54,6 +54,67 @@ describe('workflow AI employee files', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the original attachment for NocoBase permanent file urls', async () => {
|
||||
vi.mocked(axios.get).mockClear();
|
||||
const attachment = {
|
||||
id: 24,
|
||||
filename: 'report.pdf',
|
||||
extname: '.pdf',
|
||||
storageId: 1,
|
||||
};
|
||||
const findOne = vi.fn(async () => ({
|
||||
toJSON: () => attachment,
|
||||
}));
|
||||
const collection = {
|
||||
name: 'attachments',
|
||||
options: { template: 'file' },
|
||||
};
|
||||
const createFileRecord = vi.fn();
|
||||
const plugin = {
|
||||
app: {
|
||||
name: 'main',
|
||||
dataSourceManager: {
|
||||
get: () => ({
|
||||
collectionManager: {
|
||||
getCollection: () => collection,
|
||||
getRepository: () => ({ findOne }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
db: {
|
||||
getRepository: () => ({
|
||||
findOne: async () => ({ options: { storage: 'local' } }),
|
||||
}),
|
||||
},
|
||||
pm: {
|
||||
get: () => ({ createFileRecord }),
|
||||
},
|
||||
} as unknown as Plugin;
|
||||
const attachmentPart: { attachments?: unknown[] } = {};
|
||||
|
||||
await Files.resolvers(plugin, attachmentPart).resolveUrls([
|
||||
{
|
||||
type: 'file_url',
|
||||
value: '/files/main/main/attachments/24.pdf',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(findOne).toHaveBeenCalledWith(expect.objectContaining({ filter: { id: '24' } }));
|
||||
expect(axios.get).not.toHaveBeenCalled();
|
||||
expect(createFileRecord).not.toHaveBeenCalled();
|
||||
expect(attachmentPart.attachments).toEqual([
|
||||
{
|
||||
...attachment,
|
||||
source: {
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'attachments',
|
||||
trustworthy: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not overwrite stored filenames when resolving file urls', async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: Buffer.from('image'),
|
||||
|
||||
@@ -13,7 +13,7 @@ import path from 'node:path';
|
||||
import { AIEmployeeInstructionFiles } from './types';
|
||||
import _ from 'lodash';
|
||||
import axios from 'axios';
|
||||
import PluginFileManagerServer from '@nocobase/plugin-file-manager';
|
||||
import { parsePermanentFileReference, type PluginFileManagerServer } from '@nocobase/plugin-file-manager';
|
||||
import { Plugin } from '@nocobase/server';
|
||||
import { resolveContentType, resolveFileIdentity } from '../../utils';
|
||||
import { getAttachmentSource, type AttachmentSource } from '../../../attachments';
|
||||
@@ -28,6 +28,51 @@ function appendSource(record: unknown, source: AttachmentSource) {
|
||||
};
|
||||
}
|
||||
|
||||
type FileRecord = Record<string, unknown> & {
|
||||
toJSON?: () => unknown;
|
||||
};
|
||||
|
||||
function toFilePlainObject(record: FileRecord) {
|
||||
const value = typeof record.toJSON === 'function' ? record.toJSON() : record;
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid file record');
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function resolveInternalFileURL(plugin: Plugin, url: string) {
|
||||
const reference = parsePermanentFileReference(url);
|
||||
if (!reference) {
|
||||
return null;
|
||||
}
|
||||
if (reference.appName !== (plugin.app.name || 'main')) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
|
||||
const dataSource = plugin.app.dataSourceManager.get(reference.dataSourceKey);
|
||||
const collection = dataSource?.collectionManager.getCollection(reference.collectionName);
|
||||
if (!dataSource || !collection || (collection.name !== 'attachments' && collection.options?.template !== 'file')) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
|
||||
const record = (await dataSource.collectionManager.getRepository(collection.name).findOne({
|
||||
filter: { id: reference.id },
|
||||
})) as FileRecord | null;
|
||||
if (!record) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
const file = toFilePlainObject(record);
|
||||
if (file.storageId == null || (reference.extname && reference.extname !== file.extname)) {
|
||||
throw new Error('File not found');
|
||||
}
|
||||
|
||||
return appendSource(file, {
|
||||
dataSourceKey: reference.dataSourceKey,
|
||||
collectionName: collection.name,
|
||||
trustworthy: true,
|
||||
});
|
||||
}
|
||||
|
||||
export abstract class Files {
|
||||
static resolvers(plugin: Plugin, attachmentPart: { attachments?: unknown[] }) {
|
||||
const resolveAttachments = async (files: AIEmployeeInstructionFiles[]) => {
|
||||
@@ -90,6 +135,15 @@ export abstract class Files {
|
||||
const storageName = settings?.options?.storage;
|
||||
const attachments = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const internalFile = await resolveInternalFileURL(plugin, url);
|
||||
if (internalFile) {
|
||||
return internalFile;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
throw new Error(`File URL must be an absolute URL or a NocoBase permanent file URL: ${url}`);
|
||||
}
|
||||
const response = await axios.get(url, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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 { afterEach, describe, expect, it } from 'vitest';
|
||||
import { parsePermanentFileReference } from '../file-reference';
|
||||
|
||||
const originalPublicPath = process.env.APP_PUBLIC_PATH;
|
||||
const originalPublicOrigin = process.env.APP_PUBLIC_ORIGIN;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPublicPath === undefined) {
|
||||
delete process.env.APP_PUBLIC_PATH;
|
||||
} else {
|
||||
process.env.APP_PUBLIC_PATH = originalPublicPath;
|
||||
}
|
||||
if (originalPublicOrigin === undefined) {
|
||||
delete process.env.APP_PUBLIC_ORIGIN;
|
||||
} else {
|
||||
process.env.APP_PUBLIC_ORIGIN = originalPublicOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
describe('permanent file references', () => {
|
||||
it('parses relative permanent file paths with APP_PUBLIC_PATH', () => {
|
||||
process.env.APP_PUBLIC_PATH = '/nocobase';
|
||||
|
||||
expect(parsePermanentFileReference('/nocobase/files/main/another/reports/42.xlsx')).toEqual({
|
||||
appName: 'main',
|
||||
dataSourceKey: 'another',
|
||||
collectionName: 'reports',
|
||||
id: '42',
|
||||
extname: '.xlsx',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses absolute urls only for APP_PUBLIC_ORIGIN', () => {
|
||||
process.env.APP_PUBLIC_ORIGIN = 'https://nocobase.example.com';
|
||||
|
||||
expect(parsePermanentFileReference('https://nocobase.example.com/files/main/main/attachments/24.pdf')).toEqual({
|
||||
appName: 'main',
|
||||
dataSourceKey: 'main',
|
||||
collectionName: 'attachments',
|
||||
id: '24',
|
||||
extname: '.pdf',
|
||||
});
|
||||
expect(parsePermanentFileReference('https://cdn.example.com/files/main/main/attachments/24.pdf')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat temporary access urls as permanent file references', () => {
|
||||
expect(
|
||||
parsePermanentFileReference('/files/main/main/attachments/24.pdf?temporaryAccessToken=temporary-token'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects malformed permanent file paths', () => {
|
||||
expect(() => parsePermanentFileReference('/files/main/main/attachments')).toThrow('Invalid file URL');
|
||||
expect(() => parsePermanentFileReference('/files/main/main/attachments/%2Fetc%2Fpasswd')).toThrow(
|
||||
'Invalid file URL',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 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 { normalizeFileAccessExtname, trimPublicPath } from './utils';
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/;
|
||||
|
||||
export type PermanentFileReference = {
|
||||
appName: string;
|
||||
dataSourceKey: string;
|
||||
collectionName: string;
|
||||
id: string;
|
||||
extname?: string;
|
||||
};
|
||||
|
||||
function invalidFileURL() {
|
||||
return Object.assign(new Error('Invalid file URL'), { status: 404 });
|
||||
}
|
||||
|
||||
function stripPublicPath(pathname: string) {
|
||||
const publicPath = trimPublicPath(process.env.APP_PUBLIC_PATH);
|
||||
if (publicPath && (pathname === publicPath || pathname.startsWith(`${publicPath}/`))) {
|
||||
return pathname.slice(publicPath.length) || '/';
|
||||
}
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function getTrustedPublicOrigin() {
|
||||
const value = process.env.APP_PUBLIC_ORIGIN;
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(value).origin;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePermanentFileReference(value: unknown): PermanentFileReference | null {
|
||||
if (typeof value !== 'string' || !value || value.startsWith('//')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
if (value.startsWith('/')) {
|
||||
url = new URL(value, 'http://localhost');
|
||||
} else {
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
const trustedOrigin = getTrustedPublicOrigin();
|
||||
if (!trustedOrigin || url.origin !== trustedOrigin) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (url.searchParams.has('temporaryAccessToken')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = stripPublicPath(url.pathname).split('/').filter(Boolean);
|
||||
if (segments[0] !== 'files') {
|
||||
return null;
|
||||
}
|
||||
if (segments.length !== 5) {
|
||||
throw invalidFileURL();
|
||||
}
|
||||
|
||||
try {
|
||||
const appName = decodeURIComponent(segments[1]);
|
||||
const dataSourceKey = decodeURIComponent(segments[2]);
|
||||
const collectionName = decodeURIComponent(segments[3]);
|
||||
const fileIdSegment = decodeURIComponent(segments[4]);
|
||||
const extnameIndex = fileIdSegment.lastIndexOf('.');
|
||||
const extname = extnameIndex > 0 ? normalizeFileAccessExtname(fileIdSegment.slice(extnameIndex)) : '';
|
||||
const id = extname ? fileIdSegment.slice(0, extnameIndex) : fileIdSegment;
|
||||
|
||||
if (
|
||||
!IDENTIFIER_PATTERN.test(appName) ||
|
||||
!IDENTIFIER_PATTERN.test(dataSourceKey) ||
|
||||
!IDENTIFIER_PATTERN.test(collectionName) ||
|
||||
!id ||
|
||||
id.includes('/') ||
|
||||
id.includes('\\') ||
|
||||
id.includes('\0')
|
||||
) {
|
||||
throw invalidFileURL();
|
||||
}
|
||||
|
||||
return {
|
||||
appName,
|
||||
dataSourceKey,
|
||||
collectionName,
|
||||
id,
|
||||
extname: extname || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
throw invalidFileURL();
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import { StorageEngine } from 'multer';
|
||||
export * from '../constants';
|
||||
export { AttachmentModel, default, PluginFileManagerServer, StorageModel } from './server';
|
||||
export type { FileAccessAuthorizeParams, FileAccessAuthorizer } from './server';
|
||||
export { parsePermanentFileReference } from './file-reference';
|
||||
export type { PermanentFileReference } from './file-reference';
|
||||
export { cloudFilenameGetter } from './utils';
|
||||
export {
|
||||
appendDownloadResponse,
|
||||
|
||||
Reference in New Issue
Block a user