zipcalm 0.0.0-stage → 1.0.2

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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/bin/zipcalm.js +191 -0
  4. package/package.json +67 -4
  5. package/server/app.js +309 -0
  6. package/server/boot.js +145 -0
  7. package/server/config/defaults.js +153 -0
  8. package/server/config/index.js +140 -0
  9. package/server/config/schema.js +244 -0
  10. package/server/core/auth/password.js +89 -0
  11. package/server/core/auth/store.js +261 -0
  12. package/server/core/compress/blacklist.js +236 -0
  13. package/server/core/compress/capRegistry.js +171 -0
  14. package/server/core/compress/deleter.js +285 -0
  15. package/server/core/compress/hardlinkScan.js +67 -0
  16. package/server/core/compress/orchestrator.js +783 -0
  17. package/server/core/compress/preflight.js +376 -0
  18. package/server/core/compress/registry.js +240 -0
  19. package/server/core/compress/sourceWalk.js +84 -0
  20. package/server/core/compress/verify.js +47 -0
  21. package/server/core/engineRunner.js +369 -0
  22. package/server/core/fs/list.js +164 -0
  23. package/server/core/fs/platformProfile.js +167 -0
  24. package/server/core/fs/repairNames.js +150 -0
  25. package/server/core/fs/roots.js +178 -0
  26. package/server/core/fs/safePath.js +173 -0
  27. package/server/core/fs/search.js +108 -0
  28. package/server/core/fs/stat.js +110 -0
  29. package/server/core/fs/systemTrash.js +212 -0
  30. package/server/core/fs/writeOps.js +257 -0
  31. package/server/core/hardlink/executor.js +106 -0
  32. package/server/core/hardlink/scanner.js +193 -0
  33. package/server/core/metrics/collector.js +279 -0
  34. package/server/core/metrics/procfs.js +107 -0
  35. package/server/core/task/events.js +82 -0
  36. package/server/core/task/manager.js +987 -0
  37. package/server/core/task/recovery.js +116 -0
  38. package/server/core/task/store.js +240 -0
  39. package/server/http/middleware/auth.js +130 -0
  40. package/server/http/middleware/error.js +54 -0
  41. package/server/http/middleware/logger.js +27 -0
  42. package/server/http/middleware/pathGuard.js +119 -0
  43. package/server/http/router.js +29 -0
  44. package/server/http/routes/auth.js +115 -0
  45. package/server/http/routes/blacklist.js +97 -0
  46. package/server/http/routes/capabilities.js +21 -0
  47. package/server/http/routes/compress.js +339 -0
  48. package/server/http/routes/fs.js +109 -0
  49. package/server/http/routes/hardlink.js +160 -0
  50. package/server/http/routes/health.js +52 -0
  51. package/server/http/routes/metrics.js +36 -0
  52. package/server/http/routes/tasks.js +304 -0
  53. package/server/lib/atomic-json.js +118 -0
  54. package/server/lib/errors.js +145 -0
  55. package/server/lib/fs-probe.js +25 -0
  56. package/server/lib/logger.js +68 -0
  57. package/server/lib/runtime-guard.js +49 -0
  58. package/server/lib/throttle.js +42 -0
  59. package/server/plugins/capabilities/password/index.js +300 -0
  60. package/server/plugins/capabilities/password/methods/aes256.js +31 -0
  61. package/server/plugins/capabilities/password/methods/zip20.js +29 -0
  62. package/server/plugins/formats/_shared/createGuards.js +26 -0
  63. package/server/plugins/formats/_shared/entrySafety.js +174 -0
  64. package/server/plugins/formats/_shared/nameRepair.js +123 -0
  65. package/server/plugins/formats/_shared/streams.js +18 -0
  66. package/server/plugins/formats/_shared/tar/compression.js +114 -0
  67. package/server/plugins/formats/_shared/tar/reader.js +74 -0
  68. package/server/plugins/formats/_shared/tar/writer.js +206 -0
  69. package/server/plugins/formats/_shared/tarFamily.js +207 -0
  70. package/server/plugins/formats/rar/index.js +354 -0
  71. package/server/plugins/formats/rar/js-engine.js +122 -0
  72. package/server/plugins/formats/rar/js-runner.mjs +129 -0
  73. package/server/plugins/formats/rar/unrar.js +266 -0
  74. package/server/plugins/formats/sevenzip/engine.js +355 -0
  75. package/server/plugins/formats/sevenzip/index.js +461 -0
  76. package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
  77. package/server/plugins/formats/tar/index.js +19 -0
  78. package/server/plugins/formats/targz/index.js +19 -0
  79. package/server/plugins/formats/tarzst/index.js +26 -0
  80. package/server/plugins/formats/zip/index.js +587 -0
  81. package/shared/app-version.js +7 -0
  82. package/shared/capabilities.js +21 -0
  83. package/shared/compress-modes.js +38 -0
  84. package/shared/error-codes.js +117 -0
  85. package/shared/format-list.js +101 -0
  86. package/shared/hardlink-hint.js +21 -0
  87. package/shared/task-phase.js +62 -0
  88. package/shared/task-status.js +84 -0
  89. package/web/dist/assets/index-D3zjq7N2.js +131 -0
  90. package/web/dist/assets/index-D9Vdmrb9.css +1 -0
  91. package/web/dist/index.html +18 -0
@@ -0,0 +1,339 @@
1
+ // 压缩 / 解压 / 任务 路由(M2)
2
+ //
3
+ // 契约:docs/M0/API规范.md §3.3 端点总表 + §4.3 压缩与任务
4
+ // - 压缩与解压**共用**任务查询/取消/确认删除(`/tasks/{id}/...`),`/compress/tasks/{id}/...` 只是别名;
5
+ // - 所有入参路径都过 `app.fsGuard`(INV-06),业务层再用 core 复算一遍(defense in depth);
6
+ // - 创建任务前先跑预检:预检不通过**不建任务**(避免"排队后秒失败"的噪音任务)。
7
+ //
8
+ // 结构:**每个端点一个 handler 函数**,参数解析单独抽出(parseCompressRequest / parseExtractRequest),
9
+ // 插件函数 compressRoutes 只做"路由表注册"这一件事。
10
+
11
+ import fsp from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
15
+ import { resolveWithinRoot } from '../../core/fs/safePath.js';
16
+ import { compressPreflight, extractPreflight } from '../../core/compress/preflight.js';
17
+ import { defaultExtractDir } from '../../core/compress/orchestrator.js';
18
+
19
+ const COMPRESS_MODES = ['single', 'bundle'];
20
+
21
+ // ===== 端点 handler =====
22
+
23
+ /** GET /formats —— 格式列表 + 可用性 + 能力声明(UI 置灰依据) */
24
+ async function handleListFormats(app, request, reply) {
25
+ const formats = await app.m2.registry.list();
26
+ // 能力声明:前端据此决定「密码开关能否点」。取值口径是
27
+ // 「格式声明的 capabilities ∩ 能力插件的 appliesTo ∩ 能力已成功加载」,
28
+ // 由 capRegistry 统一算,避免前端自己拼条件。
29
+ // 注意 PW-4:这里只影响 UI,后端仍在建任务时独立校验(不信前端)。
30
+ const capRegistry = app.m2.capRegistry;
31
+ if (capRegistry) {
32
+ for (const format of formats) {
33
+ // 不能直接把能力模块塞进响应:它内部的 methods 是 Map(JSON 会变成 {}),
34
+ // 还带着 verify/prepareCreate 等函数。统一走 describe() 取可序列化视图,
35
+ // 再补上 params(前端要靠它渲染密码输入框的 schema)。
36
+ format.capabilities = capRegistry.capabilitiesForFormat(format.id).map((cap) => ({
37
+ ...(typeof cap.describe === 'function' ? cap.describe() : { id: cap.id, name: cap.name }),
38
+ params: cap.params,
39
+ }));
40
+ }
41
+ }
42
+ reply.ok({ formats });
43
+ }
44
+
45
+ /** POST /compress/preflight —— 压缩预检(不建任务) */
46
+ async function handleCompressPreflight(app, request, reply) {
47
+ const parsed = await parseCompressRequest(app, request.body || {});
48
+ const preflight = await compressPreflightFor(app, parsed);
49
+ reply.ok(preflight);
50
+ }
51
+
52
+ /** POST /compress/tasks —— 创建压缩任务(预检不通过则不建) */
53
+ async function handleCompressTaskCreate(app, request, reply) {
54
+ const { manager, orchestrator, config } = app.m2;
55
+ const parsed = await parseCompressRequest(app, request.body || {}, { enforceSourceLimit: true });
56
+
57
+ const preflight = await compressPreflightFor(app, parsed);
58
+ if (!preflight.canProceed) {
59
+ throw apiError(preflight.blockedReason.code, preflight.blockedReason.message);
60
+ }
61
+ if (!(await app.m2.registry.isAvailable(parsed.format))) {
62
+ throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, `格式 ${parsed.format} 当前不可用`);
63
+ }
64
+
65
+ // 预检已把目录展开成 size/fileCount,直接用于进度分母。
66
+ // willSkip 的源(整源都被黑名单跳过)不建包 —— 契约:single 模式"跳过文件不生成空压缩包"。
67
+ const enriched = parsed.sources
68
+ .map((source, index) => ({
69
+ ...source,
70
+ size: preflight.items[index] ? preflight.items[index].size : 0,
71
+ fileCount: preflight.items[index] ? preflight.items[index].keptFiles : 0,
72
+ willSkip: Boolean(preflight.items[index] && preflight.items[index].willSkip),
73
+ }))
74
+ .filter((source) => !source.willSkip);
75
+ if (enriched.length === 0) {
76
+ throw apiError(ERROR_CODES.ALL_SOURCES_SKIPPED, '没有可压缩的文件:全部命中黑名单');
77
+ }
78
+
79
+ const task = await manager.create({
80
+ kind: 'compress',
81
+ mode: parsed.mode,
82
+ format: parsed.format,
83
+ sources: enriched,
84
+ options: {
85
+ level: Number.isInteger(parsed.options.level) ? parsed.options.level : config.compress.defaultLevel,
86
+ keepOriginal: parsed.options.keepOriginal !== false,
87
+ deleteConfirmed: parsed.options.deleteConfirmed === true,
88
+ bundle: parsed.mode === 'bundle',
89
+ // 黑名单意图随任务落盘:打包阶段要按同一份意图重新解析(用户可能在创建后改过用户层)
90
+ blacklist: {
91
+ enabled: !parsed.options.blacklist || parsed.options.blacklist.enabled !== false,
92
+ extraPatterns: (parsed.options.blacklist && Array.isArray(parsed.options.blacklist.extraPatterns))
93
+ ? parsed.options.blacklist.extraPatterns
94
+ : [],
95
+ },
96
+ // 能力参数(docs/19 §六):原样交给编排层,由 capRegistry 校验并装配成 create 选项。
97
+ // 明文只活到压缩完成;落盘前 store.js 按各能力插件的 redact() 剔除(PW-1)。
98
+ // 注意:这里必须透传,否则装配循环拿不到参数 → 加密静默不生效。
99
+ ...(parsed.options.capabilities && typeof parsed.options.capabilities === 'object'
100
+ ? { capabilities: parsed.options.capabilities }
101
+ : {}),
102
+ },
103
+ // 绝对路径只走 internal(不会出现在 API 响应里)
104
+ // rootAbs 必须随任务落盘:执行/删除阶段的 INV-06 复核要用**任务自己的根**,不能用服务默认根
105
+ internal: { outputDirAbs: parsed.outputDirAbs, rootAbs: parsed.rootAbs },
106
+ });
107
+
108
+ manager.enqueue(task.id, (record) => orchestrator.runCompress(record));
109
+ reply.ok({ task: manager.get(task.id) }, 201);
110
+ }
111
+
112
+ /** POST /extract/preflight —— 解压预检(归档可读可解析 / 目标可写 / 空间) */
113
+ async function handleExtractPreflight(app, request, reply) {
114
+ const parsed = await parseExtractRequest(app, request.body || {});
115
+ const preflight = await extractPreflightFor(app, parsed);
116
+ // 把"这个归档被识别成哪个格式"显式告诉前端:复合扩展名(x.tar.gz)的识别完全在服务端做,
117
+ // 对话框据此展示,用户可一眼确认没被更短的 .gz 抢走
118
+ reply.ok({ ...preflight, format: parsed.plugin.id, formatName: parsed.plugin.name });
119
+ }
120
+
121
+ /** POST /extract/tasks —— 创建解压任务 */
122
+ async function handleExtractTaskCreate(app, request, reply) {
123
+ const { manager, orchestrator } = app.m2;
124
+ const parsed = await parseExtractRequest(app, request.body || {});
125
+
126
+ const preflight = await extractPreflightFor(app, parsed);
127
+ if (!preflight.canProceed) {
128
+ throw apiError(preflight.blockedReason.code, preflight.blockedReason.message);
129
+ }
130
+ if (!(await app.m2.registry.isAvailable(parsed.plugin.id))) {
131
+ throw apiError(ERROR_CODES.FORMAT_UNAVAILABLE, `格式 ${parsed.plugin.id} 当前不可用`);
132
+ }
133
+
134
+ const task = await manager.create({
135
+ kind: 'extract',
136
+ mode: 'bundle',
137
+ format: parsed.plugin.id,
138
+ sources: [],
139
+ archivePath: parsed.archive.userPath,
140
+ targetDir: parsed.target.userPath,
141
+ // 绝对路径只走 internal(不泄漏到 API 响应)
142
+ internal: {
143
+ archiveAbsPath: parsed.archive.realPath,
144
+ targetAbsDir: parsed.target.absPath,
145
+ rootAbs: parsed.rootAbs,
146
+ },
147
+ options: {
148
+ deleteArchiveAfterExtract: parsed.options.deleteArchiveAfterExtract === true,
149
+ deleteConfirmed: parsed.options.deleteConfirmed === true,
150
+ // P4:加密包解压密码(normalizeOptions 会透传;PW-1 落盘脱敏走 manager 通道)
151
+ ...(typeof parsed.options.password === 'string' && parsed.options.password.length > 0
152
+ ? { password: parsed.options.password }
153
+ : {}),
154
+ },
155
+ });
156
+
157
+ manager.enqueue(task.id, (record) => orchestrator.runExtract(record));
158
+ reply.ok({ task: manager.get(task.id) }, 201);
159
+ }
160
+
161
+ // ===== 路由注册(只做接线;任务类端点统一在 routes/tasks.js)=====
162
+
163
+ export default async function compressRoutes(app) {
164
+ const auth = { preHandler: [app.requireAuth] };
165
+ const bind = (handler) => (request, reply) => handler(app, request, reply);
166
+
167
+ app.get('/formats', auth, bind(handleListFormats));
168
+
169
+ app.post('/compress/preflight', auth, bind(handleCompressPreflight));
170
+ app.post('/compress/tasks', auth, bind(handleCompressTaskCreate));
171
+
172
+ app.post('/extract/preflight', auth, bind(handleExtractPreflight));
173
+ app.post('/extract/tasks', auth, bind(handleExtractTaskCreate));
174
+ }
175
+
176
+ // ===== 参数解析与内部工具(保持路由层"薄":解析入参 + 调 core)=====
177
+
178
+ /** 校验并解析压缩请求(sources / format / mode / outputDir) */
179
+ async function parseCompressRequest(app, body, options = {}) {
180
+ const { config } = app.m2;
181
+ // 根先定下来:源解析/输出目录/预检都以它为准(未知 rootId → VALIDATION_FAILED)
182
+ const root = app.fsGuard.root(body.rootId);
183
+ const rootAbs = root.absPath;
184
+ const sources = await resolveSources(app, body.sources, rootAbs);
185
+ const format = String(body.format || config.compress.defaultFormat);
186
+ const mode = String(body.mode || config.compress.defaultMode);
187
+
188
+ if (!COMPRESS_MODES.includes(mode)) {
189
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'mode 只能是 single 或 bundle', { mode });
190
+ }
191
+ if (options.enforceSourceLimit && sources.length > config.limits.maxSourcesPerTask) {
192
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, `一次最多 ${config.limits.maxSourcesPerTask} 个源`, { count: sources.length });
193
+ }
194
+
195
+ const requestedOutputDir = body.options && body.options.outputDir;
196
+ const outputDirAbs = requestedOutputDir
197
+ ? (await resolveDirForWrite(app, requestedOutputDir, { allowRoot: true, rootAbs })).absPath
198
+ : path.dirname(sources[0].absPath);
199
+
200
+ return { sources, format, mode, outputDirAbs, rootAbs, rootId: root.id, options: body.options || {} };
201
+ }
202
+
203
+ /** 校验并解析解压请求(归档 + 目标目录 + 选项) */
204
+ async function parseExtractRequest(app, body) {
205
+ const root = app.fsGuard.root(body.rootId);
206
+ const archive = await app.fsGuard.resolveRealIn(root.absPath, body.path);
207
+ const stat = await fsStat(archive.realPath);
208
+ if (!stat.isFile()) {
209
+ throw apiError(ERROR_CODES.NOT_A_DIRECTORY, '解压目标必须是文件', { path: archive.userPath });
210
+ }
211
+
212
+ const plugin = pluginForArchive(app, archive.userPath);
213
+ const target = body.targetDir
214
+ ? await resolveDirForWrite(app, body.targetDir, { rootAbs: root.absPath })
215
+ : await defaultTarget(app, archive.realPath);
216
+
217
+ return { archive, plugin, target, rootAbs: root.absPath, rootId: root.id, options: body.options || {} };
218
+ }
219
+
220
+ /** 未指定目标目录时:用"与压缩包同级的同名目录"(可能尚不存在) */
221
+ async function defaultTarget(app, archiveAbsPath) {
222
+ const targetAbsDir = await defaultExtractDir(archiveAbsPath);
223
+ return { userPath: relUserPath(app, targetAbsDir), absPath: targetAbsDir };
224
+ }
225
+
226
+ /** 把 body.sources(API 路径数组)解析成白名单内的实体 */
227
+ async function resolveSources(app, rawSources, rootAbs) {
228
+ if (!Array.isArray(rawSources) || rawSources.length === 0) {
229
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'sources 必须是非空数组');
230
+ }
231
+ const resolved = [];
232
+ for (const raw of rawSources) {
233
+ const { userPath, realPath } = await app.fsGuard.resolveRealIn(rootAbs, raw);
234
+ resolved.push({
235
+ path: userPath,
236
+ userPath,
237
+ absPath: realPath,
238
+ name: userPath === '/' ? '/' : path.basename(userPath),
239
+ kind: (await fsStat(realPath)).isDirectory() ? 'dir' : 'file',
240
+ });
241
+ }
242
+ return resolved;
243
+ }
244
+
245
+ function fsStat(target) {
246
+ return fsp.stat(target);
247
+ }
248
+
249
+ async function compressPreflightFor(app, parsed) {
250
+ // 黑名单层在这里解析一次:预检与(随后的)打包必须用同一套判定语义
251
+ const blacklist = parsed.options.blacklist || {};
252
+ const layers = await app.blacklist.layersFor({
253
+ enabled: blacklist.enabled !== false,
254
+ taskPatterns: Array.isArray(blacklist.extraPatterns) ? blacklist.extraPatterns : [],
255
+ });
256
+ return compressPreflight(
257
+ { config: app.m2.config, rootAbs: parsed.rootAbs, blacklistLayers: layers },
258
+ {
259
+ sources: parsed.sources,
260
+ format: parsed.format,
261
+ mode: parsed.mode,
262
+ outputDir: parsed.outputDirAbs,
263
+ },
264
+ );
265
+ }
266
+
267
+ async function extractPreflightFor(app, parsed) {
268
+ const preflight = await extractPreflight(
269
+ { config: app.m2.config, plugin: parsed.plugin },
270
+ {
271
+ archivePath: parsed.archive.userPath,
272
+ archiveAbsPath: parsed.archive.realPath,
273
+ targetDir: parsed.target.userPath,
274
+ targetAbsDir: parsed.target.absPath,
275
+ },
276
+ );
277
+ // 加密包预检:由格式插件自报(ZIP 读 central directory 标志位,RAR 看 unrar 列表输出)。
278
+ // 不能写死某个格式 —— 任何能解加密包的插件都应让前端弹密码框,
279
+ // 否则用户会停在「提示要密码、却没有输入框」的死角。
280
+ if (typeof parsed.plugin.probeEncryption === 'function') {
281
+ const probe = await parsed.plugin.probeEncryption(parsed.archive.realPath);
282
+ if (probe.encrypted && !(parsed.options && parsed.options.password)) {
283
+ preflight.encrypted = true;
284
+ preflight.isAes = probe.isAes === true;
285
+ }
286
+ }
287
+ return preflight;
288
+ }
289
+
290
+ /** 归档 → 格式包(按扩展名匹配;匹配不到报 UNSUPPORTED_FORMAT)
291
+ * 注意:必须传**完整文件名/路径**而不是 path.extname() —— 复合扩展名如 `x.tar.gz` 的
292
+ * extname 只有 `.gz`,光看它匹配不到 `targz`(注册表按"整体后缀最长匹配"判定)。
293
+ */
294
+ function pluginForArchive(app, userPath) {
295
+ const plugin = app.m2.registry.byExtension(path.basename(userPath));
296
+ if (!plugin) {
297
+ throw apiError(ERROR_CODES.UNSUPPORTED_FORMAT, '不支持的压缩包格式', { path: userPath });
298
+ }
299
+ return plugin;
300
+ }
301
+
302
+ /** 绝对路径 → API 用户路径(内部路径不出 API) */
303
+ function relUserPath(app, absPath) {
304
+ const root = app.fsContext.rootAbs;
305
+ const relative = path.relative(root, absPath);
306
+ if (relative === '') return '/';
307
+ return `/${relative.split(path.sep).join('/')}`;
308
+ }
309
+
310
+ /**
311
+ * 解析"写入用的目录"(outputDir / targetDir):
312
+ * - 允许目标**尚不存在**(解压目标、新建产物目录都会先给一个不存在的路径);
313
+ * - 先用 fsGuard.resolve 做形态 + 白名单前缀校验(不碰磁盘),
314
+ * 再对**父目录**做 resolveReal(父目录必须存在,借此拦住 symlink 逃逸)。
315
+ */
316
+ /** 在指定根下做同步形态校验(与 fsGuard.resolve 同规则,只是换了基准) */
317
+ function resolveWithinRootFor(rootAbs, rawUserPath, fsContext) {
318
+ return resolveWithinRoot(rootAbs, rawUserPath, {
319
+ maxPathLength: fsContext.maxPathLength,
320
+ maxPathSegmentLength: fsContext.maxPathSegmentLength,
321
+ });
322
+ }
323
+
324
+ async function resolveDirForWrite(app, rawUserPath, options = {}) {
325
+ // 多根:输出目录也必须在**本次请求的根**下解析(否则任务会把产物写到另一个根里)
326
+ const rootAbs = options.rootAbs || app.fsContext.rootAbs;
327
+ const { userPath } = resolveWithinRootFor(rootAbs, rawUserPath, app.fsContext);
328
+ if (userPath === '/') {
329
+ if (options.allowRoot) return { userPath: '/', absPath: rootAbs };
330
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'targetDir 不能是存储根目录', { path: userPath });
331
+ }
332
+ const parentUserPath = path.posix.dirname(userPath);
333
+ const parent = await app.fsGuard.resolveRealIn(rootAbs, parentUserPath);
334
+ const absPath = path.join(parent.realPath, path.posix.basename(userPath));
335
+ if (!absPath.startsWith(parent.realPath + path.sep)) {
336
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '目录超出可访问范围', { path: userPath });
337
+ }
338
+ return { userPath, absPath };
339
+ }
@@ -0,0 +1,109 @@
1
+ // 文件浏览路由(M1:docs/M0/API规范.md §4.2 的 fs/list 与 fs/stat)
2
+ //
3
+ // 安全约定:所有涉及路径的端点都挂 `app.guardPath()`(中间件层白名单)+
4
+ // 业务层(core/fs/*)再次校验 —— defense in depth。
5
+ //
6
+ // 响应字段严格照契约:list → {path,parent,entries,total,truncated,...};
7
+ // stat → 单条目对象。**不出现在响应里的绝对路径**(进程内部字段不返回)。
8
+
9
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
10
+ import { listDirectory } from '../../core/fs/list.js';
11
+ import { listRoots } from '../../core/fs/roots.js';
12
+ import { searchFiles } from '../../core/fs/search.js';
13
+ import { deleteEntries, makeDirectory, moveEntries, renameEntry } from '../../core/fs/writeOps.js';
14
+ import { repairNamesInDirectory } from '../../core/fs/repairNames.js';
15
+ import { statEntry } from '../../core/fs/stat.js';
16
+
17
+ /** 查询串里的数字参数解析(非法值→undefined,由 core 用默认值) */
18
+ function toInt(value) {
19
+ if (value === undefined || value === null || value === '') return undefined;
20
+ const parsed = Number.parseInt(value, 10);
21
+ return Number.isInteger(parsed) ? parsed : undefined;
22
+ }
23
+
24
+ function toBool(value) {
25
+ if (value === true || value === 'true' || value === '1') return true;
26
+ if (value === false || value === 'false' || value === '0') return false;
27
+ return undefined;
28
+ }
29
+
30
+ export default async function fsRoutes(app) {
31
+ /**
32
+ * 存储根列表(docs/16 §三.2):平台 + 模式 + 根列表 + 容量。
33
+ * 两种数据源(卷 / 共享)由 roots.js 按平台决定;statfs 失败只让容量为 null,不报错。
34
+ */
35
+ app.get('/fs/volumes', { preHandler: [app.requireAuth] }, async (request, reply) => {
36
+ const storage = app.m2.config.storage;
37
+ const rootsPayload = await listRoots({ storage, env: process.env });
38
+ // 回写缓存:新增/删除共享后,带 rootId 的请求不必等重启就能用
39
+ if (typeof app.refreshRoots === 'function') app.refreshRoots(rootsPayload);
40
+ // 配置里显式给了 roots 时,把它们的 label 一并带上(自动发现的 label 已在 roots.js 里定好)
41
+ reply.ok(rootsPayload);
42
+ });
43
+
44
+ app.get('/fs/list', { preHandler: [app.requireAuth, app.guardPath({ source: 'query' })] }, async (request, reply) => {
45
+ const query = request.query || {};
46
+ // 黑名单层(用户层有缓存,逐请求解析成本可忽略);契约 §4.2 的 blacklisted/blacklistLayer 由服务端算
47
+ const blacklistLayers = await app.blacklist.layersFor({ enabled: true, taskPatterns: [] });
48
+ const listing = await listDirectory(app.requestContext(request), {
49
+ blacklistLayers,
50
+ // 用中间件已校验过的 userPath 再走一遍 core(core 内部仍会完整校验)
51
+ path: request.userPath,
52
+ sort: query.sort,
53
+ order: query.order,
54
+ showHidden: toBool(query.showHidden),
55
+ limit: toInt(query.limit),
56
+ offset: toInt(query.offset),
57
+ });
58
+ reply.ok(listing);
59
+ });
60
+
61
+ // ===== 写操作(M8 右键菜单/快捷键/拖拽的后端支撑,形状照 API 规范 §4.2)=====
62
+ // 路径校验在这里**不做 preHandler**:四个端点都可能是"源 + 目标"或数组路径,
63
+ // 统一交给 core/fs/writeOps 逐条走 safePath(含符号链接复核),避免半套校验。
64
+ app.post('/fs/mkdir', { preHandler: [app.requireAuth] }, async (request, reply) => {
65
+ reply.ok(await makeDirectory(app.requestContext(request), request.body || {}));
66
+ });
67
+
68
+ app.post('/fs/rename', { preHandler: [app.requireAuth] }, async (request, reply) => {
69
+ reply.ok(await renameEntry(app.requestContext(request), request.body || {}));
70
+ });
71
+
72
+ app.post('/fs/move', { preHandler: [app.requireAuth] }, async (request, reply) => {
73
+ reply.ok(await moveEntries(app.requestContext(request), request.body || {}));
74
+ });
75
+
76
+ // 批量修复乱码文件名(U+FFFE + 私有区字节转义 → 原名)。dryRun 默认 true,不显式传 false 只出清单。
77
+ app.post('/fs/repair-names', { preHandler: [app.requireAuth] }, async (request, reply) => {
78
+ reply.ok(await repairNamesInDirectory(app.requestContext(request), request.body || {}));
79
+ });
80
+ app.post('/fs/delete', { preHandler: [app.requireAuth] }, async (request, reply) => {
81
+ reply.ok(await deleteEntries(app.requestContext(request), request.body || {}, {
82
+ // 与 M5 删除原文件同一机制:进回收站(delete.mode 决定是 trash 还是 log-only 记账)
83
+ deleter: app.m2.deleteService,
84
+ maxDeleteItems: app.fsContext.maxDeleteItems,
85
+ // 删除任务的 INV-06 复核基准跟随本次请求的根(多根下不能用全局默认根)
86
+ rootAbs: app.requestContext(request).rootAbs,
87
+ }));
88
+ });
89
+
90
+ /**
91
+ * 文件名搜索(有界遍历:深度 6 / 结果 500 / 3s):只在当前根内、不跟随符号链接、跳过黑名单目录。
92
+ * 未给 q 或为空 → 400(前端"未输入不请求")。
93
+ */
94
+ app.get('/fs/search', { preHandler: [app.requireAuth] }, async (request, reply) => {
95
+ const context = app.requestContext(request);
96
+ const layers = await app.blacklist.layersFor({ enabled: true, taskPatterns: [] });
97
+ const searchPayload = await searchFiles(context, request.query.q, { limit: request.query.limit, layers });
98
+ reply.ok(searchPayload);
99
+ });
100
+
101
+ app.get('/fs/stat', { preHandler: [app.requireAuth, app.guardPath({ source: 'query' })] }, async (request, reply) => {
102
+ if (request.userPath === undefined) {
103
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'path 必填');
104
+ }
105
+ const blacklistLayers = await app.blacklist.layersFor({ enabled: true, taskPatterns: [] });
106
+ const entry = await statEntry(app.requestContext(request), request.userPath, { blacklistLayers });
107
+ reply.ok(entry);
108
+ });
109
+ }
@@ -0,0 +1,160 @@
1
+ // 硬链接去重端点(docs/15 §三)
2
+ //
3
+ // - POST /hardlink/scan 扫描重复分组(**默认 dry-run,不动任何文件**——本端点本身永不改文件)
4
+ // 请求:{ path: string, extensions?: string[] }
5
+ // 响应:data.groups[] = { size, hash, master, duplicates[], savedBytes } + data.stats
6
+ // 路径全部走 fsGuard.resolveReal(INV-06 白名单 + 符号链接复核);响应里只出 userPath。
7
+ //
8
+ // - POST /hardlink/execute 批次②实现(执行替换,需显式 confirmed)——本文件先放参数守卫与
9
+ // 409 占位,避免批次①就开执行面。
10
+ //
11
+ // 风险纪律(docs/15 §四):风险与 M5 同级 → 扫描只读、扩展名白名单、日志(批次②)。
12
+
13
+ import path from 'node:path';
14
+
15
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
16
+ import { executeHardlinkGroups } from '../../core/hardlink/executor.js';
17
+ import { scanHardlinkDuplicates } from '../../core/hardlink/scanner.js';
18
+
19
+ /** 扩展名默认白名单(docs/15 §三.前端:mp4/mkv/avi/mov/iso/zip/7z) */
20
+ const DEFAULT_EXTENSIONS = ['mp4', 'mkv', 'avi', 'mov', 'iso', 'zip', '7z'];
21
+ /** 扩展名条目上限(防异常请求撑爆配置面) */
22
+ const MAX_EXTENSIONS = 64;
23
+
24
+ /** 解析并归一化扩展名白名单(小写、去点、去空、限数) */
25
+ function normalizeExtensions(raw) {
26
+ if (raw === undefined || raw === null) return DEFAULT_EXTENSIONS;
27
+ if (!Array.isArray(raw)) {
28
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'extensions 必须是字符串数组', { got: typeof raw });
29
+ }
30
+ const cleaned = [...new Set(raw
31
+ .filter((item) => typeof item === 'string')
32
+ .map((item) => item.trim().replace(/^\./, '').toLowerCase())
33
+ .filter(Boolean))];
34
+ if (cleaned.length === 0 || cleaned.length > MAX_EXTENSIONS) {
35
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, `extensions 需要 1~${MAX_EXTENSIONS} 个有效条目`, { got: cleaned.length });
36
+ }
37
+ return cleaned;
38
+ }
39
+
40
+ /**
41
+ * 在当前请求所属存储根内解析路径(docs/16 多根:与 fs 路由同源,用 app.requestContext)。
42
+ * 错误文案要能区分两种"扫不了":
43
+ * - PATH_NOT_FOUND:当前根里没有这个目录(可能是手填错了)
44
+ * - PATH_OUT_OF_ROOT:路径不在当前根内(例如没切根就去扫别的共享)→ 提示先切根
45
+ */
46
+ async function resolveInRoot(app, context, rawPath) {
47
+ try {
48
+ return await app.fsGuard.resolveRealIn(context.rootAbs, rawPath);
49
+ } catch (error) {
50
+ if (error && error.code === ERROR_CODES.PATH_NOT_FOUND) {
51
+ throw apiError(ERROR_CODES.PATH_NOT_FOUND, '该目录在当前存储根内不存在', { path: rawPath, rootId: context.rootId });
52
+ }
53
+ if (error && error.code === ERROR_CODES.PATH_OUT_OF_ROOT) {
54
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '不在当前根内,请先从左侧切换存储位置', { path: rawPath, rootId: context.rootId });
55
+ }
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ export default async function hardlinkRoutes(app) {
61
+ /**
62
+ * 扫描重复分组(只读;永不改文件)。
63
+ * docs/15:默认 dry-run 语义 = 本端点天然只读;「执行」是独立端点(批次②)且需显式确认。
64
+ */
65
+ app.post('/hardlink/scan', { preHandler: [app.requireAuth] }, async (request, reply) => {
66
+ const body = request.body || {};
67
+ if (typeof body.path !== 'string' || body.path.length === 0) {
68
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'path 必须是非空字符串');
69
+ }
70
+ const extensions = normalizeExtensions(body.extensions);
71
+ // 白名单 + 符号链接复核(INV-06,与 fs 路由同源),基准是**本请求的存储根**
72
+ const context = app.requestContext(request);
73
+ const resolved = await resolveInRoot(app, context, body.path);
74
+ const scanPayload = await scanHardlinkDuplicates(resolved.absPath, extensions);
75
+
76
+ // 响应路径统一转 userPath(绝对路径绝不进 API,INV-06 红线)
77
+ const rootPrefix = resolved.absPath;
78
+ const toUser = (absPath) => {
79
+ const relative = path.posix.relative(rootPrefix.replaceAll(path.sep, '/'), absPath.replaceAll(path.sep, '/'));
80
+ const base = resolved.userPath === '/' ? '' : resolved.userPath.replace(/\/$/, '');
81
+ return `${base}/${relative}`;
82
+ };
83
+ const groups = scanPayload.groups.map((group) => ({
84
+ ...group,
85
+ master: toUser(group.master),
86
+ duplicates: group.duplicates.map(toUser),
87
+ }));
88
+
89
+ reply.ok({
90
+ path: resolved.userPath,
91
+ extensions,
92
+ groups,
93
+ stats: scanPayload.stats,
94
+ });
95
+ });
96
+
97
+ /**
98
+ * 执行替换(批次②):对 scan 产出的分组执行 rename→link→unlink 三步替换。
99
+ * 请求:{ path, extensions?, groups: [{size, hash, master, duplicates[]}](userPath 坐标系,必须来自 scan 响应), confirmed: true }
100
+ * 守卫链:confirmed 显式 true → 组路径逐一过白名单并限定在扫描根下 → 服务端重新全量哈希复核
101
+ * (不信任客户端判定,扫描到执行之间文件可能变化;不一致整组跳过)→ 三步替换
102
+ * (EXDEV/EEXIST/EPERM/ENOSPC 单条隔离,失败回滚 rename)。每条落 hardlink-*.jsonl。
103
+ */
104
+ app.post('/hardlink/execute', { preHandler: [app.requireAuth] }, async (request, reply) => {
105
+ const body = request.body || {};
106
+ if (body.confirmed !== true) {
107
+ throw apiError(ERROR_CODES.INVALID_STATE, '执行需要显式确认(confirmed=true);先跑 scan 看预览');
108
+ }
109
+ if (typeof body.path !== 'string' || body.path.length === 0) {
110
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'path 必须是非空字符串');
111
+ }
112
+ if (!Array.isArray(body.groups) || body.groups.length === 0) {
113
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'groups 必须是非空数组(来自 scan 响应)');
114
+ }
115
+ const extensions = normalizeExtensions(body.extensions);
116
+ const context = app.requestContext(request);
117
+ const resolved = await resolveInRoot(app, context, body.path);
118
+
119
+ // 组路径白名单复核(INV-06:执行面是写路径,比 scan 更严——逐个 resolveReal 并限定在扫描根下)
120
+ const resolveGroup = async (group) => {
121
+ if (!group || typeof group.master !== 'string' || !Array.isArray(group.duplicates) || group.duplicates.length === 0) {
122
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'groups 每项需要 master 与非空 duplicates');
123
+ }
124
+ const toAbs = async (userPath) => {
125
+ const resolvedEntry = await resolveInRoot(app, context, userPath);
126
+ const rel = path.relative(resolved.absPath, resolvedEntry.absPath);
127
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
128
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '分组路径不在扫描根下', { userPath });
129
+ }
130
+ return resolvedEntry.absPath;
131
+ };
132
+ return {
133
+ size: Number(group.size) || 0,
134
+ master: await toAbs(group.master),
135
+ duplicates: await Promise.all(group.duplicates.map(toAbs)),
136
+ };
137
+ };
138
+ const resolvedGroups = await Promise.all(body.groups.map(resolveGroup));
139
+
140
+ const executeOutcome = await executeHardlinkGroups(
141
+ { logDir: app.hardlinkLogDir, logger: app.appLogger },
142
+ resolvedGroups,
143
+ { confirmed: true },
144
+ );
145
+
146
+ // 响应路径转回 userPath(内部 absPath 绝不进 API)
147
+ const toUser = (absPath) => {
148
+ const rel = path.relative(resolved.absPath, absPath).split(path.sep).join('/');
149
+ const base = resolved.userPath === '/' ? '' : resolved.userPath.replace(/\/$/, '');
150
+ return `${base}/${rel}`;
151
+ };
152
+ reply.ok({
153
+ executed: executeOutcome.executed,
154
+ savedBytes: executeOutcome.savedBytes,
155
+ failed: executeOutcome.failed.map((item) => ({ duplicate: toUser(item.duplicate), code: item.code })),
156
+ skippedGroups: executeOutcome.skippedGroups.map((item) => ({ master: toUser(item.master), reason: item.reason })),
157
+ });
158
+ });
159
+ }
160
+
@@ -0,0 +1,52 @@
1
+ // 健康检查(docs/M0/API规范.md §4.6 的 M1 子集)
2
+ //
3
+ // 用途:启停脚本的 status 判定、前端"服务是否在线"探测。
4
+ // 安全:**不返回存储根的绝对路径**(避免把本机目录结构暴露给任何能访问端口的客户端),
5
+ // 只返回"是否已配置/可写"这类布尔与统计值。
6
+
7
+ import os from 'node:os';
8
+
9
+ /** 取服务进程的**实际**运行身份(决定它能读哪些文件;拿不到用户名就只给 uid) */
10
+ function runtimeIdentity() {
11
+ let uid = null;
12
+ let user = null;
13
+ try {
14
+ uid = typeof process.getuid === 'function' ? process.getuid() : null;
15
+ } catch {
16
+ uid = null;
17
+ }
18
+ try {
19
+ user = os.userInfo().username || null;
20
+ } catch {
21
+ user = null;
22
+ }
23
+ return { uid, user };
24
+ }
25
+
26
+ export default async function healthRoutes(app, options) {
27
+ const { meta } = options;
28
+
29
+ app.get('/health', async (request, reply) => {
30
+ const authStats = app.authStore ? app.authStore.stats() : { sessions: 0, tokens: 0 };
31
+ const taskStats = app.tasks ? app.tasks.manager.stats() : { total: 0, active: null, waiting: 0, subscribers: 0, seq: 0 };
32
+ reply.ok({
33
+ version: meta.version,
34
+ startedAt: meta.startedAt,
35
+ uptimeMs: Date.now() - meta.startedAt,
36
+ bootId: app.tasks ? app.tasks.manager.bootId : meta.bootId,
37
+ auth: { sessions: authStats.sessions, tokens: authStats.tokens },
38
+ storage: { configured: true, readable: true, writable: meta.rootWritable === true },
39
+ tasks: {
40
+ total: taskStats.total,
41
+ active: taskStats.active,
42
+ waiting: taskStats.waiting,
43
+ subscribers: taskStats.subscribers,
44
+ seq: taskStats.seq,
45
+ },
46
+ // 运行身份:权限提示要能说清"服务以谁的身份在访问文件",不能写死(套件代启 vs 用户手动启不同)
47
+ runtime: runtimeIdentity(),
48
+ // M2 已实现压缩/解压;M3 补齐任务系统;指标看板在 M7
49
+ features: { fs: true, auth: true, compress: true, tasks: true, metrics: true },
50
+ });
51
+ });
52
+ }