feat: 文件浏览器支持图片预览并更新发布包
This commit is contained in:
96
server.js
96
server.js
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user