@lobehub/chat 1.120.4 → 1.120.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.vscode/settings.json +4 -4
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +5 -0
- package/package.json +2 -2
- package/packages/model-bank/src/standard-parameters/index.ts +2 -2
- package/src/server/routers/lambda/__tests__/image.test.ts +138 -0
- package/src/server/routers/lambda/image.ts +42 -4
- package/src/server/services/file/impls/local.test.ts +46 -1
- package/src/server/services/file/impls/local.ts +6 -1
- package/src/server/services/file/impls/s3.test.ts +50 -0
- package/src/server/services/file/impls/s3.ts +7 -3
- package/src/server/services/file/impls/utils.test.ts +154 -0
- package/src/server/services/file/impls/utils.ts +17 -0
package/.vscode/settings.json
CHANGED
@@ -59,9 +59,9 @@
|
|
59
59
|
"**/src/**/route.ts": "${dirname(1)}/${dirname} • route",
|
60
60
|
"**/src/**/index.tsx": "${dirname} • component",
|
61
61
|
|
62
|
-
"**/
|
63
|
-
"**/
|
64
|
-
"**/
|
62
|
+
"**/packages/database/src/repositories/*/index.ts": "${dirname} • db repository",
|
63
|
+
"**/packages/database/src/models/*.ts": "${filename} • db model",
|
64
|
+
"**/packages/database/src/schemas/*.ts": "${filename} • db schema",
|
65
65
|
|
66
66
|
"**/src/services/*.ts": "${filename} • service",
|
67
67
|
"**/src/services/*/client.ts": "${dirname} • client service",
|
@@ -81,7 +81,7 @@
|
|
81
81
|
"**/src/store/*/slices/*/reducer.ts": "${dirname(2)}/${dirname} • reducer",
|
82
82
|
|
83
83
|
"**/src/config/modelProviders/*.ts": "${filename} • provider",
|
84
|
-
"**/packages/model-bank/src/aiModels
|
84
|
+
"**/packages/model-bank/src/aiModels/*.ts": "${filename} • model",
|
85
85
|
"**/packages/model-runtime/src/*/index.ts": "${dirname} • runtime",
|
86
86
|
|
87
87
|
"**/src/server/services/*/index.ts": "${dirname} • server/service",
|
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,31 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
### [Version 1.120.5](https://github.com/lobehub/lobe-chat/compare/v1.120.4...v1.120.5)
|
6
|
+
|
7
|
+
<sup>Released on **2025-09-01**</sup>
|
8
|
+
|
9
|
+
#### 🐛 Bug Fixes
|
10
|
+
|
11
|
+
- **ai-image**: Save config.imageUrl with fullUrl instead of key.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### What's fixed
|
19
|
+
|
20
|
+
- **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))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
5
30
|
### [Version 1.120.4](https://github.com/lobehub/lobe-chat/compare/v1.120.3...v1.120.4)
|
6
31
|
|
7
32
|
<sup>Released on **2025-09-01**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.120.
|
3
|
+
"version": "1.120.5",
|
4
4
|
"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.",
|
5
5
|
"keywords": [
|
6
6
|
"framework",
|
@@ -192,7 +192,7 @@
|
|
192
192
|
"fast-deep-equal": "^3.1.3",
|
193
193
|
"file-type": "^21.0.0",
|
194
194
|
"framer-motion": "^12.23.12",
|
195
|
-
"gpt-tokenizer": "^
|
195
|
+
"gpt-tokenizer": "^3.0.0",
|
196
196
|
"gray-matter": "^4.0.3",
|
197
197
|
"html-to-text": "^9.0.5",
|
198
198
|
"i18next": "^25.3.2",
|
@@ -0,0 +1,138 @@
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
2
|
+
|
3
|
+
// Copy of the validation function from image.ts for testing
|
4
|
+
function validateNoUrlsInConfig(obj: any, path: string = ''): void {
|
5
|
+
if (typeof obj === 'string') {
|
6
|
+
if (obj.startsWith('http://') || obj.startsWith('https://')) {
|
7
|
+
throw new Error(
|
8
|
+
`Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` +
|
9
|
+
`URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` +
|
10
|
+
`All URLs must be converted to storage keys before database insertion.`,
|
11
|
+
);
|
12
|
+
}
|
13
|
+
} else if (Array.isArray(obj)) {
|
14
|
+
obj.forEach((item, index) => {
|
15
|
+
validateNoUrlsInConfig(item, `${path}[${index}]`);
|
16
|
+
});
|
17
|
+
} else if (obj && typeof obj === 'object') {
|
18
|
+
Object.entries(obj).forEach(([key, value]) => {
|
19
|
+
const currentPath = path ? `${path}.${key}` : key;
|
20
|
+
validateNoUrlsInConfig(value, currentPath);
|
21
|
+
});
|
22
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
describe('imageRouter', () => {
|
26
|
+
describe('validateNoUrlsInConfig utility', () => {
|
27
|
+
describe('valid configurations', () => {
|
28
|
+
it('should pass with normal keys', () => {
|
29
|
+
const config = {
|
30
|
+
imageUrl: 'images/photo.jpg',
|
31
|
+
imageUrls: ['files/doc.pdf', 'assets/video.mp4'],
|
32
|
+
prompt: 'Generate an image',
|
33
|
+
};
|
34
|
+
|
35
|
+
expect(() => validateNoUrlsInConfig(config)).not.toThrow();
|
36
|
+
});
|
37
|
+
|
38
|
+
it('should pass with empty strings', () => {
|
39
|
+
const config = {
|
40
|
+
imageUrl: '',
|
41
|
+
imageUrls: [],
|
42
|
+
prompt: 'Generate an image',
|
43
|
+
};
|
44
|
+
|
45
|
+
expect(() => validateNoUrlsInConfig(config)).not.toThrow();
|
46
|
+
});
|
47
|
+
|
48
|
+
it('should pass with null/undefined values', () => {
|
49
|
+
const config = {
|
50
|
+
imageUrl: null,
|
51
|
+
imageUrls: undefined,
|
52
|
+
prompt: 'Generate an image',
|
53
|
+
};
|
54
|
+
|
55
|
+
expect(() => validateNoUrlsInConfig(config)).not.toThrow();
|
56
|
+
});
|
57
|
+
});
|
58
|
+
|
59
|
+
describe('invalid configurations', () => {
|
60
|
+
it('should throw for https URL in imageUrl', () => {
|
61
|
+
const config = {
|
62
|
+
imageUrl: 'https://s3.amazonaws.com/bucket/image.jpg',
|
63
|
+
prompt: 'Generate an image',
|
64
|
+
};
|
65
|
+
|
66
|
+
expect(() => validateNoUrlsInConfig(config)).toThrow(
|
67
|
+
'Invalid configuration: Found full URL instead of key at imageUrl',
|
68
|
+
);
|
69
|
+
});
|
70
|
+
|
71
|
+
it('should throw for http URL in imageUrls array', () => {
|
72
|
+
const config = {
|
73
|
+
imageUrls: ['files/doc.pdf', 'http://example.com/image.jpg'],
|
74
|
+
prompt: 'Generate an image',
|
75
|
+
};
|
76
|
+
|
77
|
+
expect(() => validateNoUrlsInConfig(config)).toThrow(
|
78
|
+
'Invalid configuration: Found full URL instead of key at imageUrls[1]',
|
79
|
+
);
|
80
|
+
});
|
81
|
+
|
82
|
+
it('should throw for nested URL in complex object', () => {
|
83
|
+
const config = {
|
84
|
+
settings: {
|
85
|
+
imageConfig: {
|
86
|
+
url: 'https://cdn.example.com/very-long-url-that-exceeds-100-characters-to-test-truncation-functionality.jpg',
|
87
|
+
},
|
88
|
+
},
|
89
|
+
};
|
90
|
+
|
91
|
+
expect(() => validateNoUrlsInConfig(config)).toThrow(
|
92
|
+
'Invalid configuration: Found full URL instead of key at settings.imageConfig.url',
|
93
|
+
);
|
94
|
+
expect(() => validateNoUrlsInConfig(config)).toThrow(
|
95
|
+
'https://cdn.example.com/very-long-url-that-exceeds-100-characters-to-test-truncation-func',
|
96
|
+
);
|
97
|
+
});
|
98
|
+
|
99
|
+
it('should throw for presigned URL with query parameters', () => {
|
100
|
+
const config = {
|
101
|
+
imageUrl:
|
102
|
+
'https://s3.amazonaws.com/bucket/file.jpg?X-Amz-Signature=abc&X-Amz-Expires=3600',
|
103
|
+
};
|
104
|
+
|
105
|
+
expect(() => validateNoUrlsInConfig(config)).toThrow(
|
106
|
+
'All URLs must be converted to storage keys before database insertion',
|
107
|
+
);
|
108
|
+
});
|
109
|
+
});
|
110
|
+
|
111
|
+
describe('edge cases', () => {
|
112
|
+
it('should handle deeply nested structures', () => {
|
113
|
+
const config = {
|
114
|
+
level1: {
|
115
|
+
level2: {
|
116
|
+
level3: {
|
117
|
+
level4: ['normal-key', 'https://bad-url.com'],
|
118
|
+
},
|
119
|
+
},
|
120
|
+
},
|
121
|
+
};
|
122
|
+
|
123
|
+
expect(() => validateNoUrlsInConfig(config)).toThrow(
|
124
|
+
'Invalid configuration: Found full URL instead of key at level1.level2.level3.level4[1]',
|
125
|
+
);
|
126
|
+
});
|
127
|
+
|
128
|
+
it('should not throw for strings that contain but do not start with http', () => {
|
129
|
+
const config = {
|
130
|
+
imageUrl: 'some-prefix-https://example.com',
|
131
|
+
description: 'This text contains http:// but is not a URL',
|
132
|
+
};
|
133
|
+
|
134
|
+
expect(() => validateNoUrlsInConfig(config)).not.toThrow();
|
135
|
+
});
|
136
|
+
});
|
137
|
+
});
|
138
|
+
});
|
@@ -24,6 +24,31 @@ import { generateUniqueSeeds } from '@/utils/number';
|
|
24
24
|
|
25
25
|
const log = debug('lobe-image:lambda');
|
26
26
|
|
27
|
+
/**
|
28
|
+
* Recursively validate that no full URLs are present in the config
|
29
|
+
* This is a defensive check to ensure only keys are stored in database
|
30
|
+
*/
|
31
|
+
function validateNoUrlsInConfig(obj: any, path: string = ''): void {
|
32
|
+
if (typeof obj === 'string') {
|
33
|
+
if (obj.startsWith('http://') || obj.startsWith('https://')) {
|
34
|
+
throw new Error(
|
35
|
+
`Invalid configuration: Found full URL instead of key at ${path || 'root'}. ` +
|
36
|
+
`URL: "${obj.slice(0, 100)}${obj.length > 100 ? '...' : ''}". ` +
|
37
|
+
`All URLs must be converted to storage keys before database insertion.`,
|
38
|
+
);
|
39
|
+
}
|
40
|
+
} else if (Array.isArray(obj)) {
|
41
|
+
obj.forEach((item, index) => {
|
42
|
+
validateNoUrlsInConfig(item, `${path}[${index}]`);
|
43
|
+
});
|
44
|
+
} else if (obj && typeof obj === 'object') {
|
45
|
+
Object.entries(obj).forEach(([key, value]) => {
|
46
|
+
const currentPath = path ? `${path}.${key}` : key;
|
47
|
+
validateNoUrlsInConfig(value, currentPath);
|
48
|
+
});
|
49
|
+
}
|
50
|
+
}
|
51
|
+
|
27
52
|
const imageProcedure = authedProcedure
|
28
53
|
.use(keyVaults)
|
29
54
|
.use(serverDatabase)
|
@@ -71,8 +96,9 @@ export const imageRouter = router({
|
|
71
96
|
|
72
97
|
log('Starting image creation process, input: %O', input);
|
73
98
|
|
74
|
-
//
|
99
|
+
// 规范化参考图地址,统一存储 S3 key(避免把会过期的预签名 URL 存进数据库)
|
75
100
|
let configForDatabase = { ...params };
|
101
|
+
// 1) 处理多图 imageUrls
|
76
102
|
if (Array.isArray(params.imageUrls) && params.imageUrls.length > 0) {
|
77
103
|
log('Converting imageUrls to S3 keys for database storage: %O', params.imageUrls);
|
78
104
|
try {
|
@@ -82,18 +108,30 @@ export const imageRouter = router({
|
|
82
108
|
return key;
|
83
109
|
});
|
84
110
|
|
85
|
-
// 将转换后的 keys 存储为数据库配置
|
86
111
|
configForDatabase = {
|
87
|
-
...
|
112
|
+
...configForDatabase,
|
88
113
|
imageUrls: imageKeys,
|
89
114
|
};
|
90
115
|
log('Successfully converted imageUrls to keys for database: %O', imageKeys);
|
91
116
|
} catch (error) {
|
92
117
|
log('Error converting imageUrls to keys: %O', error);
|
93
|
-
// 如果转换失败,保持原始 URLs(可能是本地文件或其他格式)
|
94
118
|
log('Keeping original imageUrls due to conversion error');
|
95
119
|
}
|
96
120
|
}
|
121
|
+
// 2) 处理单图 imageUrl
|
122
|
+
if (typeof params.imageUrl === 'string' && params.imageUrl) {
|
123
|
+
try {
|
124
|
+
const key = fileService.getKeyFromFullUrl(params.imageUrl);
|
125
|
+
log('Converted single imageUrl to key: %s -> %s', params.imageUrl, key);
|
126
|
+
configForDatabase = { ...configForDatabase, imageUrl: key };
|
127
|
+
} catch (error) {
|
128
|
+
log('Error converting imageUrl to key: %O', error);
|
129
|
+
// 转换失败则保留原始值
|
130
|
+
}
|
131
|
+
}
|
132
|
+
|
133
|
+
// 防御性检测:确保没有完整URL进入数据库
|
134
|
+
validateNoUrlsInConfig(configForDatabase, 'configForDatabase');
|
97
135
|
|
98
136
|
// 步骤 1: 在事务中原子性地创建所有数据库记录
|
99
137
|
const { batch: createdBatch, generationsWithTasks } = await serverDB.transaction(async (tx) => {
|
@@ -1,5 +1,4 @@
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
2
|
-
import path from 'node:path';
|
3
2
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
4
3
|
|
5
4
|
import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
|
@@ -234,6 +233,52 @@ describe('DesktopLocalFileImpl', () => {
|
|
234
233
|
// 验证
|
235
234
|
expect(result).toBe('');
|
236
235
|
});
|
236
|
+
|
237
|
+
// Legacy bug compatibility tests - https://github.com/lobehub/lobe-chat/issues/8994
|
238
|
+
describe('legacy bug compatibility', () => {
|
239
|
+
it('should handle full URL input by extracting key', async () => {
|
240
|
+
const fullUrl = 'http://localhost:3000/desktop-file/documents/test.txt';
|
241
|
+
|
242
|
+
// Mock getKeyFromFullUrl and getLocalFileUrl
|
243
|
+
vi.spyOn(service, 'getKeyFromFullUrl').mockReturnValue('desktop://documents/test.txt');
|
244
|
+
const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
|
245
|
+
getLocalFileUrlSpy.mockResolvedValueOnce('data:image/png;base64,test');
|
246
|
+
|
247
|
+
const result = await service.getFullFileUrl(fullUrl);
|
248
|
+
|
249
|
+
expect(service.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl);
|
250
|
+
expect(getLocalFileUrlSpy).toHaveBeenCalledWith('desktop://documents/test.txt');
|
251
|
+
expect(result).toBe('data:image/png;base64,test');
|
252
|
+
});
|
253
|
+
|
254
|
+
it('should handle normal desktop:// key input without extraction', async () => {
|
255
|
+
const key = 'desktop://documents/test.txt';
|
256
|
+
|
257
|
+
const extractSpy = vi.spyOn(service, 'getKeyFromFullUrl');
|
258
|
+
const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
|
259
|
+
getLocalFileUrlSpy.mockResolvedValueOnce('data:image/png;base64,test');
|
260
|
+
|
261
|
+
const result = await service.getFullFileUrl(key);
|
262
|
+
|
263
|
+
expect(extractSpy).not.toHaveBeenCalled();
|
264
|
+
expect(getLocalFileUrlSpy).toHaveBeenCalledWith(key);
|
265
|
+
expect(result).toBe('data:image/png;base64,test');
|
266
|
+
});
|
267
|
+
|
268
|
+
it('should handle https:// URLs for legacy compatibility', async () => {
|
269
|
+
const httpsUrl = 'https://localhost:3000/desktop-file/images/photo.jpg';
|
270
|
+
|
271
|
+
vi.spyOn(service, 'getKeyFromFullUrl').mockReturnValue('desktop://images/photo.jpg');
|
272
|
+
const getLocalFileUrlSpy = vi.spyOn(service as any, 'getLocalFileUrl');
|
273
|
+
getLocalFileUrlSpy.mockResolvedValueOnce('data:image/jpeg;base64,test');
|
274
|
+
|
275
|
+
const result = await service.getFullFileUrl(httpsUrl);
|
276
|
+
|
277
|
+
expect(service.getKeyFromFullUrl).toHaveBeenCalledWith(httpsUrl);
|
278
|
+
expect(getLocalFileUrlSpy).toHaveBeenCalledWith('desktop://images/photo.jpg');
|
279
|
+
expect(result).toBe('data:image/jpeg;base64,test');
|
280
|
+
});
|
281
|
+
});
|
237
282
|
});
|
238
283
|
|
239
284
|
describe('uploadContent', () => {
|
@@ -6,6 +6,7 @@ import { electronIpcClient } from '@/server/modules/ElectronIPCClient';
|
|
6
6
|
import { inferContentTypeFromImageUrl } from '@/utils/url';
|
7
7
|
|
8
8
|
import { FileServiceImpl } from './type';
|
9
|
+
import { extractKeyFromUrlOrReturnOriginal } from './utils';
|
9
10
|
|
10
11
|
/**
|
11
12
|
* 桌面应用本地文件服务实现
|
@@ -127,7 +128,11 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
|
|
127
128
|
*/
|
128
129
|
async getFullFileUrl(url?: string | null): Promise<string> {
|
129
130
|
if (!url) return '';
|
130
|
-
|
131
|
+
|
132
|
+
// Handle legacy data compatibility using shared utility
|
133
|
+
const key = extractKeyFromUrlOrReturnOriginal(url, this.getKeyFromFullUrl.bind(this));
|
134
|
+
|
135
|
+
return this.getLocalFileUrl(key);
|
131
136
|
}
|
132
137
|
|
133
138
|
/**
|
@@ -65,6 +65,56 @@ describe('S3StaticFileImpl', () => {
|
|
65
65
|
);
|
66
66
|
config.S3_ENABLE_PATH_STYLE = false;
|
67
67
|
});
|
68
|
+
|
69
|
+
// Legacy bug compatibility tests - https://github.com/lobehub/lobe-chat/issues/8994
|
70
|
+
describe('legacy bug compatibility', () => {
|
71
|
+
it('should handle full URL input by extracting key (S3_SET_ACL=false)', async () => {
|
72
|
+
config.S3_SET_ACL = false;
|
73
|
+
const fullUrl = 'https://s3.example.com/bucket/path/to/file.jpg?X-Amz-Signature=expired';
|
74
|
+
|
75
|
+
// Mock getKeyFromFullUrl to return the extracted key
|
76
|
+
vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg');
|
77
|
+
|
78
|
+
const result = await fileService.getFullFileUrl(fullUrl);
|
79
|
+
|
80
|
+
expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl);
|
81
|
+
expect(result).toBe('https://presigned.example.com/test.jpg');
|
82
|
+
config.S3_SET_ACL = true;
|
83
|
+
});
|
84
|
+
|
85
|
+
it('should handle full URL input by extracting key (S3_SET_ACL=true)', async () => {
|
86
|
+
const fullUrl = 'https://s3.example.com/bucket/path/to/file.jpg';
|
87
|
+
|
88
|
+
vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg');
|
89
|
+
|
90
|
+
const result = await fileService.getFullFileUrl(fullUrl);
|
91
|
+
|
92
|
+
expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(fullUrl);
|
93
|
+
expect(result).toBe('https://example.com/path/to/file.jpg');
|
94
|
+
});
|
95
|
+
|
96
|
+
it('should handle normal key input without extraction', async () => {
|
97
|
+
const key = 'path/to/file.jpg';
|
98
|
+
|
99
|
+
const spy = vi.spyOn(fileService, 'getKeyFromFullUrl');
|
100
|
+
|
101
|
+
const result = await fileService.getFullFileUrl(key);
|
102
|
+
|
103
|
+
expect(spy).not.toHaveBeenCalled();
|
104
|
+
expect(result).toBe('https://example.com/path/to/file.jpg');
|
105
|
+
});
|
106
|
+
|
107
|
+
it('should handle http:// URLs for legacy compatibility', async () => {
|
108
|
+
const httpUrl = 'http://s3.example.com/bucket/path/to/file.jpg';
|
109
|
+
|
110
|
+
vi.spyOn(fileService, 'getKeyFromFullUrl').mockReturnValue('path/to/file.jpg');
|
111
|
+
|
112
|
+
const result = await fileService.getFullFileUrl(httpUrl);
|
113
|
+
|
114
|
+
expect(fileService.getKeyFromFullUrl).toHaveBeenCalledWith(httpUrl);
|
115
|
+
expect(result).toBe('https://example.com/path/to/file.jpg');
|
116
|
+
});
|
117
|
+
});
|
68
118
|
});
|
69
119
|
|
70
120
|
describe('getFileContent', () => {
|
@@ -4,6 +4,7 @@ import { fileEnv } from '@/config/file';
|
|
4
4
|
import { S3 } from '@/server/modules/S3';
|
5
5
|
|
6
6
|
import { FileServiceImpl } from './type';
|
7
|
+
import { extractKeyFromUrlOrReturnOriginal } from './utils';
|
7
8
|
|
8
9
|
/**
|
9
10
|
* 基于S3的文件服务实现
|
@@ -46,16 +47,19 @@ export class S3StaticFileImpl implements FileServiceImpl {
|
|
46
47
|
async getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string> {
|
47
48
|
if (!url) return '';
|
48
49
|
|
50
|
+
// Handle legacy data compatibility using shared utility
|
51
|
+
const key = extractKeyFromUrlOrReturnOriginal(url, this.getKeyFromFullUrl.bind(this));
|
52
|
+
|
49
53
|
// If bucket is not set public read, the preview address needs to be regenerated each time
|
50
54
|
if (!fileEnv.S3_SET_ACL) {
|
51
|
-
return await this.createPreSignedUrlForPreview(
|
55
|
+
return await this.createPreSignedUrlForPreview(key, expiresIn);
|
52
56
|
}
|
53
57
|
|
54
58
|
if (fileEnv.S3_ENABLE_PATH_STYLE) {
|
55
|
-
return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!,
|
59
|
+
return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, fileEnv.S3_BUCKET!, key);
|
56
60
|
}
|
57
61
|
|
58
|
-
return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!,
|
62
|
+
return urlJoin(fileEnv.S3_PUBLIC_DOMAIN!, key);
|
59
63
|
}
|
60
64
|
|
61
65
|
getKeyFromFullUrl(url: string): string {
|
@@ -0,0 +1,154 @@
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { extractKeyFromUrlOrReturnOriginal } from './utils';
|
4
|
+
|
5
|
+
describe('extractKeyFromUrlOrReturnOriginal', () => {
|
6
|
+
const mockGetKeyFromFullUrl = vi.fn();
|
7
|
+
|
8
|
+
beforeEach(() => {
|
9
|
+
vi.clearAllMocks();
|
10
|
+
});
|
11
|
+
|
12
|
+
describe('URL detection', () => {
|
13
|
+
it('should detect https:// URLs and extract key', () => {
|
14
|
+
const httpsUrl = 'https://s3.example.com/bucket/path/to/file.jpg';
|
15
|
+
const expectedKey = 'path/to/file.jpg';
|
16
|
+
|
17
|
+
mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
|
18
|
+
|
19
|
+
const result = extractKeyFromUrlOrReturnOriginal(httpsUrl, mockGetKeyFromFullUrl);
|
20
|
+
|
21
|
+
expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(httpsUrl);
|
22
|
+
expect(result).toBe(expectedKey);
|
23
|
+
});
|
24
|
+
|
25
|
+
it('should detect http:// URLs and extract key', () => {
|
26
|
+
const httpUrl = 'http://s3.example.com/bucket/path/to/file.jpg';
|
27
|
+
const expectedKey = 'path/to/file.jpg';
|
28
|
+
|
29
|
+
mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
|
30
|
+
|
31
|
+
const result = extractKeyFromUrlOrReturnOriginal(httpUrl, mockGetKeyFromFullUrl);
|
32
|
+
|
33
|
+
expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(httpUrl);
|
34
|
+
expect(result).toBe(expectedKey);
|
35
|
+
});
|
36
|
+
|
37
|
+
it('should handle presigned URLs with query parameters', () => {
|
38
|
+
const presignedUrl = 'https://s3.amazonaws.com/bucket/file.jpg?X-Amz-Signature=abc&X-Amz-Expires=3600';
|
39
|
+
const expectedKey = 'file.jpg';
|
40
|
+
|
41
|
+
mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
|
42
|
+
|
43
|
+
const result = extractKeyFromUrlOrReturnOriginal(presignedUrl, mockGetKeyFromFullUrl);
|
44
|
+
|
45
|
+
expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(presignedUrl);
|
46
|
+
expect(result).toBe(expectedKey);
|
47
|
+
});
|
48
|
+
});
|
49
|
+
|
50
|
+
describe('key passthrough', () => {
|
51
|
+
it('should return original string for plain keys', () => {
|
52
|
+
const key = 'path/to/file.jpg';
|
53
|
+
|
54
|
+
const result = extractKeyFromUrlOrReturnOriginal(key, mockGetKeyFromFullUrl);
|
55
|
+
|
56
|
+
expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
|
57
|
+
expect(result).toBe(key);
|
58
|
+
});
|
59
|
+
|
60
|
+
it('should return original string for desktop:// keys', () => {
|
61
|
+
const desktopKey = 'desktop://documents/file.pdf';
|
62
|
+
|
63
|
+
const result = extractKeyFromUrlOrReturnOriginal(desktopKey, mockGetKeyFromFullUrl);
|
64
|
+
|
65
|
+
expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
|
66
|
+
expect(result).toBe(desktopKey);
|
67
|
+
});
|
68
|
+
|
69
|
+
it('should return original string for relative paths', () => {
|
70
|
+
const relativePath = './assets/image.png';
|
71
|
+
|
72
|
+
const result = extractKeyFromUrlOrReturnOriginal(relativePath, mockGetKeyFromFullUrl);
|
73
|
+
|
74
|
+
expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
|
75
|
+
expect(result).toBe(relativePath);
|
76
|
+
});
|
77
|
+
|
78
|
+
it('should return original string for file:// protocol', () => {
|
79
|
+
const fileUrl = 'file:///Users/test/file.txt';
|
80
|
+
|
81
|
+
const result = extractKeyFromUrlOrReturnOriginal(fileUrl, mockGetKeyFromFullUrl);
|
82
|
+
|
83
|
+
expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
|
84
|
+
expect(result).toBe(fileUrl);
|
85
|
+
});
|
86
|
+
});
|
87
|
+
|
88
|
+
describe('edge cases', () => {
|
89
|
+
it('should handle empty string', () => {
|
90
|
+
const emptyString = '';
|
91
|
+
|
92
|
+
const result = extractKeyFromUrlOrReturnOriginal(emptyString, mockGetKeyFromFullUrl);
|
93
|
+
|
94
|
+
expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
|
95
|
+
expect(result).toBe(emptyString);
|
96
|
+
});
|
97
|
+
|
98
|
+
it('should handle strings that start with http but are not URLs', () => {
|
99
|
+
const notUrl = 'httpish-string';
|
100
|
+
|
101
|
+
const result = extractKeyFromUrlOrReturnOriginal(notUrl, mockGetKeyFromFullUrl);
|
102
|
+
|
103
|
+
expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
|
104
|
+
expect(result).toBe(notUrl);
|
105
|
+
});
|
106
|
+
|
107
|
+
it('should handle case sensitivity correctly', () => {
|
108
|
+
const upperCaseHttps = 'HTTPS://example.com/file.jpg';
|
109
|
+
|
110
|
+
const result = extractKeyFromUrlOrReturnOriginal(upperCaseHttps, mockGetKeyFromFullUrl);
|
111
|
+
|
112
|
+
expect(mockGetKeyFromFullUrl).not.toHaveBeenCalled();
|
113
|
+
expect(result).toBe(upperCaseHttps);
|
114
|
+
});
|
115
|
+
});
|
116
|
+
|
117
|
+
describe('legacy bug scenarios', () => {
|
118
|
+
it('should handle S3 public URLs', () => {
|
119
|
+
const s3PublicUrl = 'https://mybucket.s3.amazonaws.com/images/photo.jpg';
|
120
|
+
const expectedKey = 'images/photo.jpg';
|
121
|
+
|
122
|
+
mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
|
123
|
+
|
124
|
+
const result = extractKeyFromUrlOrReturnOriginal(s3PublicUrl, mockGetKeyFromFullUrl);
|
125
|
+
|
126
|
+
expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(s3PublicUrl);
|
127
|
+
expect(result).toBe(expectedKey);
|
128
|
+
});
|
129
|
+
|
130
|
+
it('should handle custom domain S3 URLs', () => {
|
131
|
+
const customDomainUrl = 'https://cdn.example.com/files/document.pdf';
|
132
|
+
const expectedKey = 'files/document.pdf';
|
133
|
+
|
134
|
+
mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
|
135
|
+
|
136
|
+
const result = extractKeyFromUrlOrReturnOriginal(customDomainUrl, mockGetKeyFromFullUrl);
|
137
|
+
|
138
|
+
expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(customDomainUrl);
|
139
|
+
expect(result).toBe(expectedKey);
|
140
|
+
});
|
141
|
+
|
142
|
+
it('should handle local development URLs', () => {
|
143
|
+
const localUrl = 'http://localhost:3000/desktop-file/images/screenshot.png';
|
144
|
+
const expectedKey = 'desktop://images/screenshot.png';
|
145
|
+
|
146
|
+
mockGetKeyFromFullUrl.mockReturnValue(expectedKey);
|
147
|
+
|
148
|
+
const result = extractKeyFromUrlOrReturnOriginal(localUrl, mockGetKeyFromFullUrl);
|
149
|
+
|
150
|
+
expect(mockGetKeyFromFullUrl).toHaveBeenCalledWith(localUrl);
|
151
|
+
expect(result).toBe(expectedKey);
|
152
|
+
});
|
153
|
+
});
|
154
|
+
});
|
@@ -0,0 +1,17 @@
|
|
1
|
+
/**
|
2
|
+
* Handle legacy bug where full URLs were stored instead of keys
|
3
|
+
* Some historical data stored complete URLs in database instead of just keys
|
4
|
+
* Related issue: https://github.com/lobehub/lobe-chat/issues/8994
|
5
|
+
*/
|
6
|
+
export function extractKeyFromUrlOrReturnOriginal(
|
7
|
+
url: string,
|
8
|
+
getKeyFromFullUrl: (url: string) => string,
|
9
|
+
): string {
|
10
|
+
// Only process URLs that start with http:// or https://
|
11
|
+
if (url.startsWith('http://') || url.startsWith('https://')) {
|
12
|
+
// Extract key from full URL for legacy data compatibility
|
13
|
+
return getKeyFromFullUrl(url);
|
14
|
+
}
|
15
|
+
// Return original input if it's already a key
|
16
|
+
return url;
|
17
|
+
}
|