feat: support attached images for all providers

This commit is contained in:
Haileyesus
2026-07-03 15:42:29 +03:00
parent 3ade1a1105
commit a253a2bda4
33 changed files with 1467 additions and 321 deletions

View 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;

View 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';

View 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;
}

View 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);
});