diff --git a/.vscode/settings.json b/.vscode/settings.json index 6a914c5401a..b8ac40fa3f1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -59,9 +59,9 @@ "**/src/**/route.ts": "${dirname(1)}/${dirname} • route", "**/src/**/index.tsx": "${dirname} • component", - "**/src/database/repositories/*/index.ts": "${dirname} • db repository", - "**/src/database/models/*.ts": "${filename} • db model", - "**/src/database/schemas/*.ts": "${filename} • db schema", + "**/packages/database/src/repositories/*/index.ts": "${dirname} • db repository", + "**/packages/database/src/models/*.ts": "${filename} • db model", + "**/packages/database/src/schemas/*.ts": "${filename} • db schema", "**/src/services/*.ts": "${filename} • service", "**/src/services/*/client.ts": "${dirname} • client service", @@ -81,7 +81,7 @@ "**/src/store/*/slices/*/reducer.ts": "${dirname(2)}/${dirname} • reducer", "**/src/config/modelProviders/*.ts": "${filename} • provider", - "**/packages/model-bank/src/aiModels/aiModels/*.ts": "${filename} • model", + "**/packages/model-bank/src/aiModels/*.ts": "${filename} • model", "**/packages/model-runtime/src/*/index.ts": "${dirname} • runtime", "**/src/server/services/*/index.ts": "${dirname} • server/service", diff --git a/CHANGELOG.md b/CHANGELOG.md index d441f6642ed..9148bf8e4bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ # Changelog +### [Version 1.120.6](https://github.com/lobehub/lobe-chat/compare/v1.120.5...v1.120.6) + +Released on **2025-09-01** + +#### 💄 Styles + +- **misc**: Add upload hint for non-visual model. + +
+ +
+Improvements and Fixes + +#### Styles + +- **misc**: Add upload hint for non-visual model, closes [#7969](https://github.com/lobehub/lobe-chat/issues/7969) ([1224f4e](https://github.com/lobehub/lobe-chat/commit/1224f4e)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +### [Version 1.120.5](https://github.com/lobehub/lobe-chat/compare/v1.120.4...v1.120.5) + +Released on **2025-09-01** + +#### 🐛 Bug Fixes + +- **ai-image**: Save config.imageUrl with fullUrl instead of key. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- **ai-image**: Save config.imageUrl with fullUrl instead of key, closes [#9016](https://github.com/lobehub/lobe-chat/issues/9016) ([bad009a](https://github.com/lobehub/lobe-chat/commit/bad009a)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ ### [Version 1.120.4](https://github.com/lobehub/lobe-chat/compare/v1.120.3...v1.120.4) Released on **2025-09-01** diff --git a/changelog/v1.json b/changelog/v1.json index 4c01b444162..9b87b0d97ea 100644 --- a/changelog/v1.json +++ b/changelog/v1.json @@ -1,4 +1,16 @@ [ + { + "children": { + "improvements": ["Add upload hint for non-visual model."] + }, + "date": "2025-09-01", + "version": "1.120.6" + }, + { + "children": {}, + "date": "2025-09-01", + "version": "1.120.5" + }, { "children": { "improvements": ["Adjust ControlsForm component to adapt to mobile phone display."] diff --git a/package.json b/package.json index d1ecf05da66..ff1d902b636 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lobehub/chat", - "version": "1.120.4", + "version": "1.120.6", "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.", "keywords": [ "framework", diff --git a/packages/model-bank/src/standard-parameters/index.ts b/packages/model-bank/src/standard-parameters/index.ts index 961b23bc54c..5e12c187e55 100644 --- a/packages/model-bank/src/standard-parameters/index.ts +++ b/packages/model-bank/src/standard-parameters/index.ts @@ -45,8 +45,8 @@ export const DEFAULT_DIMENSION_CONSTRAINTS = { } as const; export const CHAT_MODEL_IMAGE_GENERATION_PARAMS: ModelParamsSchema = { - imageUrl: { - default: null, + imageUrls: { + default: [], }, prompt: { default: '' }, }; diff --git a/src/components/DragUpload/useDragUpload.test.tsx b/src/components/DragUpload/useDragUpload.test.tsx index b7264b46b81..7f1fb0bfbc8 100644 --- a/src/components/DragUpload/useDragUpload.test.tsx +++ b/src/components/DragUpload/useDragUpload.test.tsx @@ -1,15 +1,43 @@ import { act, renderHook } from '@testing-library/react'; +import { App } from 'antd'; import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useModelSupportVision } from '@/hooks/useModelSupportVision'; +import { useAgentStore } from '@/store/agent'; +import { agentSelectors } from '@/store/agent/slices/chat'; + import { getContainer, useDragUpload } from './useDragUpload'; +// Mock the hooks and components +vi.mock('@/hooks/useModelSupportVision'); +vi.mock('@/store/agent'); +vi.mock('antd', () => ({ + App: { + useApp: () => ({ + message: { + warning: vi.fn(), + }, + }), + }, +})); + describe('useDragUpload', () => { let mockOnUploadFiles: Mock; + let mockMessage: { warning: Mock }; beforeEach(() => { mockOnUploadFiles = vi.fn(); + mockMessage = { warning: vi.fn() }; vi.useFakeTimers(); document.body.innerHTML = ''; + + // Mock the hooks + (useModelSupportVision as Mock).mockReturnValue(false); + (useAgentStore as unknown as Mock).mockImplementation((selector) => { + if (selector === agentSelectors.currentAgentModel) return 'test-model'; + if (selector === agentSelectors.currentAgentModelProvider) return 'test-provider'; + return null; + }); }); afterEach(() => { @@ -115,6 +143,89 @@ describe('useDragUpload', () => { expect(mockOnUploadFiles).toHaveBeenCalledWith([mockFile]); }); + + it('should show warning when dropping image file with vision not supported', async () => { + renderHook(() => useDragUpload(mockOnUploadFiles)); + + const mockImageFile = new File([''], 'test.png', { type: 'image/png' }); + const dropEvent = new Event('drop') as DragEvent; + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + items: [ + { + kind: 'file', + getAsFile: () => mockImageFile, + webkitGetAsEntry: () => ({ + isFile: true, + file: (cb: (file: File) => void) => cb(mockImageFile), + }), + }, + ], + types: ['Files'], + }, + }); + + await act(async () => { + window.dispatchEvent(dropEvent); + }); + + expect(mockOnUploadFiles).not.toHaveBeenCalled(); + }); + + it('should show warning when pasting image file with vision not supported', async () => { + renderHook(() => useDragUpload(mockOnUploadFiles)); + + const mockImageFile = new File([''], 'test.png', { type: 'image/png' }); + const pasteEvent = new Event('paste') as ClipboardEvent; + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + items: [ + { + kind: 'file', + getAsFile: () => mockImageFile, + webkitGetAsEntry: () => null, + }, + ], + }, + }); + + await act(async () => { + window.dispatchEvent(pasteEvent); + }); + + expect(mockOnUploadFiles).not.toHaveBeenCalled(); + }); + + it('should allow image files when vision is supported', async () => { + (useModelSupportVision as Mock).mockReturnValue(true); + + renderHook(() => useDragUpload(mockOnUploadFiles)); + + const mockImageFile = new File([''], 'test.png', { type: 'image/png' }); + const dropEvent = new Event('drop') as DragEvent; + Object.defineProperty(dropEvent, 'dataTransfer', { + value: { + items: [ + { + kind: 'file', + getAsFile: () => mockImageFile, + webkitGetAsEntry: () => ({ + isFile: true, + file: (cb: (file: File) => void) => cb(mockImageFile), + }), + }, + ], + types: ['Files'], + }, + }); + + await act(async () => { + window.dispatchEvent(dropEvent); + }); + + expect(mockOnUploadFiles).toHaveBeenCalledWith([mockImageFile]); + expect(App.useApp().message.warning).not.toHaveBeenCalled(); + }); }); describe('getContainer', () => { diff --git a/src/components/DragUpload/useDragUpload.tsx b/src/components/DragUpload/useDragUpload.tsx index 2c0cc080d20..962015a1abc 100644 --- a/src/components/DragUpload/useDragUpload.tsx +++ b/src/components/DragUpload/useDragUpload.tsx @@ -1,5 +1,11 @@ /* eslint-disable no-undef */ +import { App } from 'antd'; import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useModelSupportVision } from '@/hooks/useModelSupportVision'; +import { useAgentStore } from '@/store/agent'; +import { agentSelectors } from '@/store/agent/selectors'; const DRAGGING_ROOT_ID = 'dragging-root'; export const getContainer = () => document.querySelector(`#${DRAGGING_ROOT_ID}`); @@ -62,12 +68,18 @@ const getFileListFromDataTransferItems = async (items: DataTransferItem[]) => { }; export const useDragUpload = (onUploadFiles: (files: File[]) => Promise) => { + const { t } = useTranslation('chat'); + const { message } = App.useApp(); const [isDragging, setIsDragging] = useState(false); // When a file is dragged to a different area, the 'dragleave' event may be triggered, // causing isDragging to be mistakenly set to false. // to fix this issue, use a counter to ensure the status change only when drag event left the browser window . const dragCounter = useRef(0); + const model = useAgentStore(agentSelectors.currentAgentModel); + const provider = useAgentStore(agentSelectors.currentAgentModelProvider); + const supportVision = useModelSupportVision(model, provider); + const handleDragEnter = (e: DragEvent) => { if (!e.dataTransfer?.items || e.dataTransfer.items.length === 0) return; @@ -113,6 +125,13 @@ export const useDragUpload = (onUploadFiles: (files: File[]) => Promise) = if (files.length === 0) return; + // 检查是否有图片文件且模型不支持视觉功能 + const hasImageFiles = files.some((file) => file.type.startsWith('image/')); + if (hasImageFiles && !supportVision) { + message.warning(t('upload.clientMode.visionNotSupported')); + return; + } + // upload files onUploadFiles(files); }; @@ -125,6 +144,13 @@ export const useDragUpload = (onUploadFiles: (files: File[]) => Promise) = const files = await getFileListFromDataTransferItems(items); if (files.length === 0) return; + // 检查是否有图片文件且模型不支持视觉功能 + const hasImageFiles = files.some((file) => file.type.startsWith('image/')); + if (hasImageFiles && !supportVision) { + message.warning(t('upload.clientMode.visionNotSupported')); + return; + } + onUploadFiles(files); }; diff --git a/src/locales/default/chat.ts b/src/locales/default/chat.ts index 7a6ef1ba0b1..09499241206 100644 --- a/src/locales/default/chat.ts +++ b/src/locales/default/chat.ts @@ -278,6 +278,7 @@ export default { actionFiletip: '上传文件', actionTooltip: '上传', disabled: '当前模型不支持视觉识别和文件分析,请切换模型后使用', + visionNotSupported: '当前模型不支持视觉识别,请切换模型后使用', }, preview: { prepareTasks: '准备分块...', diff --git a/src/server/routers/lambda/__tests__/image.test.ts b/src/server/routers/lambda/__tests__/image.test.ts new file mode 100644 index 00000000000..aed5a5f8fd8 --- /dev/null +++ b/src/server/routers/lambda/__tests__/image.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +// Copy of the validation function from image.ts for testing +function validateNoUrlsInConfig(obj: any, path: string = ''): void { + if (typeof obj === 'string') { + if (obj.startsWith('http://') || obj.startsWith('https://')) { + throw new Error( + `Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` + + `URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` + + `All URLs must be converted to storage keys before database insertion.`, + ); + } + } else if (Array.isArray(obj)) { + obj.forEach((item, index) => { + validateNoUrlsInConfig(item, `${path}[${index}]`); + }); + } else if (obj && typeof obj === 'object') { + Object.entries(obj).forEach(([key, value]) => { + const currentPath = path ? `${path}.${key}` : key; + validateNoUrlsInConfig(value, currentPath); + }); + } +} + +describe('imageRouter', () => { + describe('validateNoUrlsInConfig utility', () => { + describe('valid configurations', () => { + it('should pass with normal keys', () => { + const config = { + imageUrl: 'images/photo.jpg', + imageUrls: ['files/doc.pdf', 'assets/video.mp4'], + prompt: 'Generate an image', + }; + + expect(() => validateNoUrlsInConfig(config)).not.toThrow(); + }); + + it('should pass with empty strings', () => { + const config = { + imageUrl: '', + imageUrls: [], + prompt: 'Generate an image', + }; + + expect(() => validateNoUrlsInConfig(config)).not.toThrow(); + }); + + it('should pass with null/undefined values', () => { + const config = { + imageUrl: null, + imageUrls: undefined, + prompt: 'Generate an image', + }; + + expect(() => validateNoUrlsInConfig(config)).not.toThrow(); + }); + }); + + describe('invalid configurations', () => { + it('should throw for https URL in imageUrl', () => { + const config = { + imageUrl: 'https://s3.amazonaws.com/bucket/image.jpg', + prompt: 'Generate an image', + }; + + expect(() => validateNoUrlsInConfig(config)).toThrow( + 'Invalid configuration: Found full URL instead of key at imageUrl', + ); + }); + + it('should throw for http URL in imageUrls array', () => { + const config = { + imageUrls: ['files/doc.pdf', 'http://example.com/image.jpg'], + prompt: 'Generate an image', + }; + + expect(() => validateNoUrlsInConfig(config)).toThrow( + 'Invalid configuration: Found full URL instead of key at imageUrls[1]', + ); + }); + + it('should throw for nested URL in complex object', () => { + const config = { + settings: { + imageConfig: { + url: 'https://cdn.example.com/very-long-url-that-exceeds-100-characters-to-test-truncation-functionality.jpg', + }, + }, + }; + + expect(() => validateNoUrlsInConfig(config)).toThrow( + 'Invalid configuration: Found full URL instead of key at settings.imageConfig.url', + ); + expect(() => validateNoUrlsInConfig(config)).toThrow( + 'https://cdn.example.com/very-long-url-that-exceeds-100-characters-to-test-truncation-func', + ); + }); + + it('should throw for presigned URL with query parameters', () => { + const config = { + imageUrl: + 'https://s3.amazonaws.com/bucket/file.jpg?X-Amz-Signature=abc&X-Amz-Expires=3600', + }; + + expect(() => validateNoUrlsInConfig(config)).toThrow( + 'All URLs must be converted to storage keys before database insertion', + ); + }); + }); + + describe('edge cases', () => { + it('should handle deeply nested structures', () => { + const config = { + level1: { + level2: { + level3: { + level4: ['normal-key', 'https://bad-url.com'], + }, + }, + }, + }; + + expect(() => validateNoUrlsInConfig(config)).toThrow( + 'Invalid configuration: Found full URL instead of key at level1.level2.level3.level4[1]', + ); + }); + + it('should not throw for strings that contain but do not start with http', () => { + const config = { + imageUrl: 'some-prefix-https://example.com', + description: 'This text contains http:// but is not a URL', + }; + + expect(() => validateNoUrlsInConfig(config)).not.toThrow(); + }); + }); + }); +}); diff --git a/src/server/routers/lambda/image.ts b/src/server/routers/lambda/image.ts index 9be43dd3606..20ca462c0c9 100644 --- a/src/server/routers/lambda/image.ts +++ b/src/server/routers/lambda/image.ts @@ -24,6 +24,31 @@ import { generateUniqueSeeds } from '@/utils/number'; const log = debug('lobe-image:lambda'); +/** + * Recursively validate that no full URLs are present in the config + * This is a defensive check to ensure only keys are stored in database + */ +function validateNoUrlsInConfig(obj: any, path: string = ''): void { + if (typeof obj === 'string') { + if (obj.startsWith('http://') || obj.startsWith('https://')) { + throw new Error( + `Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` + + `URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` + + `All URLs must be converted to storage keys before database insertion.`, + ); + } + } else if (Array.isArray(obj)) { + obj.forEach((item, index) => { + validateNoUrlsInConfig(item, `${path}[${index}]`); + }); + } else if (obj && typeof obj === 'object') { + Object.entries(obj).forEach(([key, value]) => { + const currentPath = path ? `${path}.${key}` : key; + validateNoUrlsInConfig(value, currentPath); + }); + } +} + const imageProcedure = authedProcedure .use(keyVaults) .use(serverDatabase) @@ -71,8 +96,9 @@ export const imageRouter = router({ log('Starting image creation process, input: %O', input); - // 如果 params 中包含 imageUrls,将它们转换为 S3 keys 用于数据库存储 + // 规范化参考图地址,统一存储 S3 key(避免把会过期的预签名 URL 存进数据库) let configForDatabase = { ...params }; + // 1) 处理多图 imageUrls if (Array.isArray(params.imageUrls) && params.imageUrls.length > 0) { log('Converting imageUrls to S3 keys for database storage: %O', params.imageUrls); try { @@ -82,18 +108,30 @@ export const imageRouter = router({ return key; }); - // 将转换后的 keys 存储为数据库配置 configForDatabase = { - ...params, + ...configForDatabase, imageUrls: imageKeys, }; log('Successfully converted imageUrls to keys for database: %O', imageKeys); } catch (error) { log('Error converting imageUrls to keys: %O', error); - // 如果转换失败,保持原始 URLs(可能是本地文件或其他格式) log('Keeping original imageUrls due to conversion error'); } } + // 2) 处理单图 imageUrl + if (typeof params.imageUrl === 'string' && params.imageUrl) { + try { + const key = fileService.getKeyFromFullUrl(params.imageUrl); + log('Converted single imageUrl to key: %s -> %s', params.imageUrl, key); + configForDatabase = { ...configForDatabase, imageUrl: key }; + } catch (error) { + log('Error converting imageUrl to key: %O', error); + // 转换失败则保留原始值 + } + } + + // 防御性检测:确保没有完整URL进入数据库 + validateNoUrlsInConfig(configForDatabase, 'configForDatabase'); // 步骤 1: 在事务中原子性地创建所有数据库记录 const { batch: createdBatch, generationsWithTasks } = await serverDB.transaction(async (tx) => { diff --git a/src/server/services/file/impls/local.test.ts b/src/server/services/file/impls/local.test.ts index e4faddd7398..39ae4f36c8e 100644 --- a/src/server/services/file/impls/local.test.ts +++ b/src/server/services/file/impls/local.test.ts @@ -1,5 +1,4 @@ import { existsSync, readFileSync } from 'node:fs'; -import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { electronIpcClient } from '@/server/modules/ElectronIPCClient'; @@ -234,6 +233,52 @@ describe('DesktopLocalFileImpl', () => { // 验证 expect(result).toBe(''); }); + + // Legacy bug compatibility tests - https://github.com/lobehub/lobe-chat/issues/8994 + describe('legacy bug compatibility', () => { + it('should handle full URL input by extracting key', async () => { + const fullUrl = 'http://localhost:3000/desktop-file/documents/test.txt'; + + // Mock getKeyFromFullUrl and getLocalFileUrl + vi.spyOn(service, 'getKeyFromFullUrl').mockReturnValue('desktop://documents/test.txt'); + const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl'); + getLocalFileUrlSpy.mockResolvedValueOnce('data:image/png;base64,test'); + + const result = await service.getFullFileUrl(fullUrl); + + expect(service.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl); + expect(getLocalFileUrlSpy).toHaveBeenCalledWith('desktop://documents/test.txt'); + expect(result).toBe('data:image/png;base64,test'); + }); + + it('should handle normal desktop:// key input without extraction', async () => { + const key = 'desktop://documents/test.txt'; + + const extractSpy = vi.spyOn(service, 'getKeyFromFullUrl'); + const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl'); + getLocalFileUrlSpy.mockResolvedValueOnce('data:image/png;base64,test'); + + const result = await service.getFullFileUrl(key); + + expect(extractSpy).not.toHaveBeenCalled(); + expect(getLocalFileUrlSpy).toHaveBeenCalledWith(key); + expect(result).toBe('data:image/png;base64,test'); + }); + + it('should handle https:// URLs for legacy compatibility', async () => { + const httpsUrl = 'https://localhost:3000/desktop-file/images/photo.jpg'; + + vi.spyOn(service, 'getKeyFromFullUrl').mockReturnValue('desktop://images/photo.jpg'); + const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl'); + getLocalFileUrlSpy.mockResolvedValueOnce('data:image/jpeg;base64,test'); + + const result = await service.getFullFileUrl(httpsUrl); + + expect(service.getKeyFromFullUrl).toHaveBeenCalledWith(httpsUrl); + expect(getLocalFileUrlSpy).toHaveBeenCalledWith('desktop://images/photo.jpg'); + expect(result).toBe('data:image/jpeg;base64,test'); + }); + }); }); describe('uploadContent', () => { diff --git a/src/server/services/file/impls/local.ts b/src/server/services/file/impls/local.ts index a0add64251b..95aa3e940ee 100644 --- a/src/server/services/file/impls/local.ts +++ b/src/server/services/file/impls/local.ts @@ -6,6 +6,7 @@ import { electronIpcClient } from '@/server/modules/ElectronIPCClient'; import { inferContentTypeFromImageUrl } from '@/utils/url'; import { FileServiceImpl } from './type'; +import { extractKeyFromUrlOrReturnOriginal } from './utils'; /** * 桌面应用本地文件服务实现 @@ -127,7 +128,11 @@ export class DesktopLocalFileImpl implements FileServiceImpl { */ async getFullFileUrl(url?: string | null): Promise { if (!url) return ''; - return this.getLocalFileUrl(url); + + // Handle legacy data compatibility using shared utility + const key = extractKeyFromUrlOrReturnOriginal(url, this.getKeyFromFullUrl.bind(this)); + + return this.getLocalFileUrl(key); } /** diff --git a/src/server/services/file/impls/s3.test.ts b/src/server/services/file/impls/s3.test.ts index de3bf126f85..46291d47055 100644 --- a/src/server/services/file/impls/s3.test.ts +++ b/src/server/services/file/impls/s3.test.ts @@ -65,6 +65,56 @@ describe('S3StaticFileImpl', () => { ); config.S3_ENABLE_PATH_STYLE = false; }); + + // Legacy bug compatibility tests - https://github.com/lobehub/lobe-chat/issues/8994 + describe('legacy bug compatibility', () => { + it('should handle full URL input by extracting key (S3_SET_ACL=false)', async () => { + config.S3_SET_ACL = false; + const fullUrl = 'https://s3.example.com/bucket/path/to/file.jpg?X-Amz-Signature=expired'; + + // Mock getKeyFromFullUrl to return the extracted key + vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg'); + + const result = await fileService.getFullFileUrl(fullUrl); + + expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl); + expect(result).toBe('https://presigned.example.com/test.jpg'); + config.S3_SET_ACL = true; + }); + + it('should handle full URL input by extracting key (S3_SET_ACL=true)', async () => { + const fullUrl = 'https://s3.example.com/bucket/path/to/file.jpg'; + + vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg'); + + const result = await fileService.getFullFileUrl(fullUrl); + + expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl); + expect(result).toBe('https://example.com/path/to/file.jpg'); + }); + + it('should handle normal key input without extraction', async () => { + const key = 'path/to/file.jpg'; + + const spy = vi.spyOn(fileService, 'getKeyFromFullUrl'); + + const result = await fileService.getFullFileUrl(key); + + expect(spy).not.toHaveBeenCalled(); + expect(result).toBe('https://example.com/path/to/file.jpg'); + }); + + it('should handle http:// URLs for legacy compatibility', async () => { + const httpUrl = 'http://s3.example.com/bucket/path/to/file.jpg'; + + vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg'); + + const result = await fileService.getFullFileUrl(httpUrl); + + expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(httpUrl); + expect(result).toBe('https://example.com/path/to/file.jpg'); + }); + }); }); describe('getFileContent', () => { diff --git a/src/server/services/file/impls/s3.ts b/src/server/services/file/impls/s3.ts index 48a86704ebe..2b27eaa8df9 100644 --- a/src/server/services/file/impls/s3.ts +++ b/src/server/services/file/impls/s3.ts @@ -4,6 +4,7 @@ import { fileEnv } from '@/config/file'; import { S3 } from '@/server/modules/S3'; import { FileServiceImpl } from './type'; +import { extractKeyFromUrlOrReturnOriginal } from './utils'; /** * 基于S3的文件服务实现 @@ -46,16 +47,19 @@ export class S3StaticFileImpl implements FileServiceImpl { async getFullFileUrl(url?: string | null, expiresIn?: number): Promise { if (!url) return ''; + // Handle legacy data compatibility using shared utility + const key = extractKeyFromUrlOrReturnOriginal(url, this.getKeyFromFullUrl.bind(this)); + // If bucket is not set public read, the preview address needs to be regenerated each time if (!fileEnv.S3_SET_ACL) { - return await this.createPreSignedUrlForPreview(url, expiresIn); + return await this.createPreSignedUrlForPreview(key, expiresIn); } if (fileEnv.S3_ENABLE_PATH_STYLE) { - return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!, url); + return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!, key); } - return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, url); + return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, key); } getKeyFromFullUrl(url: string): string { diff --git a/src/server/services/file/impls/utils.test.ts b/src/server/services/file/impls/utils.test.ts new file mode 100644 index 00000000000..ff6ff303c29 --- /dev/null +++ b/src/server/services/file/impls/utils.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { extractKeyFromUrlOrReturnOriginal } from './utils'; + +describe('extractKeyFromUrlOrReturnOriginal', () => { + const mockGetKeyFromFullUrl = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('URL detection', () => { + it('should detect https:// URLs and extract key', () => { + const httpsUrl = 'https://s3.example.com/bucket/path/to/file.jpg'; + const expectedKey = 'path/to/file.jpg'; + + mockGetKeyFromFullUrl.mockReturnValue(expectedKey); + + const result = extractKeyFromUrlOrReturnOriginal(httpsUrl, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(httpsUrl); + expect(result).toBe(expectedKey); + }); + + it('should detect http:// URLs and extract key', () => { + const httpUrl = 'http://s3.example.com/bucket/path/to/file.jpg'; + const expectedKey = 'path/to/file.jpg'; + + mockGetKeyFromFullUrl.mockReturnValue(expectedKey); + + const result = extractKeyFromUrlOrReturnOriginal(httpUrl, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(httpUrl); + expect(result).toBe(expectedKey); + }); + + it('should handle presigned URLs with query parameters', () => { + const presignedUrl = 'https://s3.amazonaws.com/bucket/file.jpg?X-Amz-Signature=abc&X-Amz-Expires=3600'; + const expectedKey = 'file.jpg'; + + mockGetKeyFromFullUrl.mockReturnValue(expectedKey); + + const result = extractKeyFromUrlOrReturnOriginal(presignedUrl, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(presignedUrl); + expect(result).toBe(expectedKey); + }); + }); + + describe('key passthrough', () => { + it('should return original string for plain keys', () => { + const key = 'path/to/file.jpg'; + + const result = extractKeyFromUrlOrReturnOriginal(key, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled(); + expect(result).toBe(key); + }); + + it('should return original string for desktop:// keys', () => { + const desktopKey = 'desktop://documents/file.pdf'; + + const result = extractKeyFromUrlOrReturnOriginal(desktopKey, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled(); + expect(result).toBe(desktopKey); + }); + + it('should return original string for relative paths', () => { + const relativePath = './assets/image.png'; + + const result = extractKeyFromUrlOrReturnOriginal(relativePath, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled(); + expect(result).toBe(relativePath); + }); + + it('should return original string for file:// protocol', () => { + const fileUrl = 'file:///Users/test/file.txt'; + + const result = extractKeyFromUrlOrReturnOriginal(fileUrl, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled(); + expect(result).toBe(fileUrl); + }); + }); + + describe('edge cases', () => { + it('should handle empty string', () => { + const emptyString = ''; + + const result = extractKeyFromUrlOrReturnOriginal(emptyString, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled(); + expect(result).toBe(emptyString); + }); + + it('should handle strings that start with http but are not URLs', () => { + const notUrl = 'httpish-string'; + + const result = extractKeyFromUrlOrReturnOriginal(notUrl, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled(); + expect(result).toBe(notUrl); + }); + + it('should handle case sensitivity correctly', () => { + const upperCaseHttps = 'HTTPS://example.com/file.jpg'; + + const result = extractKeyFromUrlOrReturnOriginal(upperCaseHttps, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled(); + expect(result).toBe(upperCaseHttps); + }); + }); + + describe('legacy bug scenarios', () => { + it('should handle S3 public URLs', () => { + const s3PublicUrl = 'https://mybucket.s3.amazonaws.com/images/photo.jpg'; + const expectedKey = 'images/photo.jpg'; + + mockGetKeyFromFullUrl.mockReturnValue(expectedKey); + + const result = extractKeyFromUrlOrReturnOriginal(s3PublicUrl, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(s3PublicUrl); + expect(result).toBe(expectedKey); + }); + + it('should handle custom domain S3 URLs', () => { + const customDomainUrl = 'https://cdn.example.com/files/document.pdf'; + const expectedKey = 'files/document.pdf'; + + mockGetKeyFromFullUrl.mockReturnValue(expectedKey); + + const result = extractKeyFromUrlOrReturnOriginal(customDomainUrl, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(customDomainUrl); + expect(result).toBe(expectedKey); + }); + + it('should handle local development URLs', () => { + const localUrl = 'http://localhost:3000/desktop-file/images/screenshot.png'; + const expectedKey = 'desktop://images/screenshot.png'; + + mockGetKeyFromFullUrl.mockReturnValue(expectedKey); + + const result = extractKeyFromUrlOrReturnOriginal(localUrl, mockGetKeyFromFullUrl); + + expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(localUrl); + expect(result).toBe(expectedKey); + }); + }); +}); \ No newline at end of file diff --git a/src/server/services/file/impls/utils.ts b/src/server/services/file/impls/utils.ts new file mode 100644 index 00000000000..127865e1af6 --- /dev/null +++ b/src/server/services/file/impls/utils.ts @@ -0,0 +1,17 @@ +/** + * Handle legacy bug where full URLs were stored instead of keys + * Some historical data stored complete URLs in database instead of just keys + * Related issue: https://github.com/lobehub/lobe-chat/issues/8994 + */ +export function extractKeyFromUrlOrReturnOriginal( + url: string, + getKeyFromFullUrl: (url: string) => string, +): string { + // Only process URLs that start with http:// or https:// + if (url.startsWith('http://') || url.startsWith('https://')) { + // Extract key from full URL for legacy data compatibility + return getKeyFromFullUrl(url); + } + // Return original input if it's already a key + return url; +} \ No newline at end of file