@caoruhua/open-claude-remote 1.0.9 → 1.3.6

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.
Files changed (97) hide show
  1. package/README.md +48 -27
  2. package/dist/backend/src/api/file-routes.d.ts +0 -24
  3. package/dist/backend/src/api/file-routes.d.ts.map +1 -1
  4. package/dist/backend/src/api/file-routes.js +464 -68
  5. package/dist/backend/src/api/file-routes.js.map +1 -1
  6. package/dist/backend/src/api/instance-routes.d.ts +4 -0
  7. package/dist/backend/src/api/instance-routes.d.ts.map +1 -1
  8. package/dist/backend/src/api/instance-routes.js +27 -2
  9. package/dist/backend/src/api/instance-routes.js.map +1 -1
  10. package/dist/backend/src/cli-utils.d.ts +2 -0
  11. package/dist/backend/src/cli-utils.d.ts.map +1 -1
  12. package/dist/backend/src/cli-utils.js +11 -1
  13. package/dist/backend/src/cli-utils.js.map +1 -1
  14. package/dist/backend/src/cli.d.ts +6 -0
  15. package/dist/backend/src/cli.d.ts.map +1 -1
  16. package/dist/backend/src/cli.js +46 -7
  17. package/dist/backend/src/cli.js.map +1 -1
  18. package/dist/backend/src/config.d.ts +11 -0
  19. package/dist/backend/src/config.d.ts.map +1 -1
  20. package/dist/backend/src/config.js +36 -1
  21. package/dist/backend/src/config.js.map +1 -1
  22. package/dist/backend/src/daemon/daemon-client.d.ts +1 -0
  23. package/dist/backend/src/daemon/daemon-client.d.ts.map +1 -1
  24. package/dist/backend/src/daemon/daemon-client.js +1 -0
  25. package/dist/backend/src/daemon/daemon-client.js.map +1 -1
  26. package/dist/backend/src/daemon/daemon-launcher.d.ts.map +1 -1
  27. package/dist/backend/src/daemon/daemon-launcher.js +4 -1
  28. package/dist/backend/src/daemon/daemon-launcher.js.map +1 -1
  29. package/dist/backend/src/daemon/restart-state.d.ts +1 -0
  30. package/dist/backend/src/daemon/restart-state.d.ts.map +1 -1
  31. package/dist/backend/src/daemon/restart-state.js.map +1 -1
  32. package/dist/backend/src/deps/detector.js +1 -1
  33. package/dist/backend/src/deps/index.d.ts +1 -1
  34. package/dist/backend/src/deps/index.js +22 -22
  35. package/dist/backend/src/deps/installer.js +6 -6
  36. package/dist/backend/src/deps/installer.js.map +1 -1
  37. package/dist/backend/src/deps/types.d.ts +1 -1
  38. package/dist/backend/src/deps/types.d.ts.map +1 -1
  39. package/dist/backend/src/deps/types.js +9 -11
  40. package/dist/backend/src/deps/types.js.map +1 -1
  41. package/dist/backend/src/index.d.ts.map +1 -1
  42. package/dist/backend/src/index.js +29 -6
  43. package/dist/backend/src/index.js.map +1 -1
  44. package/dist/backend/src/instance/instance-manager.d.ts +4 -0
  45. package/dist/backend/src/instance/instance-manager.d.ts.map +1 -1
  46. package/dist/backend/src/instance/instance-manager.js +30 -7
  47. package/dist/backend/src/instance/instance-manager.js.map +1 -1
  48. package/dist/backend/src/instance/instance-session.d.ts +6 -1
  49. package/dist/backend/src/instance/instance-session.d.ts.map +1 -1
  50. package/dist/backend/src/instance/instance-session.js +9 -3
  51. package/dist/backend/src/instance/instance-session.js.map +1 -1
  52. package/dist/backend/src/instance/types.d.ts +1 -0
  53. package/dist/backend/src/instance/types.d.ts.map +1 -1
  54. package/dist/backend/src/pty/fix-pty-permissions.d.ts +1 -1
  55. package/dist/backend/src/pty/fix-pty-permissions.js +1 -1
  56. package/dist/backend/src/pty/output-buffer.d.ts +6 -0
  57. package/dist/backend/src/pty/output-buffer.d.ts.map +1 -1
  58. package/dist/backend/src/pty/output-buffer.js +10 -0
  59. package/dist/backend/src/pty/output-buffer.js.map +1 -1
  60. package/dist/backend/src/registry/stop-instances.js +1 -1
  61. package/dist/backend/src/services/pptx-converter.d.ts +66 -0
  62. package/dist/backend/src/services/pptx-converter.d.ts.map +1 -0
  63. package/dist/backend/src/services/pptx-converter.js +282 -0
  64. package/dist/backend/src/services/pptx-converter.js.map +1 -0
  65. package/dist/backend/src/update.d.ts +2 -2
  66. package/dist/backend/src/update.d.ts.map +1 -1
  67. package/dist/backend/src/update.js +9 -9
  68. package/dist/backend/src/update.js.map +1 -1
  69. package/dist/backend/src/utils/banner.d.ts +2 -0
  70. package/dist/backend/src/utils/banner.d.ts.map +1 -1
  71. package/dist/backend/src/utils/banner.js +2 -1
  72. package/dist/backend/src/utils/banner.js.map +1 -1
  73. package/dist/backend/src/utils/network.d.ts +22 -0
  74. package/dist/backend/src/utils/network.d.ts.map +1 -1
  75. package/dist/backend/src/utils/network.js +54 -0
  76. package/dist/backend/src/utils/network.js.map +1 -1
  77. package/dist/shared/file-types.d.ts +176 -0
  78. package/dist/shared/file-types.d.ts.map +1 -0
  79. package/dist/shared/file-types.js +108 -0
  80. package/dist/shared/file-types.js.map +1 -0
  81. package/dist/shared/index.d.ts +1 -0
  82. package/dist/shared/index.d.ts.map +1 -1
  83. package/dist/shared/index.js +1 -0
  84. package/dist/shared/index.js.map +1 -1
  85. package/dist/shared/instance.d.ts +2 -0
  86. package/dist/shared/instance.d.ts.map +1 -1
  87. package/dist/shared/instance.js.map +1 -1
  88. package/frontend-dist/assets/{index-UXAwH56Q.css → index-BY0fnkbW.css} +1 -1
  89. package/frontend-dist/assets/index-qwSai8-t.js +211 -0
  90. package/frontend-dist/index.html +2 -2
  91. package/package.json +8 -4
  92. package/scripts/build.sh +3 -9
  93. package/scripts/dev.sh +1 -4
  94. package/scripts/git-hooks/pre-commit +0 -4
  95. package/scripts/publish-npm.sh +61 -0
  96. package/scripts/stop.sh +1 -1
  97. package/frontend-dist/assets/index-CVRtrLyp.js +0 -199
@@ -1,30 +1,17 @@
1
1
  import { Router } from 'express';
2
2
  import { readFile, stat, readdir } from 'node:fs/promises';
3
3
  import { createReadStream } from 'node:fs';
4
- import { extname, join, relative, resolve, normalize } from 'node:path';
4
+ import { extname, join, relative, basename, resolve, normalize } from 'node:path';
5
5
  import { loadUserConfig } from '../config.js';
6
6
  import { logger } from '../logger/logger.js';
7
+ import { SUPPORTED_EXTENSIONS, IMAGE_EXTENSIONS, PREVIEWABLE_EXTENSIONS, CONVERTIBLE_TO_PDF, FILE_MIME_TYPES, getFileCategory, } from '../../../shared/index.js';
8
+ import { createConversionTask, getTaskStatus, startConversion, isSofficeAvailable, getLibreOfficeInstallGuide, TEMP_DIR } from '../services/pptx-converter.js';
7
9
  /** 文件大小限制:5MB(与 PRD 一致) */
8
10
  const MAX_FILE_SIZE = 5 * 1024 * 1024;
9
- /** 允许的文件扩展名 */
10
- const ALLOWED_EXTENSIONS = ['.md', '.markdown'];
11
- /** 允许的图片扩展名 */
12
- const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico', '.bmp'];
13
- /** 图片 MIME 类型映射 */
14
- const IMAGE_MIME_TYPES = {
15
- '.png': 'image/png',
16
- '.jpg': 'image/jpeg',
17
- '.jpeg': 'image/jpeg',
18
- '.gif': 'image/gif',
19
- '.webp': 'image/webp',
20
- '.svg': 'image/svg+xml',
21
- '.ico': 'image/x-icon',
22
- '.bmp': 'image/bmp',
23
- };
24
11
  /** 最大扫描文件数限制 */
25
12
  const MAX_FILES = 1000;
26
13
  /** 跳过的目录 */
27
- const SKIP_DIRS = ['node_modules', 'dist', 'build', '.git'];
14
+ const SKIP_DIRS = ['node_modules', 'dist', 'build', '.git', '.svn', '.hg'];
28
15
  /**
29
16
  * 检查路径是否在允许的目录白名单范围内
30
17
  * @param path - 要检查的路径(绝对路径)
@@ -33,6 +20,10 @@ const SKIP_DIRS = ['node_modules', 'dist', 'build', '.git'];
33
20
  */
34
21
  function isPathAllowed(path, allowedCwds) {
35
22
  const normalizedPath = normalize(path);
23
+ // 允许访问 PPTX 转换输出目录
24
+ if (normalizedPath.startsWith(TEMP_DIR + '/') || normalizedPath === TEMP_DIR) {
25
+ return true;
26
+ }
36
27
  return allowedCwds.some((cwd) => {
37
28
  const normalizedCwd = normalize(cwd);
38
29
  return normalizedPath.startsWith(normalizedCwd + '/') || normalizedPath === normalizedCwd;
@@ -87,24 +78,39 @@ function getAllowedCwds(instanceManager) {
87
78
  return [...new Set([...configWorkspaces, ...instanceCwds])];
88
79
  }
89
80
  /**
90
- * 递归扫描目录中的 Markdown 文件
91
- * @param dir - 要扫描的目录
92
- * @param baseDir - 基准目录(用于计算相对路径)
93
- * @param allowedCwds - 允许的目录白名单
94
- * @param files - 已收集的文件列表(递归用)
95
- * @returns Markdown 文件列表
81
+ * 构建目录树节点
96
82
  */
97
- async function scanMarkdownFiles(dir, baseDir, allowedCwds, files = []) {
83
+ function buildDirectoryNode(name, path, relativePath) {
84
+ return {
85
+ name,
86
+ path,
87
+ relativePath,
88
+ children: [],
89
+ fileCount: 0,
90
+ typeCounts: {
91
+ markdown: 0,
92
+ pdf: 0,
93
+ presentation: 0,
94
+ image: 0,
95
+ all: 0,
96
+ },
97
+ };
98
+ }
99
+ async function scanFiles(dir, baseDir, allowedCwds, result, category = 'all', searchQuery, currentNode) {
98
100
  // 安全检查:路径必须在白名单范围内
99
101
  if (!isPathAllowed(dir, allowedCwds)) {
100
102
  logger.warn({ dir, allowedCwds }, 'Scan rejected: directory not allowed');
101
- return files;
103
+ return;
104
+ }
105
+ // 初始化当前节点
106
+ if (!currentNode) {
107
+ currentNode = result.rootNode;
102
108
  }
103
109
  try {
104
110
  const entries = await readdir(dir, { withFileTypes: true });
105
111
  for (const entry of entries) {
106
112
  // 达到最大文件数限制时停止扫描
107
- if (files.length >= MAX_FILES) {
113
+ if (result.files.length >= MAX_FILES) {
108
114
  logger.warn({ dir, maxFiles: MAX_FILES }, 'Scan stopped: max file limit reached');
109
115
  break;
110
116
  }
@@ -114,27 +120,64 @@ async function scanMarkdownFiles(dir, baseDir, allowedCwds, files = []) {
114
120
  if (entry.name.startsWith('.') || SKIP_DIRS.includes(entry.name)) {
115
121
  continue;
116
122
  }
123
+ // 创建子目录节点
124
+ const relativePath = relative(baseDir, fullPath);
125
+ const childNode = buildDirectoryNode(entry.name, fullPath, relativePath.startsWith('.') ? relativePath : `./${relativePath}`);
126
+ currentNode.children.push(childNode);
117
127
  // 递归扫描子目录
118
- await scanMarkdownFiles(fullPath, baseDir, allowedCwds, files);
128
+ await scanFiles(fullPath, baseDir, allowedCwds, result, category, searchQuery, childNode);
129
+ // 汇总子目录统计
130
+ currentNode.fileCount += childNode.fileCount;
131
+ for (const cat of Object.keys(childNode.typeCounts)) {
132
+ currentNode.typeCounts[cat] += childNode.typeCounts[cat];
133
+ }
119
134
  }
120
135
  else if (entry.isFile()) {
121
136
  // 检查扩展名
122
137
  const ext = extname(entry.name).toLowerCase();
123
- if (!ALLOWED_EXTENSIONS.includes(ext)) {
138
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
124
139
  continue;
125
140
  }
141
+ // 获取文件分类
142
+ const fileCategory = getFileCategory(entry.name);
143
+ // ✅ 先统计 typeCounts(不受 category 影响)
144
+ currentNode.typeCounts[fileCategory]++;
145
+ currentNode.typeCounts.all++;
146
+ // 分类筛选(只影响 files 数组,不影响 typeCounts)
147
+ if (category !== 'all' && fileCategory !== category) {
148
+ continue; // 不加入 files,但 typeCounts 已统计
149
+ }
126
150
  try {
127
151
  const fileStat = await stat(fullPath);
128
152
  const relativePath = relative(baseDir, fullPath);
129
- files.push({
153
+ // 搜索筛选(搜索时需要回退 typeCounts,因为搜索结果不应显示被排除的文件)
154
+ if (searchQuery) {
155
+ const searchLower = searchQuery.toLowerCase();
156
+ if (!entry.name.toLowerCase().includes(searchLower) &&
157
+ !relativePath.toLowerCase().includes(searchLower)) {
158
+ // 搜索过滤时回退 typeCounts
159
+ currentNode.typeCounts[fileCategory]--;
160
+ currentNode.typeCounts.all--;
161
+ continue;
162
+ }
163
+ }
164
+ const fileItem = {
130
165
  filename: entry.name,
131
166
  absolutePath: fullPath,
132
167
  relativePath: relativePath.startsWith('.') ? relativePath : `./${relativePath}`,
133
168
  size: fileStat.size,
134
169
  modifiedAt: fileStat.mtime.toISOString(),
135
- });
170
+ category: fileCategory,
171
+ extension: ext,
172
+ };
173
+ result.files.push(fileItem);
174
+ // 更新当前目录统计(fileCount 统计过滤后的文件数)
175
+ currentNode.fileCount++;
136
176
  }
137
177
  catch (err) {
178
+ // stat 失败时回退 typeCounts
179
+ currentNode.typeCounts[fileCategory]--;
180
+ currentNode.typeCounts.all--;
138
181
  logger.debug({ path: fullPath, err: String(err) }, 'Failed to stat file during scan');
139
182
  }
140
183
  }
@@ -143,15 +186,16 @@ async function scanMarkdownFiles(dir, baseDir, allowedCwds, files = []) {
143
186
  catch (err) {
144
187
  logger.warn({ dir, err: String(err) }, 'Failed to read directory during scan');
145
188
  }
146
- return files;
147
189
  }
148
190
  export function createFileRoutes(authModule, instanceManager) {
149
191
  const router = Router();
150
192
  /**
151
- * GET /api/file/list - 列出当前工作目录下的 Markdown 文件
193
+ * GET /api/file/list - 列出当前工作目录下的文件
152
194
  * Query params:
153
195
  * - instanceId: 可选,指定实例 ID,优先使用该实例的 cwd
154
196
  * - cwd: 可选,指定扫描目录(默认使用第一个实例的 cwd)
197
+ * - category: 可选,文件分类筛选(markdown|pdf|presentation|image|all)
198
+ * - search: 可选,搜索关键词(匹配文件名和路径)
155
199
  */
156
200
  router.get('/file/list', authModule.requireAuth, async (req, res) => {
157
201
  try {
@@ -164,25 +208,40 @@ export function createFileRoutes(authModule, instanceManager) {
164
208
  files: [],
165
209
  cwd: '',
166
210
  total: 0,
211
+ directoryTree: undefined,
212
+ typeCounts: undefined,
167
213
  });
168
214
  return;
169
215
  }
170
- // 确定扫描目录:优先级 instanceId > cwd > 默认第一个允许目录
216
+ // 确定扫描目录:
217
+ // - 如果提供了 cwd,使用 cwd(需要验证是否在允许范围内)
218
+ // - 如果只提供了 instanceId,使用实例的 cwd
219
+ // - 否则使用默认第一个允许目录
171
220
  const instanceId = req.query.instanceId;
172
221
  const queryCwd = req.query.cwd;
173
222
  let baseDir;
223
+ // 首先获取实例的 cwd(用于验证 cwd 是否在实例范围内)
224
+ let instanceCwd;
174
225
  if (instanceId) {
175
- // 通过 instanceId 获取实例的 cwd
176
226
  const instance = instanceManager.getInstance(instanceId);
177
227
  if (!instance) {
178
228
  logger.warn({ instanceId }, 'File list rejected: instance not found');
179
229
  res.status(404).json({ error: 'Instance not found' });
180
230
  return;
181
231
  }
182
- baseDir = instance.cwd;
232
+ instanceCwd = instance.cwd;
183
233
  }
184
- else if (queryCwd) {
185
- baseDir = normalizePath(queryCwd, allowedCwds[0]) || allowedCwds[0];
234
+ if (queryCwd) {
235
+ // 使用 cwd 参数,但需要验证路径是否在允许范围内
236
+ const resolvedCwd = normalizePath(queryCwd, instanceCwd || allowedCwds[0]);
237
+ if (!resolvedCwd) {
238
+ res.status(400).json({ error: 'Invalid cwd path format' });
239
+ return;
240
+ }
241
+ baseDir = resolvedCwd;
242
+ }
243
+ else if (instanceCwd) {
244
+ baseDir = instanceCwd;
186
245
  }
187
246
  else {
188
247
  baseDir = allowedCwds[0];
@@ -195,22 +254,33 @@ export function createFileRoutes(authModule, instanceManager) {
195
254
  });
196
255
  return;
197
256
  }
198
- logger.info({ baseDir }, 'Scanning for Markdown files');
199
- // 递归扫描 Markdown 文件
200
- const files = await scanMarkdownFiles(baseDir, baseDir, allowedCwds);
257
+ // 解析查询参数
258
+ const category = req.query.category || 'all';
259
+ const searchQuery = req.query.search;
260
+ logger.info({ baseDir, category, search: searchQuery }, 'Scanning for files');
261
+ // 初始化扫描结果
262
+ const scanResult = {
263
+ files: [],
264
+ rootNode: buildDirectoryNode(basename(baseDir), baseDir, './'),
265
+ };
266
+ // 递归扫描文件
267
+ await scanFiles(baseDir, baseDir, allowedCwds, scanResult, category, searchQuery);
201
268
  // 按修改时间倒序排序
202
- files.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
269
+ scanResult.files.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime());
203
270
  // 限制返回数量
204
- const limitedFiles = files.slice(0, 100);
271
+ const limitedFiles = scanResult.files.slice(0, 100);
205
272
  logger.info({
206
- scanned: files.length,
273
+ scanned: scanResult.files.length,
207
274
  returned: limitedFiles.length,
208
275
  cwd: baseDir,
209
- }, 'Markdown file scan completed');
276
+ category,
277
+ }, 'File scan completed');
210
278
  const response = {
211
279
  files: limitedFiles,
212
280
  cwd: baseDir,
213
281
  total: limitedFiles.length,
282
+ directoryTree: scanResult.rootNode.children.length > 0 ? scanResult.rootNode : undefined,
283
+ typeCounts: scanResult.rootNode.typeCounts,
214
284
  };
215
285
  res.json(response);
216
286
  }
@@ -220,15 +290,17 @@ export function createFileRoutes(authModule, instanceManager) {
220
290
  }
221
291
  });
222
292
  /**
223
- * GET /api/file/read - 读取 Markdown 文件内容
293
+ * GET /api/file/content - 读取文件内容(统一接口,支持文本和二进制)
224
294
  * Query params:
225
295
  * - instanceId: 可选,指定实例 ID,用于解析相对路径
226
296
  * - path: 文件路径(绝对路径或相对于 cwd 的相对路径)
297
+ * - asBase64: 可选,是否以 base64 返回(默认为文本,图片/PDF自动转为base64)
227
298
  */
228
- router.get('/file/read', authModule.requireAuth, async (req, res) => {
299
+ router.get('/file/content', authModule.requireAuth, async (req, res) => {
229
300
  try {
230
301
  const rawPath = req.query.path;
231
302
  const instanceId = req.query.instanceId;
303
+ const asBase64 = req.query.asBase64 === 'true';
232
304
  if (!rawPath) {
233
305
  res.status(400).json({ error: 'Missing path parameter' });
234
306
  return;
@@ -237,7 +309,7 @@ export function createFileRoutes(authModule, instanceManager) {
237
309
  const allowedCwds = getAllowedCwds(instanceManager);
238
310
  // 如果白名单为空,拒绝所有请求
239
311
  if (allowedCwds.length === 0) {
240
- logger.warn({ path: rawPath }, 'File read rejected: no allowed directories');
312
+ logger.warn({ path: rawPath }, 'File content request rejected: no allowed directories');
241
313
  res.status(403).json({
242
314
  error: 'No allowed directories configured. Start an instance first.',
243
315
  });
@@ -248,7 +320,7 @@ export function createFileRoutes(authModule, instanceManager) {
248
320
  if (instanceId) {
249
321
  const instance = instanceManager.getInstance(instanceId);
250
322
  if (!instance) {
251
- logger.warn({ instanceId }, 'File read rejected: instance not found');
323
+ logger.warn({ instanceId }, 'File content request rejected: instance not found');
252
324
  res.status(404).json({ error: 'Instance not found' });
253
325
  return;
254
326
  }
@@ -264,17 +336,17 @@ export function createFileRoutes(authModule, instanceManager) {
264
336
  }
265
337
  // 安全检查:路径必须在白名单范围内
266
338
  if (!isPathAllowed(absolutePath, allowedCwds)) {
267
- logger.warn({ path: absolutePath, allowedCwds }, 'File read rejected: path not allowed');
339
+ logger.warn({ path: absolutePath, allowedCwds }, 'File content request rejected: path not allowed');
268
340
  res.status(403).json({
269
341
  error: 'Access denied. File is outside allowed directories.',
270
342
  });
271
343
  return;
272
344
  }
273
- // 扩展名检查
345
+ // 扩展名检查(只允许支持的文件类型)
274
346
  const ext = extname(absolutePath).toLowerCase();
275
- if (!ALLOWED_EXTENSIONS.includes(ext)) {
347
+ if (!SUPPORTED_EXTENSIONS.includes(ext) && !PREVIEWABLE_EXTENSIONS.includes(ext)) {
276
348
  res.status(400).json({
277
- error: 'Only Markdown files (.md, .markdown) are allowed',
349
+ error: `Unsupported file type. Supported: ${SUPPORTED_EXTENSIONS.join(', ')}`,
278
350
  });
279
351
  return;
280
352
  }
@@ -300,28 +372,133 @@ export function createFileRoutes(authModule, instanceManager) {
300
372
  });
301
373
  return;
302
374
  }
375
+ const filename = basename(absolutePath);
376
+ const mimeType = FILE_MIME_TYPES[ext] || 'application/octet-stream';
377
+ const isBinary = IMAGE_EXTENSIONS.includes(ext) || ext === '.pdf';
303
378
  // 读取文件内容
304
- const content = await readFile(absolutePath, 'utf-8');
305
- const filename = absolutePath.split('/').pop() || absolutePath;
306
- logger.info({ path: absolutePath, size: fileStat.size }, 'File read successfully');
379
+ let content;
380
+ let responseIsBase64 = false;
381
+ if (isBinary || asBase64) {
382
+ // 二进制文件或强制 base64:读取并转为 base64
383
+ const buffer = await readFile(absolutePath);
384
+ content = buffer.toString('base64');
385
+ responseIsBase64 = true;
386
+ }
387
+ else {
388
+ // 文本文件:直接读取
389
+ content = await readFile(absolutePath, 'utf-8');
390
+ }
391
+ logger.info({ path: absolutePath, size: fileStat.size, mimeType }, 'File content served');
307
392
  const response = {
308
393
  content,
309
394
  path: absolutePath,
310
395
  filename,
311
396
  size: fileStat.size,
397
+ isBase64: responseIsBase64,
398
+ mimeType,
312
399
  };
313
400
  res.json(response);
314
401
  }
402
+ catch (err) {
403
+ logger.error({ err }, 'Failed to read file content');
404
+ res.status(500).json({ error: 'Internal server error' });
405
+ }
406
+ });
407
+ /**
408
+ * GET /api/file/read - 读取 Markdown 文件内容(向后兼容)
409
+ * 保留原有接口行为,仅支持 Markdown 文件
410
+ */
411
+ router.get('/file/read', authModule.requireAuth, async (req, res) => {
412
+ try {
413
+ const rawPath = req.query.path;
414
+ const instanceId = req.query.instanceId;
415
+ if (!rawPath) {
416
+ res.status(400).json({ error: 'Missing path parameter' });
417
+ return;
418
+ }
419
+ // 获取允许的目录白名单
420
+ const allowedCwds = getAllowedCwds(instanceManager);
421
+ if (allowedCwds.length === 0) {
422
+ logger.warn({ path: rawPath }, 'File read rejected: no allowed directories');
423
+ res.status(403).json({
424
+ error: 'No allowed directories configured. Start an instance first.',
425
+ });
426
+ return;
427
+ }
428
+ // 确定基准目录
429
+ let baseDir;
430
+ if (instanceId) {
431
+ const instance = instanceManager.getInstance(instanceId);
432
+ if (!instance) {
433
+ logger.warn({ instanceId }, 'File read rejected: instance not found');
434
+ res.status(404).json({ error: 'Instance not found' });
435
+ return;
436
+ }
437
+ baseDir = instance.cwd;
438
+ }
439
+ else {
440
+ baseDir = allowedCwds[0];
441
+ }
442
+ const absolutePath = normalizePath(rawPath, baseDir);
443
+ if (!absolutePath) {
444
+ res.status(400).json({ error: 'Invalid path format' });
445
+ return;
446
+ }
447
+ // 安全检查
448
+ if (!isPathAllowed(absolutePath, allowedCwds)) {
449
+ logger.warn({ path: absolutePath, allowedCwds }, 'File read rejected: path not allowed');
450
+ res.status(403).json({
451
+ error: 'Access denied. File is outside allowed directories.',
452
+ });
453
+ return;
454
+ }
455
+ // 仅允许 Markdown 文件(向后兼容)
456
+ const ext = extname(absolutePath).toLowerCase();
457
+ if (!['.md', '.markdown'].includes(ext)) {
458
+ res.status(400).json({
459
+ error: 'Only Markdown files (.md, .markdown) are allowed',
460
+ });
461
+ return;
462
+ }
463
+ // 检查文件
464
+ let fileStat;
465
+ try {
466
+ fileStat = await stat(absolutePath);
467
+ }
468
+ catch {
469
+ logger.debug({ path: absolutePath }, 'File not found');
470
+ res.status(404).json({ error: 'File not found' });
471
+ return;
472
+ }
473
+ if (!fileStat.isFile()) {
474
+ res.status(400).json({ error: 'Path is not a file' });
475
+ return;
476
+ }
477
+ if (fileStat.size > MAX_FILE_SIZE) {
478
+ res.status(413).json({
479
+ error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB`,
480
+ });
481
+ return;
482
+ }
483
+ // 读取文件内容
484
+ const content = await readFile(absolutePath, 'utf-8');
485
+ const filename = basename(absolutePath);
486
+ logger.info({ path: absolutePath, size: fileStat.size }, 'Markdown file read successfully');
487
+ res.json({
488
+ content,
489
+ path: absolutePath,
490
+ filename,
491
+ size: fileStat.size,
492
+ });
493
+ }
315
494
  catch (err) {
316
495
  logger.error({ err }, 'Failed to read file');
317
496
  res.status(500).json({ error: 'Internal server error' });
318
497
  }
319
498
  });
320
499
  /**
321
- * GET /api/file/asset - 获取图片等静态资源文件
322
- * Query params:
323
- * - instanceId: 可选,指定实例 ID,用于解析相对路径
324
- * - path: 文件路径(绝对路径或相对于 cwd 的相对路径)
500
+ * GET /api/file/asset - 获取图片等静态资源文件(向后兼容)
501
+ * 支持流式传输,保留原有行为
325
502
  */
326
503
  router.get('/file/asset', authModule.requireAuth, async (req, res) => {
327
504
  try {
@@ -333,7 +510,6 @@ export function createFileRoutes(authModule, instanceManager) {
333
510
  }
334
511
  // 获取允许的目录白名单
335
512
  const allowedCwds = getAllowedCwds(instanceManager);
336
- // 如果白名单为空,拒绝所有请求
337
513
  if (allowedCwds.length === 0) {
338
514
  logger.warn({ path: rawPath }, 'Asset request rejected: no allowed directories');
339
515
  res.status(403).json({
@@ -341,7 +517,7 @@ export function createFileRoutes(authModule, instanceManager) {
341
517
  });
342
518
  return;
343
519
  }
344
- // 确定基准目录:优先级 instanceId > 默认第一个允许目录
520
+ // 确定基准目录
345
521
  let baseDir;
346
522
  if (instanceId) {
347
523
  const instance = instanceManager.getInstance(instanceId);
@@ -360,7 +536,7 @@ export function createFileRoutes(authModule, instanceManager) {
360
536
  res.status(400).json({ error: 'Invalid path format' });
361
537
  return;
362
538
  }
363
- // 安全检查:路径必须在白名单范围内
539
+ // 安全检查
364
540
  if (!isPathAllowed(absolutePath, allowedCwds)) {
365
541
  logger.warn({ path: absolutePath, allowedCwds }, 'Asset request rejected: path not allowed');
366
542
  res.status(403).json({
@@ -370,13 +546,13 @@ export function createFileRoutes(authModule, instanceManager) {
370
546
  }
371
547
  // 扩展名检查(仅允许图片格式)
372
548
  const ext = extname(absolutePath).toLowerCase();
373
- if (!ALLOWED_IMAGE_EXTENSIONS.includes(ext)) {
549
+ if (!IMAGE_EXTENSIONS.includes(ext)) {
374
550
  res.status(400).json({
375
- error: `Only image files (${ALLOWED_IMAGE_EXTENSIONS.join(', ')}) are allowed`,
551
+ error: `Only image files (${IMAGE_EXTENSIONS.join(', ')}) are allowed`,
376
552
  });
377
553
  return;
378
554
  }
379
- // 检查文件是否存在并获取大小
555
+ // 检查文件
380
556
  let fileStat;
381
557
  try {
382
558
  fileStat = await stat(absolutePath);
@@ -386,12 +562,10 @@ export function createFileRoutes(authModule, instanceManager) {
386
562
  res.status(404).json({ error: 'File not found' });
387
563
  return;
388
564
  }
389
- // 检查是否为文件
390
565
  if (!fileStat.isFile()) {
391
566
  res.status(400).json({ error: 'Path is not a file' });
392
567
  return;
393
568
  }
394
- // 文件大小检查
395
569
  if (fileStat.size > MAX_FILE_SIZE) {
396
570
  res.status(413).json({
397
571
  error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB`,
@@ -399,7 +573,7 @@ export function createFileRoutes(authModule, instanceManager) {
399
573
  return;
400
574
  }
401
575
  // 设置 Content-Type
402
- const contentType = IMAGE_MIME_TYPES[ext] || 'application/octet-stream';
576
+ const contentType = FILE_MIME_TYPES[ext] || 'application/octet-stream';
403
577
  res.setHeader('Content-Type', contentType);
404
578
  res.setHeader('Content-Length', fileStat.size);
405
579
  res.setHeader('Cache-Control', 'public, max-age=31536000');
@@ -419,6 +593,228 @@ export function createFileRoutes(authModule, instanceManager) {
419
593
  res.status(500).json({ error: 'Internal server error' });
420
594
  }
421
595
  });
596
+ /**
597
+ * GET /api/file/download - 下载文件(支持所有受支持的文件类型)
598
+ * 设置 Content-Disposition: attachment 触发浏览器原生下载
599
+ */
600
+ router.get('/file/download', authModule.requireAuth, async (req, res) => {
601
+ try {
602
+ const rawPath = req.query.path;
603
+ const instanceId = req.query.instanceId;
604
+ if (!rawPath) {
605
+ res.status(400).json({ error: 'Missing path parameter' });
606
+ return;
607
+ }
608
+ const allowedCwds = getAllowedCwds(instanceManager);
609
+ if (allowedCwds.length === 0) {
610
+ logger.warn({ path: rawPath }, 'Download request rejected: no allowed directories');
611
+ res.status(403).json({
612
+ error: 'No allowed directories configured. Start an instance first.',
613
+ });
614
+ return;
615
+ }
616
+ let baseDir;
617
+ if (instanceId) {
618
+ const instance = instanceManager.getInstance(instanceId);
619
+ if (!instance) {
620
+ logger.warn({ instanceId }, 'Download request rejected: instance not found');
621
+ res.status(404).json({ error: 'Instance not found' });
622
+ return;
623
+ }
624
+ baseDir = instance.cwd;
625
+ }
626
+ else {
627
+ baseDir = allowedCwds[0];
628
+ }
629
+ const absolutePath = normalizePath(rawPath, baseDir);
630
+ if (!absolutePath) {
631
+ res.status(400).json({ error: 'Invalid path format' });
632
+ return;
633
+ }
634
+ if (!isPathAllowed(absolutePath, allowedCwds)) {
635
+ logger.warn({ path: absolutePath, allowedCwds }, 'Download request rejected: path not allowed');
636
+ res.status(403).json({
637
+ error: 'Access denied. File is outside allowed directories.',
638
+ });
639
+ return;
640
+ }
641
+ // 扩展名检查(允许所有受支持的文件类型)
642
+ const ext = extname(absolutePath).toLowerCase();
643
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
644
+ res.status(400).json({
645
+ error: `Unsupported file type. Supported: ${SUPPORTED_EXTENSIONS.join(', ')}`,
646
+ });
647
+ return;
648
+ }
649
+ let fileStat;
650
+ try {
651
+ fileStat = await stat(absolutePath);
652
+ }
653
+ catch {
654
+ logger.debug({ path: absolutePath }, 'Download file not found');
655
+ res.status(404).json({ error: 'File not found' });
656
+ return;
657
+ }
658
+ if (!fileStat.isFile()) {
659
+ res.status(400).json({ error: 'Path is not a file' });
660
+ return;
661
+ }
662
+ if (fileStat.size > MAX_FILE_SIZE) {
663
+ res.status(413).json({
664
+ error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB`,
665
+ });
666
+ return;
667
+ }
668
+ const filename = basename(absolutePath);
669
+ const contentType = FILE_MIME_TYPES[ext] || 'application/octet-stream';
670
+ const isTextFile = contentType.startsWith('text/');
671
+ // 文本类型显式声明 charset=utf-8,避免浏览器按系统默认编码解析导致中文乱码
672
+ res.setHeader('Content-Type', isTextFile ? `${contentType}; charset=utf-8` : contentType);
673
+ // 文本文件添加 UTF-8 BOM (3 bytes),帮助移动端应用正确检测编码
674
+ const UTF8_BOM = Buffer.from([0xEF, 0xBB, 0xBF]);
675
+ res.setHeader('Content-Length', isTextFile ? fileStat.size + UTF8_BOM.length : fileStat.size);
676
+ // RFC 5987 编码文件名,支持中文等非 ASCII 字符
677
+ const encodedFilename = encodeURIComponent(filename).replace(/'/g, '%27');
678
+ // ASCII fallback for filename, UTF-8 encoded for filename*
679
+ const asciiFilename = filename.replace(/[^\x20-\x7E]/g, '_');
680
+ res.setHeader('Content-Disposition', `attachment; filename="${asciiFilename}"; filename*=UTF-8''${encodedFilename}`);
681
+ // 文本文件先写入 BOM,再 pipe 文件流
682
+ if (isTextFile) {
683
+ res.write(UTF8_BOM);
684
+ }
685
+ const fileStream = createReadStream(absolutePath);
686
+ fileStream.pipe(res);
687
+ fileStream.on('error', (err) => {
688
+ logger.error({ path: absolutePath, err: String(err) }, 'Failed to stream download file');
689
+ if (!res.headersSent) {
690
+ res.status(500).json({ error: 'Failed to read file' });
691
+ }
692
+ });
693
+ logger.info({ path: absolutePath, size: fileStat.size, filename }, 'File download served');
694
+ }
695
+ catch (err) {
696
+ logger.error({ err }, 'Failed to serve download');
697
+ res.status(500).json({ error: 'Internal server error' });
698
+ }
699
+ });
700
+ /**
701
+ * POST /api/file/convert - 创建 PPTX 转换任务
702
+ * Body: { path: string, instanceId?: string }
703
+ */
704
+ router.post('/file/convert', authModule.requireAuth, async (req, res) => {
705
+ try {
706
+ const rawPath = req.body.path;
707
+ const instanceId = req.body.instanceId;
708
+ if (!rawPath) {
709
+ res.status(400).json({ error: 'Missing path parameter' });
710
+ return;
711
+ }
712
+ // 检查文件扩展名
713
+ const ext = extname(rawPath).toLowerCase();
714
+ if (!CONVERTIBLE_TO_PDF.includes(ext)) {
715
+ res.status(400).json({
716
+ error: 'Only PPTX files can be converted',
717
+ supported: CONVERTIBLE_TO_PDF,
718
+ });
719
+ return;
720
+ }
721
+ // 获取允许的目录白名单
722
+ const allowedCwds = getAllowedCwds(instanceManager);
723
+ if (allowedCwds.length === 0) {
724
+ res.status(403).json({ error: 'No allowed directories configured' });
725
+ return;
726
+ }
727
+ // 确定基准目录
728
+ let baseDir;
729
+ if (instanceId) {
730
+ const instance = instanceManager.getInstance(instanceId);
731
+ if (!instance) {
732
+ res.status(404).json({ error: 'Instance not found' });
733
+ return;
734
+ }
735
+ baseDir = instance.cwd;
736
+ }
737
+ else {
738
+ baseDir = allowedCwds[0];
739
+ }
740
+ const absolutePath = normalizePath(rawPath, baseDir);
741
+ if (!absolutePath) {
742
+ res.status(400).json({ error: 'Invalid path format' });
743
+ return;
744
+ }
745
+ // 安全检查
746
+ if (!isPathAllowed(absolutePath, allowedCwds)) {
747
+ res.status(403).json({ error: 'Access denied' });
748
+ return;
749
+ }
750
+ // 检查文件是否存在
751
+ try {
752
+ await stat(absolutePath);
753
+ }
754
+ catch {
755
+ res.status(404).json({ error: 'File not found' });
756
+ return;
757
+ }
758
+ // 创建转换任务
759
+ const task = await createConversionTask(absolutePath);
760
+ // 异步启动转换
761
+ startConversion(task.id);
762
+ logger.info({ taskId: task.id, path: absolutePath }, 'Created conversion task');
763
+ res.json({
764
+ taskId: task.id,
765
+ status: task.status,
766
+ });
767
+ }
768
+ catch (err) {
769
+ logger.error({ err }, 'Failed to create conversion task');
770
+ res.status(500).json({ error: 'Internal server error' });
771
+ }
772
+ });
773
+ /**
774
+ * GET /api/file/convert/status - 查询转换任务状态
775
+ * Query: { taskId: string }
776
+ */
777
+ router.get('/file/convert/status', authModule.requireAuth, async (req, res) => {
778
+ try {
779
+ const taskId = req.query.taskId;
780
+ if (!taskId) {
781
+ res.status(400).json({ error: 'Missing taskId parameter' });
782
+ return;
783
+ }
784
+ const task = getTaskStatus(taskId);
785
+ if (!task) {
786
+ res.status(404).json({ error: 'Task not found' });
787
+ return;
788
+ }
789
+ const response = {
790
+ taskId: task.id,
791
+ status: task.status,
792
+ pdfPath: task.status === 'completed' ? task.outputPath : undefined,
793
+ error: task.error,
794
+ };
795
+ res.json(response);
796
+ }
797
+ catch (err) {
798
+ logger.error({ err }, 'Failed to get conversion status');
799
+ res.status(500).json({ error: 'Internal server error' });
800
+ }
801
+ });
802
+ /**
803
+ * GET /api/file/convert/check - 检查 LibreOffice 是否可用
804
+ */
805
+ router.get('/file/convert/check', authModule.requireAuth, async (req, res) => {
806
+ try {
807
+ const isAvailable = await isSofficeAvailable();
808
+ res.json({
809
+ available: isAvailable,
810
+ installGuide: !isAvailable ? getLibreOfficeInstallGuide() : undefined,
811
+ });
812
+ }
813
+ catch (err) {
814
+ logger.error({ err }, 'Failed to check LibreOffice availability');
815
+ res.status(500).json({ error: 'Internal server error' });
816
+ }
817
+ });
422
818
  return router;
423
819
  }
424
820
  //# sourceMappingURL=file-routes.js.map