mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 06:02:43 +08:00
feat: support attached images for all providers
This commit is contained in:
93
server/modules/assets/assets.routes.ts
Normal file
93
server/modules/assets/assets.routes.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import fsSync, { promises as fs } from 'node:fs';
|
||||
|
||||
import express from 'express';
|
||||
import mime from 'mime-types';
|
||||
import multer from 'multer';
|
||||
|
||||
import {
|
||||
buildStoredImageRecords,
|
||||
ensureImageAssetsDir,
|
||||
isAllowedImageMimeType,
|
||||
resolveImageAssetFile,
|
||||
} from '@/modules/assets/services/image-assets.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Multer writes uploads straight into the global assets folder; the service
|
||||
// owns the folder location and the response record shape.
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
ensureImageAssetsDir()
|
||||
.then((assetsDir) => cb(null, assetsDir))
|
||||
.catch((error) => cb(error as Error, ''));
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
|
||||
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||
cb(null, `${uniqueSuffix}-${sanitizedName}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (isAllowedImageMimeType(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG, GIF, WebP, and SVG are allowed.'));
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB
|
||||
files: 5,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Stores chat image attachments in the global `~/.cloudcli/assets` folder and
|
||||
* returns their absolute paths for use in provider prompts and chat history.
|
||||
*/
|
||||
router.post('/images', (req, res) => {
|
||||
upload.array('images', 5)(req, res, (err: unknown) => {
|
||||
if (err) {
|
||||
const message = err instanceof Error ? err.message : 'Upload failed';
|
||||
return res.status(400).json({ error: message });
|
||||
}
|
||||
|
||||
const files = Array.isArray(req.files) ? req.files : [];
|
||||
if (files.length === 0) {
|
||||
return res.status(400).json({ error: 'No image files provided' });
|
||||
}
|
||||
|
||||
res.json({ images: buildStoredImageRecords(files) });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Serves one stored image asset by filename. Only files directly inside the
|
||||
* global assets folder are reachable; traversal attempts resolve to null.
|
||||
*/
|
||||
router.get('/images/:filename', async (req, res) => {
|
||||
const resolved = resolveImageAssetFile(req.params.filename);
|
||||
if (!resolved) {
|
||||
return res.status(400).json({ error: 'Invalid asset filename' });
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(resolved);
|
||||
} catch {
|
||||
return res.status(404).json({ error: 'Asset not found' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', mime.lookup(resolved) || 'application/octet-stream');
|
||||
const fileStream = fsSync.createReadStream(resolved);
|
||||
fileStream.pipe(res);
|
||||
fileStream.on('error', (error) => {
|
||||
console.error('Error streaming image asset:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Error reading asset' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
3
server/modules/assets/index.ts
Normal file
3
server/modules/assets/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Express router mounted at /api/assets by server/index.js (upload + serving
|
||||
// of chat image attachments stored in the global ~/.cloudcli/assets folder).
|
||||
export { default as assetsRoutes } from './assets.routes.js';
|
||||
82
server/modules/assets/services/image-assets.service.ts
Normal file
82
server/modules/assets/services/image-assets.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getGlobalImageAssetsDir, toPosixPath } from '@/shared/image-attachments.js';
|
||||
|
||||
/**
|
||||
* Image mime types accepted for chat attachment uploads. SVG is allowed for
|
||||
* storage/preview even though some providers (Claude API) skip it at send time.
|
||||
*/
|
||||
const ALLOWED_IMAGE_MIME_TYPES = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
]);
|
||||
|
||||
// Used only by this service and the assets routes via the barrel file.
|
||||
type StoredImageAsset = {
|
||||
/** Original upload filename, for display. */
|
||||
name: string;
|
||||
/** Absolute posix-normalized path inside the global assets folder. */
|
||||
path: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
// Shape of one multer-stored file; kept local because only this module reads it.
|
||||
type UploadedImageFile = {
|
||||
originalname: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mimetype: string;
|
||||
};
|
||||
|
||||
/** Returns whether one uploaded mime type may be stored as a chat image asset. */
|
||||
export function isAllowedImageMimeType(mimeType: string): boolean {
|
||||
return ALLOWED_IMAGE_MIME_TYPES.has(mimeType);
|
||||
}
|
||||
|
||||
/** Creates the global `~/.cloudcli/assets` folder if needed and returns it. */
|
||||
export async function ensureImageAssetsDir(): Promise<string> {
|
||||
const assetsDir = getGlobalImageAssetsDir();
|
||||
await fs.mkdir(assetsDir, { recursive: true });
|
||||
return assetsDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps multer-stored upload files to the attachment records returned to the
|
||||
* chat composer. The absolute path is what providers receive and what session
|
||||
* history carries back to the UI.
|
||||
*/
|
||||
export function buildStoredImageRecords(files: UploadedImageFile[]): StoredImageAsset[] {
|
||||
const assetsDir = getGlobalImageAssetsDir();
|
||||
return files.map((file) => ({
|
||||
name: file.originalname,
|
||||
path: toPosixPath(path.join(assetsDir, file.filename)),
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves one asset filename to its absolute path inside the global assets
|
||||
* folder, or null when the name is empty, contains path separators/traversal,
|
||||
* or would escape the folder. This is the only lookup the serving route uses,
|
||||
* so nothing outside `~/.cloudcli/assets` can ever be read through it.
|
||||
*/
|
||||
export function resolveImageAssetFile(filename: string): string | null {
|
||||
const trimmed = typeof filename === 'string' ? filename.trim() : '';
|
||||
if (!trimmed || trimmed.includes('/') || trimmed.includes('\\') || trimmed.includes('..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assetsDir = path.resolve(getGlobalImageAssetsDir());
|
||||
const resolved = path.resolve(assetsDir, trimmed);
|
||||
if (!resolved.startsWith(assetsDir + path.sep)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
46
server/modules/assets/tests/image-assets.service.test.ts
Normal file
46
server/modules/assets/tests/image-assets.service.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildStoredImageRecords,
|
||||
isAllowedImageMimeType,
|
||||
resolveImageAssetFile,
|
||||
} from '@/modules/assets/services/image-assets.service.js';
|
||||
|
||||
const ASSETS_DIR = path.join(os.homedir(), '.cloudcli', 'assets');
|
||||
|
||||
test('isAllowedImageMimeType accepts image formats and rejects the rest', () => {
|
||||
assert.equal(isAllowedImageMimeType('image/png'), true);
|
||||
assert.equal(isAllowedImageMimeType('image/svg+xml'), true);
|
||||
assert.equal(isAllowedImageMimeType('application/pdf'), false);
|
||||
assert.equal(isAllowedImageMimeType('text/html'), false);
|
||||
});
|
||||
|
||||
test('buildStoredImageRecords returns absolute posix paths in the assets dir', () => {
|
||||
const records = buildStoredImageRecords([
|
||||
{ originalname: 'shot.png', filename: '123-456-shot.png', size: 42, mimetype: 'image/png' },
|
||||
]);
|
||||
|
||||
assert.equal(records.length, 1);
|
||||
assert.equal(records[0].name, 'shot.png');
|
||||
assert.equal(records[0].size, 42);
|
||||
assert.equal(records[0].mimeType, 'image/png');
|
||||
assert.equal(records[0].path, `${ASSETS_DIR.replace(/\\/g, '/')}/123-456-shot.png`);
|
||||
});
|
||||
|
||||
test('resolveImageAssetFile resolves plain filenames inside the assets dir', () => {
|
||||
const resolved = resolveImageAssetFile('123-shot.png');
|
||||
assert.equal(resolved, path.join(path.resolve(ASSETS_DIR), '123-shot.png'));
|
||||
});
|
||||
|
||||
test('resolveImageAssetFile rejects traversal and separator attempts', () => {
|
||||
assert.equal(resolveImageAssetFile(''), null);
|
||||
assert.equal(resolveImageAssetFile(' '), null);
|
||||
assert.equal(resolveImageAssetFile('../auth.db'), null);
|
||||
assert.equal(resolveImageAssetFile('..'), null);
|
||||
assert.equal(resolveImageAssetFile('sub/dir.png'), null);
|
||||
assert.equal(resolveImageAssetFile('sub\\dir.png'), null);
|
||||
assert.equal(resolveImageAssetFile('a..b/../c.png'), null);
|
||||
});
|
||||
Reference in New Issue
Block a user