feat: 文件浏览器支持图片预览并更新发布包

This commit is contained in:
shiyue
2026-10-06 12:01:44 +08:00
parent 75989cb4de
commit caf8947cd9
16 changed files with 558 additions and 17 deletions

View File

@@ -151,6 +151,18 @@ const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
const MAX_INSTANCE_ICON_SIZE = 4 * 1024 * 1024;
const FILE_BROWSER_MAX_LIST_ENTRIES = 400;
const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024;
const FILE_BROWSER_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
// 图片预览白名单:仅这些扩展名允许通过 /api/fs/image 原样输出。
const IMAGE_PREVIEW_MIME_TYPES = new Map([
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.gif', 'image/gif'],
['.webp', 'image/webp'],
['.bmp', 'image/bmp'],
['.svg', 'image/svg+xml'],
['.avif', 'image/avif'],
]);
// MCP 工具可能来自多个 server;20 条会在空查询时静默截断大部分工具。
const COMPOSER_SUGGESTION_LIMIT = 200;
const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024;
@@ -2774,6 +2786,10 @@ function isPreviewTextExtension(filePath) {
return TEXT_PREVIEW_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
function getImagePreviewMimeType(filePath) {
return IMAGE_PREVIEW_MIME_TYPES.get(path.extname(filePath).toLowerCase()) || '';
}
function isProbablyTextBuffer(buffer) {
if (!buffer || buffer.length === 0) return true;
const sample = buffer.subarray(0, Math.min(buffer.length, 8192));
@@ -4283,6 +4299,7 @@ function handleFileSystemListApi(req, res, url) {
size: kind === 'file' ? finalStat.size : 0,
updatedAt: finalStat.mtime.toISOString(),
previewableHint: kind === 'file' && (isPreviewTextExtension(entry.name) || !path.extname(entry.name)),
imagePreviewableHint: kind === 'file' && !!getImagePreviewMimeType(resolvedEntryPath),
symlink,
});
} catch {}
@@ -4370,6 +4387,81 @@ function handleFileSystemReadApi(req, res, url) {
});
}
function handleFileSystemImageApi(req, res, url) {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
}
const sessionId = sanitizeId(url.searchParams.get('sessionId') || '');
if (!sessionId) {
return jsonResponse(res, 400, { ok: false, message: '缺少 sessionId' });
}
const browseContext = getSessionBrowseContext(sessionId);
if (!browseContext.ok) {
return jsonResponse(res, browseContext.statusCode, { ok: false, message: browseContext.message });
}
const target = resolveBrowseTarget(browseContext.rootDir, url.searchParams.get('path') || '');
if (!target.ok) {
return jsonResponse(res, target.statusCode, { ok: false, message: target.message });
}
let fd;
let stat;
try {
fd = fs.openSync(target.realPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
if (process.platform === 'linux' && !isPathInside(browseContext.rootDir, fs.readlinkSync(`/proc/self/fd/${fd}`))) {
fs.closeSync(fd);
return jsonResponse(res, 403, { ok: false, message: '目标路径超出允许范围' });
}
stat = fs.fstatSync(fd);
} catch (err) {
if (fd !== undefined) fs.closeSync(fd);
return jsonResponse(res, 500, { ok: false, message: `读取图片失败: ${err.message}` });
}
if (!stat.isFile()) {
fs.closeSync(fd);
return jsonResponse(res, 400, { ok: false, message: '目标路径不是文件' });
}
const mimeType = getImagePreviewMimeType(target.realPath);
if (!mimeType) {
fs.closeSync(fd);
return jsonResponse(res, 415, { ok: false, message: '当前文件格式不支持图片预览' });
}
if (stat.size > FILE_BROWSER_MAX_IMAGE_BYTES) {
fs.closeSync(fd);
return jsonResponse(res, 413, {
ok: false,
message: `图片过大,预览上限为 ${Math.round(FILE_BROWSER_MAX_IMAGE_BYTES / (1024 * 1024))} MiB`,
});
}
const headers = {
'Content-Type': mimeType,
'Content-Length': stat.size,
'Content-Disposition': 'attachment',
'Cache-Control': 'private, no-store, max-age=0',
'X-Content-Type-Options': 'nosniff',
};
if (stat.size === 0) {
fs.closeSync(fd);
res.writeHead(200, headers);
return res.end();
}
const stream = fs.createReadStream(target.realPath, { fd, autoClose: true, end: stat.size - 1 });
stream.once('error', (err) => {
if (!res.headersSent) jsonResponse(res, 500, { ok: false, message: `读取图片失败: ${err.message}` });
else res.destroy(err);
});
res.once('close', () => stream.destroy());
res.writeHead(200, headers);
stream.pipe(res);
}
function handleDirectoryPickerListApi(req, res, url) {
const token = extractBearerToken(req);
if (!token || !activeTokens.has(token)) {
@@ -9716,6 +9808,10 @@ const server = http.createServer((req, res) => {
return handleFileSystemReadApi(req, res, url);
}
if (req.method === 'GET' && url.pathname === '/api/fs/image') {
return handleFileSystemImageApi(req, res, url);
}
if (req.method === 'GET' && url.pathname === '/api/fs/directories') {
return handleDirectoryPickerListApi(req, res, url);
}