fix(plugin-ai): resolve internal file URLs without copying

This commit is contained in:
Drol
2026-08-24 17:53:31 +08:00
parent b136636712
commit 9de2f429da
5 changed files with 294 additions and 1 deletions
@@ -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',
});
@@ -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,