zipcalm 0.0.0-stage → 1.0.1

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -2
  3. package/bin/zipcalm.js +191 -0
  4. package/package.json +64 -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/repairNames.js +150 -0
  24. package/server/core/fs/roots.js +185 -0
  25. package/server/core/fs/safePath.js +173 -0
  26. package/server/core/fs/search.js +108 -0
  27. package/server/core/fs/stat.js +110 -0
  28. package/server/core/fs/systemTrash.js +212 -0
  29. package/server/core/fs/writeOps.js +257 -0
  30. package/server/core/hardlink/executor.js +106 -0
  31. package/server/core/hardlink/scanner.js +193 -0
  32. package/server/core/metrics/collector.js +279 -0
  33. package/server/core/metrics/procfs.js +107 -0
  34. package/server/core/task/events.js +82 -0
  35. package/server/core/task/manager.js +987 -0
  36. package/server/core/task/recovery.js +116 -0
  37. package/server/core/task/store.js +240 -0
  38. package/server/http/middleware/auth.js +130 -0
  39. package/server/http/middleware/error.js +54 -0
  40. package/server/http/middleware/logger.js +27 -0
  41. package/server/http/middleware/pathGuard.js +119 -0
  42. package/server/http/router.js +29 -0
  43. package/server/http/routes/auth.js +115 -0
  44. package/server/http/routes/blacklist.js +97 -0
  45. package/server/http/routes/capabilities.js +21 -0
  46. package/server/http/routes/compress.js +339 -0
  47. package/server/http/routes/fs.js +109 -0
  48. package/server/http/routes/hardlink.js +160 -0
  49. package/server/http/routes/health.js +52 -0
  50. package/server/http/routes/metrics.js +36 -0
  51. package/server/http/routes/tasks.js +304 -0
  52. package/server/lib/atomic-json.js +118 -0
  53. package/server/lib/errors.js +145 -0
  54. package/server/lib/fs-probe.js +25 -0
  55. package/server/lib/logger.js +68 -0
  56. package/server/lib/runtime-guard.js +49 -0
  57. package/server/lib/throttle.js +42 -0
  58. package/server/plugins/capabilities/password/index.js +300 -0
  59. package/server/plugins/capabilities/password/methods/aes256.js +31 -0
  60. package/server/plugins/capabilities/password/methods/zip20.js +29 -0
  61. package/server/plugins/formats/_shared/createGuards.js +26 -0
  62. package/server/plugins/formats/_shared/entrySafety.js +174 -0
  63. package/server/plugins/formats/_shared/nameRepair.js +123 -0
  64. package/server/plugins/formats/_shared/streams.js +18 -0
  65. package/server/plugins/formats/_shared/tar/compression.js +114 -0
  66. package/server/plugins/formats/_shared/tar/reader.js +74 -0
  67. package/server/plugins/formats/_shared/tar/writer.js +206 -0
  68. package/server/plugins/formats/_shared/tarFamily.js +207 -0
  69. package/server/plugins/formats/rar/index.js +354 -0
  70. package/server/plugins/formats/rar/js-engine.js +122 -0
  71. package/server/plugins/formats/rar/js-runner.mjs +129 -0
  72. package/server/plugins/formats/rar/unrar.js +266 -0
  73. package/server/plugins/formats/sevenzip/engine.js +355 -0
  74. package/server/plugins/formats/sevenzip/index.js +461 -0
  75. package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
  76. package/server/plugins/formats/tar/index.js +19 -0
  77. package/server/plugins/formats/targz/index.js +19 -0
  78. package/server/plugins/formats/tarzst/index.js +26 -0
  79. package/server/plugins/formats/zip/index.js +587 -0
  80. package/shared/app-version.js +7 -0
  81. package/shared/capabilities.js +21 -0
  82. package/shared/compress-modes.js +38 -0
  83. package/shared/error-codes.js +117 -0
  84. package/shared/format-list.js +101 -0
  85. package/shared/hardlink-hint.js +21 -0
  86. package/shared/task-phase.js +62 -0
  87. package/shared/task-status.js +84 -0
  88. package/web/dist/assets/index-D9Vdmrb9.css +1 -0
  89. package/web/dist/assets/index-DiDB4ClR.js +131 -0
  90. package/web/dist/index.html +18 -0
@@ -0,0 +1,116 @@
1
+ // 启动对账(M5-F09 / 数据模型 §六.4、P-08/P-10)
2
+ //
3
+ // 职责:进程启动、任务状态加载后,对「删除链路」留下的中间状态做一次对账扫描:
4
+ //
5
+ // 1. **trash 孤儿批次**(P-08):rename 进回收站与写 JSONL 日志是两步、非原子——
6
+ // 进程在两步之间死掉会出现「文件已在 trash/ 但日志无该 batchId」。本扫描发现
7
+ // 孤儿批次后补写 `action: 'reconciled-crash'` 日志行,消除不可对账状态。
8
+ // 文件本身是安全的(在回收站里),不移动、不删除、不还原。
9
+ //
10
+ // 2. **deleting 中断恢复**(P-10):deleting 是执行相位,中途 kill 后任务被 M3 恢复
11
+ // 逻辑标为 interrupted,但 delete.status 可能停在 'deleting'、部分文件已进 trash、
12
+ // 部分还在原位。对账后置 `delete.status='failed'`(契约枚举内,语义=删除未完成),
13
+ // details 记 batchId 指引与 trash 现状;不做自动续删(半删状态下继续删需要重新过
14
+ // 全部删除前防线,重试才是安全路径——重试会创建新任务重新走完整删除链)。
15
+ //
16
+ // 设计约束:**只读 trash 与 tasks 快照 + 追加日志 + 修改内存任务字段**,绝不移动/删除
17
+ // 任何文件(对账不是清理);所有结论落日志,人工可复核。
18
+
19
+ import fsp from 'node:fs/promises';
20
+ import path from 'node:path';
21
+
22
+ import { toUserPath } from '../fs/safePath.js';
23
+
24
+ /** 列出 trash 下的批次目录(排除 .cross-device 暂存位等非批次条目) */
25
+ async function listTrashBatches(trashDir) {
26
+ try {
27
+ const entries = await fsp.readdir(trashDir, { withFileTypes: true });
28
+ return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')).map((entry) => entry.name);
29
+ } catch {
30
+ return []; // trash 目录不存在 = 无批次
31
+ }
32
+ }
33
+
34
+ /** 读取全部删除日志里出现过的 batchId 集合(日志按天分文件,全量扫) */
35
+ async function collectLoggedBatchIds(logDir) {
36
+ const ids = new Set();
37
+ let files;
38
+ try {
39
+ files = await fsp.readdir(logDir);
40
+ } catch {
41
+ return ids;
42
+ }
43
+ for (const file of files) {
44
+ if (!file.startsWith('delete-') || !file.endsWith('.jsonl')) continue;
45
+ const text = await fsp.readFile(path.join(logDir, file), 'utf8').catch(() => '');
46
+ for (const line of text.split('\n')) {
47
+ if (!line.trim()) continue;
48
+ try {
49
+ const entry = JSON.parse(line);
50
+ if (entry.batchId) ids.add(entry.batchId);
51
+ } catch {
52
+ // 单行损坏不阻断整体对账(append-only 日志的常态容忍)
53
+ }
54
+ }
55
+ }
56
+ return ids;
57
+ }
58
+
59
+ /**
60
+ * 对账入口:返回 { orphanBatches: string[], reconciledDeletes: number }。
61
+ * @param {{trashDir: string, logDir: string, rootAbs: string, logger: object}} deps
62
+ * @param {Array<{info: {id, status, delete?}}> } tasks 当前任务快照(recoverTasks 落盘前的内存态)
63
+ */
64
+ export async function reconcileAfterBoot(deps, tasks) {
65
+ const { trashDir, logDir, rootAbs, logger } = deps;
66
+ const reconcileReport = { orphanBatches: [], reconciledDeletes: 0 };
67
+
68
+ // 1) trash 孤儿批次:有目录、无日志 → 补记
69
+ const loggedIds = await collectLoggedBatchIds(logDir);
70
+ for (const batchId of await listTrashBatches(trashDir)) {
71
+ if (loggedIds.has(batchId)) continue;
72
+ reconcileReport.orphanBatches.push(batchId);
73
+ try {
74
+ // 把批次内的文件清单写进补记日志(人工复核用,只列相对路径不复制内容)
75
+ const files = [];
76
+ const walk = async (dirAbs) => {
77
+ for (const entry of await fsp.readdir(dirAbs, { withFileTypes: true })) {
78
+ const abs = path.join(dirAbs, entry.name);
79
+ if (entry.isDirectory()) await walk(abs);
80
+ else files.push(toUserPath(rootAbs, abs));
81
+ }
82
+ };
83
+ await walk(path.join(trashDir, batchId));
84
+ const file = path.join(logDir, `delete-${new Date().toISOString().slice(0, 10).replaceAll('-', '')}.jsonl`);
85
+ await fsp.mkdir(logDir, { recursive: true });
86
+ await fsp.appendFile(file, `${JSON.stringify({
87
+ action: 'reconciled-crash',
88
+ batchId,
89
+ at: Date.now(),
90
+ files: files.slice(0, 50),
91
+ fileCount: files.length,
92
+ note: '进程在 rename 进回收站与写日志之间中断,启动对账补记(文件安全在回收站)',
93
+ })}\n`, 'utf8');
94
+ logger.warn('发现 trash 孤儿批次(日志缺失),已补记 reconciled-crash', { batchId, fileCount: files.length });
95
+ } catch (error) {
96
+ logger.error('trash 孤儿批次补记失败(不阻断启动)', { batchId, error: error.message });
97
+ }
98
+ }
99
+
100
+ // 2) deleting 中断的 delete.status 对账(任务此刻已被标 interrupted)
101
+ for (const record of tasks) {
102
+ const { info } = record;
103
+ if (!info.delete || info.delete.status !== 'deleting') continue;
104
+ info.delete.status = 'failed';
105
+ info.delete.error = {
106
+ code: 'INTERNAL_ERROR',
107
+ message: '删除阶段被服务重启中断,启动对账后标记为失败;原文件要么已进回收站(可从 trash 恢复)要么仍在原位,重试任务将重新走完整删除链',
108
+ at: Date.now(),
109
+ details: { trashBatch: info.delete.batchId || info.delete.trashBatch || null },
110
+ };
111
+ reconcileReport.reconciledDeletes += 1;
112
+ logger.warn('deleting 中断任务已对账为 delete.status=failed', { taskId: info.id, trashBatch: info.delete.batchId || null });
113
+ }
114
+
115
+ return reconcileReport;
116
+ }
@@ -0,0 +1,240 @@
1
+ // 任务落盘(docs/M0/数据模型.md §六 落盘模型)
2
+ //
3
+ // 两个文件、两种写法的分野(这是本模块存在的全部理由):
4
+ // - `tasks.json`:**非终态 + failed/interrupted** 任务,原子写(写 .tmp → rename)+ **500ms 节流**;
5
+ // 终态 done/aborted 写进历史后从这里移除。
6
+ // - `task-history.jsonl`:每次进终态**追加一行**;内存缓存最近 `limits.taskHistoryMax`(默认 200)条,
7
+ // 文件增长过大时按缓存重写压缩(避免无限增长)。
8
+ //
9
+ // 为什么必须原子写:任务状态是"服务重启后唯一能还原真相的来源",
10
+ // 原地截断写被中断 → 半个 JSON → 重启后任务全丢(INV-11)。
11
+ //
12
+ // 结构:状态全部放在 `ctx` 里,具体动作都是**模块级小函数**(每个只做一件事),
13
+ // 工厂函数只负责建 ctx 并把动作绑上去(便于单测与审读)。
14
+
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+
18
+ import { readJson, writeJsonAtomic } from '../../lib/atomic-json.js';
19
+
20
+ /** 历史条目净化:历史文件可能由旧版本写入(含 absPath 等内部字段),读取/追加时一律剥掉,防止经 API 泄漏 */
21
+ function sanitizeHistoryEntry(entry) {
22
+ if (!entry || typeof entry !== 'object') return entry;
23
+ const clean = { ...entry };
24
+ if (Array.isArray(clean.sources)) {
25
+ clean.sources = clean.sources.map(({ absPath, userPath, ...rest }) => rest);
26
+ }
27
+ if (clean.result && Array.isArray(clean.result.packages)) {
28
+ clean.result = { ...clean.result, packages: clean.result.packages.map(({ absPath, ...rest }) => rest) };
29
+ }
30
+ return clean;
31
+ }
32
+
33
+ function buildContext(options) {
34
+ const { dataDir } = options;
35
+ return {
36
+ logger: options.logger,
37
+ historyMax: Number.isInteger(options.historyMax) ? options.historyMax : 200,
38
+ saveThrottleMs: Number.isInteger(options.saveThrottleMs) ? options.saveThrottleMs : 500,
39
+ // 契约里的路径是 `storage/tasks.json`;本项目 dataDir 就是 ./storage(见 config/defaults.js)
40
+ tasksFile: path.join(dataDir, 'tasks.json'),
41
+ historyFile: path.join(dataDir, 'task-history.jsonl'),
42
+ /** 最近 historyMax 条历史(旧 → 新,与文件顺序一致) */
43
+ historyCache: [],
44
+ saveTimer: null,
45
+ savePending: null,
46
+ saveInFlight: null,
47
+ /** 写入串行链(并发写会互相截断 .tmp,见 saveTasksNow 注释) */
48
+ writeChain: Promise.resolve(),
49
+ };
50
+ }
51
+
52
+ /** 读 tasks.json;坏文件按空处理并告警(原文件保留,人工可查;不拖垮启动) */
53
+ async function loadTasks(ctx) {
54
+ const { data, ok, reason } = await readJson(ctx.tasksFile, []);
55
+ if (!ok && reason !== 'not-found') {
56
+ ctx.logger.warn('tasks.json 不可读,按空列表处理', { reason });
57
+ return [];
58
+ }
59
+ return Array.isArray(data) ? data : [];
60
+ }
61
+
62
+ /**
63
+ * 读取历史文件(逐行解析 + 净化;坏行跳过)。
64
+ *
65
+ * **关键:启动加载时就收敛文件本身**,不只裁内存。
66
+ * 为什么必须这样:`appendHistoryEntry` 是"先 appendFile、后 trimHistoryFile"两步,
67
+ * 进程若在这两步之间被 kill(crash 套件就是用 kill -9 复现的),文件就会停在上限 +1 行;
68
+ * 而旧实现只在内存里 slice(-max),文件要等"下一次 append"才顺带收敛 ——
69
+ * 若启动后一直空闲,文件会**永久停在 201 行**(实测:验收独立跑就抓到了这条)。
70
+ */
71
+ async function loadHistoryFromFile(ctx) {
72
+ let rawLines;
73
+ try {
74
+ rawLines = (await fs.readFile(ctx.historyFile, 'utf8')).split('\n').filter((line) => line.trim().length > 0);
75
+ } catch {
76
+ return []; // 文件不存在/不可读:按空历史启动(不拖垮启动)
77
+ }
78
+
79
+ const entries = rawLines.slice(-ctx.historyMax).map(parseHistoryLine).filter(Boolean);
80
+
81
+ if (rawLines.length > ctx.historyMax) {
82
+ await rewriteHistoryFile(ctx, entries, { was: rawLines.length, trigger: '启动加载' });
83
+ }
84
+ return entries;
85
+ }
86
+
87
+ /** 单行 → 净化后的历史条目;坏行返回 null(调用方过滤掉) */
88
+ function parseHistoryLine(line) {
89
+ try {
90
+ return sanitizeHistoryEntry(JSON.parse(line));
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /** 启动加载:读 tasks.json + 预热历史缓存 */
97
+ async function initStore(ctx) {
98
+ const tasks = await loadTasks(ctx);
99
+ ctx.historyCache = await loadHistoryFromFile(ctx);
100
+ return { tasks, historyCount: ctx.historyCache.length };
101
+ }
102
+
103
+ /** 单次写入(原子:写 <file>.tmp → rename) */
104
+ async function writeTasksFile(ctx, tasks) {
105
+ try {
106
+ await writeJsonAtomic(ctx.tasksFile, tasks);
107
+ } catch (error) {
108
+ ctx.logger.error('tasks.json 写盘失败', { error: error.message });
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 立即原子落盘。
114
+ * **必须串行**:`writeJsonAtomic` 用的是固定的 `<file>.tmp`,两个写并发会互相截断同一个临时文件,
115
+ * rename 之后 tasks.json 里就会出现"两份 JSON 粘在一起"的残骸(实测踩过:JSON.parse 报
116
+ * "Unexpected non-whitespace character after JSON")。串行化后任一时刻只有一个写在跑,
117
+ * 也与"单进程单写者"(INV-09)的模型一致。
118
+ */
119
+ async function saveTasksNow(ctx, tasks) {
120
+ ctx.savePending = null;
121
+ if (ctx.saveTimer) {
122
+ clearTimeout(ctx.saveTimer);
123
+ ctx.saveTimer = null;
124
+ }
125
+ const previous = ctx.writeChain || Promise.resolve();
126
+ const chained = previous.then(() => writeTasksFile(ctx, tasks)).catch(() => {});
127
+ ctx.writeChain = chained;
128
+ ctx.saveInFlight = chained;
129
+ await chained;
130
+ if (ctx.saveInFlight === chained) ctx.saveInFlight = null;
131
+ }
132
+
133
+ /**
134
+ * 节流落盘:进度类高频变更走这里(契约:tasks.json ≤2 次/秒)。
135
+ * 状态/phase 变化与终态必须用 saveTasksNow() 立即落盘,**不能等节流**(INV-10「创建即落盘」同理)。
136
+ */
137
+ function scheduleTasksSave(ctx, tasks) {
138
+ ctx.savePending = tasks;
139
+ if (ctx.saveTimer) return;
140
+ ctx.saveTimer = setTimeout(() => {
141
+ ctx.saveTimer = null;
142
+ const payload = ctx.savePending;
143
+ ctx.savePending = null;
144
+ if (payload) void saveTasksNow(ctx, payload);
145
+ }, ctx.saveThrottleMs);
146
+ if (typeof ctx.saveTimer.unref === 'function') ctx.saveTimer.unref();
147
+ }
148
+
149
+ /** 收尾:等写入链跑完,并把未决的节流内容立刻写出(优雅关闭时调用) */
150
+ async function flushStore(ctx) {
151
+ if (ctx.writeChain) await ctx.writeChain;
152
+ if (ctx.savePending) await saveTasksNow(ctx, ctx.savePending);
153
+ }
154
+
155
+ /**
156
+ * 用给定条目**原子重写**历史文件(写 .tmp → rename)。
157
+ * 空数组写成空文件(合法:0 行历史);失败只告警,不影响运行。
158
+ * @param {object} ctx store 上下文
159
+ * @param {Array} entries 要保留的条目(旧 → 新)
160
+ * @param {{was?: number, trigger?: string}} [meta] 仅用于日志(哪次触发的、原来多少行)
161
+ */
162
+ async function rewriteHistoryFile(ctx, entries, meta = {}) {
163
+ try {
164
+ const tmpPath = `${ctx.historyFile}.tmp`;
165
+ const body = entries.length > 0 ? `${entries.map((item) => JSON.stringify(item)).join('\n')}\n` : '';
166
+ await fs.writeFile(tmpPath, body, 'utf8');
167
+ await fs.rename(tmpPath, ctx.historyFile);
168
+ ctx.logger.info('task-history.jsonl 已按条数收敛', {
169
+ kept: entries.length,
170
+ was: Number.isInteger(meta.was) ? meta.was : null,
171
+ max: ctx.historyMax,
172
+ trigger: meta.trigger || '追加后',
173
+ });
174
+ } catch (error) {
175
+ ctx.logger.warn('task-history.jsonl 压缩失败(不影响运行)', { error: error.message });
176
+ }
177
+ }
178
+
179
+ /**
180
+ * 历史文件按条数收敛(契约:`task-history.jsonl` 保留最近 `limits.taskHistoryMax` 条)。
181
+ * 触发条件只看行数(不再设"文件多大才整理"的门槛,否则行数会长期超过上限 —— 实测踩过);
182
+ * 超过就按内存缓存重写一次(200 行 ≈ 400KB,代价可控),原子性由 rewriteHistoryFile 保证。
183
+ */
184
+ async function trimHistoryFile(ctx) {
185
+ try {
186
+ const lines = (await fs.readFile(ctx.historyFile, 'utf8')).split('\n').filter((line) => line.trim().length > 0);
187
+ if (lines.length <= ctx.historyMax) return;
188
+ await rewriteHistoryFile(ctx, ctx.historyCache, { was: lines.length, trigger: '追加后' });
189
+ } catch (error) {
190
+ ctx.logger.warn('task-history.jsonl 压缩失败(不影响运行)', { error: error.message });
191
+ }
192
+ }
193
+
194
+ /** 终态追加一行,并同步内存缓存(超限裁剪) */
195
+ async function appendHistoryEntry(ctx, entry) {
196
+ const clean = sanitizeHistoryEntry(entry);
197
+ ctx.historyCache.push(clean);
198
+ if (ctx.historyCache.length > ctx.historyMax) ctx.historyCache = ctx.historyCache.slice(-ctx.historyMax);
199
+ try {
200
+ await fs.mkdir(path.dirname(ctx.historyFile), { recursive: true });
201
+ await fs.appendFile(ctx.historyFile, `${JSON.stringify(clean)}\n`, 'utf8');
202
+ await trimHistoryFile(ctx);
203
+ } catch (error) {
204
+ ctx.logger.error('task-history.jsonl 写入失败', { error: error.message });
205
+ }
206
+ }
207
+
208
+ /** 历史查询(新 → 旧);内存缓存优先(契约 §六.5) */
209
+ function listHistory(ctx, filter = {}) {
210
+ const limit = Number.isInteger(filter.limit) ? filter.limit : 50;
211
+ let items = [...ctx.historyCache].reverse();
212
+ if (filter.status) items = items.filter((item) => filter.status.includes(item.status));
213
+ return items.slice(0, limit);
214
+ }
215
+
216
+ function getHistoryEntry(ctx, id) {
217
+ for (let index = ctx.historyCache.length - 1; index >= 0; index -= 1) {
218
+ if (ctx.historyCache[index].id === id) return ctx.historyCache[index];
219
+ }
220
+ return null;
221
+ }
222
+
223
+ /**
224
+ * @param {{dataDir: string, historyMax?: number, saveThrottleMs?: number, logger: object}} options
225
+ */
226
+ export function createTaskStore(options) {
227
+ const ctx = buildContext(options);
228
+ return {
229
+ init: () => initStore(ctx),
230
+ loadTasks: () => loadTasks(ctx),
231
+ scheduleSave: (tasks) => scheduleTasksSave(ctx, tasks),
232
+ saveNow: (tasks) => saveTasksNow(ctx, tasks),
233
+ flush: () => flushStore(ctx),
234
+ appendHistory: (entry) => appendHistoryEntry(ctx, entry),
235
+ listHistory: (filter) => listHistory(ctx, filter),
236
+ getHistoryEntry: (id) => getHistoryEntry(ctx, id),
237
+ files: { tasksFile: ctx.tasksFile, historyFile: ctx.historyFile },
238
+ historyMax: ctx.historyMax,
239
+ };
240
+ }
@@ -0,0 +1,130 @@
1
+ // 认证中间件:Session(Cookie)+ Bearer Token 双通道(docs/M0/API规范.md §二)
2
+ //
3
+ // 行为:
4
+ // - onRequest 阶段"尽力认证":有凭据就校验,通过则挂 request.auth,不通过则记下失败原因(不在这里抛错,
5
+ // 让公开端点(登录/健康检查)照常工作;受保护端点由 requireAuth 抛错)。
6
+ // - Bearer 优先于 Cookie(契约)。
7
+ // - Session 命中滑动续期阈值时重发 Cookie(对齐"7 天滑动续期")。
8
+ // - 失败原因区分:完全没带 → AUTH_REQUIRED;带了但无效/过期 → AUTH_INVALID / AUTH_EXPIRED。
9
+
10
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
11
+
12
+ /** 从 Authorization 头里取 Bearer token */
13
+ function readBearer(request) {
14
+ const header = request.headers.authorization;
15
+ if (typeof header !== 'string') return null;
16
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
17
+ return match ? match[1].trim() : null;
18
+ }
19
+
20
+ export function registerAuth(app, { store, config }) {
21
+ app.decorateRequest('auth', null);
22
+ app.decorateRequest('authFailure', null);
23
+ app.decorateRequest('sessionId', null);
24
+ app.decorateRequest('sessionRenewed', null);
25
+ /** 是否需要在响应里补发会话 Cookie(见 onRequest 里 Bearer 分支的说明) */
26
+ app.decorateRequest('needsSessionMint', false);
27
+
28
+ app.addHook('onRequest', async (request) => {
29
+ const bearer = readBearer(request);
30
+ if (bearer) {
31
+ applyBearerAuth(request, store, config, bearer); // ① Bearer 优先
32
+ return;
33
+ }
34
+ applySessionCookie(request, store, config); // ② Cookie 兜底
35
+ });
36
+
37
+ /**
38
+ * 受保护路由的 preHandler。
39
+ * 注意:Session 续期需要"写 Cookie",所以放在这里(只有真正访问受保护端点时才续期)。
40
+ */
41
+ app.decorate('requireAuth', async function requireAuth(request, reply) {
42
+ assertAuthenticated(request);
43
+ renewSessionCookie(request, reply, config, store);
44
+ });
45
+
46
+ // ===== Cookie 助手(统一 HttpOnly / SameSite=Lax,契约 §二)=====
47
+
48
+ app.decorate('setSessionCookie', (reply, sid, ttlMs) => setSessionCookie(reply, config, sid, ttlMs));
49
+ app.decorate('clearSessionCookie', (reply) => reply.clearCookie(config.cookieName, { path: '/' }));
50
+ }
51
+
52
+ /** 取会话 Cookie 的值(没装 cookie 插件或没带该 Cookie 时返回 null) */
53
+ function readSessionCookie(request, config) {
54
+ return request.cookies ? request.cookies[config.cookieName] : null;
55
+ }
56
+
57
+ /**
58
+ * ① Bearer 分支(优先)。
59
+ * Bearer 通过时仍看一眼 Cookie:能验证就复用(并给滑动续期机会);
60
+ * 带了却验证不过(典型:服务重启后内存 session 失效)→ 标记补发新会话 Cookie。
61
+ * 为什么必须补:EventSource 无法携带 Authorization 头,浏览器里 SSE 只能靠 Cookie ——
62
+ * 不补的话重启后 SSE 会一直 401,而其余 axios 请求因为有 Bearer 照常工作(表象极具迷惑性)。
63
+ */
64
+ function applyBearerAuth(request, store, config, bearer) {
65
+ const record = store.verifyToken(bearer);
66
+ if (!record) {
67
+ request.authFailure = ERROR_CODES.AUTH_INVALID;
68
+ return;
69
+ }
70
+ request.auth = { type: 'bearer', user: record.user, expiresAt: record.expiresAt };
71
+
72
+ const cookieValue = readSessionCookie(request, config);
73
+ if (!cookieValue) return;
74
+ const session = store.verifySession(cookieValue);
75
+ if (session) {
76
+ request.sessionId = cookieValue;
77
+ request.sessionRenewed = session.renewed;
78
+ return;
79
+ }
80
+ request.needsSessionMint = true;
81
+ }
82
+
83
+ /** ② Cookie 兜底:验证不过时区分"已过期"与"未带凭据"(前端据此决定是提示重登还是跳登录页) */
84
+ function applySessionCookie(request, store, config) {
85
+ const cookieValue = readSessionCookie(request, config);
86
+ if (!cookieValue) {
87
+ request.authFailure = ERROR_CODES.AUTH_REQUIRED;
88
+ return;
89
+ }
90
+ const session = store.verifySession(cookieValue);
91
+ if (!session) {
92
+ request.authFailure = ERROR_CODES.AUTH_EXPIRED;
93
+ return;
94
+ }
95
+ request.auth = { type: 'session', user: session.user, expiresAt: session.expiresAt };
96
+ request.sessionId = cookieValue;
97
+ request.sessionRenewed = session.renewed;
98
+ }
99
+
100
+ /** 未认证就抛契约错误(早返回:把后续的续期/补发逻辑从 if 里解放出来) */
101
+ function assertAuthenticated(request) {
102
+ if (request.auth) return;
103
+ const code = request.authFailure || ERROR_CODES.AUTH_REQUIRED;
104
+ throw apiError(code, code === ERROR_CODES.AUTH_REQUIRED ? '需要登录后才能访问' : '登录凭据无效或已过期');
105
+ }
106
+
107
+ /** 需要续期/补发时才写 Cookie(Session 续期与 Bearer 补发共用同一个出口) */
108
+ function renewSessionCookie(request, reply, config, store) {
109
+ if (request.auth.type === 'session' && request.sessionRenewed && request.sessionId) {
110
+ setSessionCookie(reply, config, request.sessionId, store.sessionTtlMs);
111
+ return;
112
+ }
113
+ // Bearer 通过但客户端带着失效的会话 Cookie → 补发一次(只补一次:新 Cookie 下次即可验证通过)
114
+ if (request.auth.type === 'bearer' && request.needsSessionMint) {
115
+ const session = store.createSession(request.auth.user);
116
+ setSessionCookie(reply, config, session.sid, store.sessionTtlMs);
117
+ }
118
+ }
119
+
120
+ /** 写会话 Cookie(统一属性;放模块级,避免在注册函数里再嵌一层函数) */
121
+ function setSessionCookie(reply, cfg, sid, ttlMs) {
122
+ reply.setCookie(cfg.cookieName, sid, {
123
+ path: '/',
124
+ httpOnly: true,
125
+ sameSite: 'lax',
126
+ // 生产环境走 HTTPS 时应置 true;本机/局域网 HTTP 场景保持 false,否则浏览器不回传
127
+ secure: false,
128
+ maxAge: Math.floor(ttlMs / 1000),
129
+ });
130
+ }
@@ -0,0 +1,54 @@
1
+ // 统一错误处理 + 统一响应信封(docs/M0/API规范.md §一 / §八)
2
+ //
3
+ // 三条硬规则:
4
+ // 1. 所有响应都是 { ok, data, error }(成功 error=null,失败 data=null);
5
+ // 2. 错误码一律来自 shared/error-codes.js,HTTP 状态由契约元信息决定(不自造映射);
6
+ // 3. 5xx 记 error 级日志并带 traceId,4xx 记 warn 级(便于排查又不吵)。
7
+
8
+ import crypto from 'node:crypto';
9
+
10
+ import { ERROR_CODES, errorPayload, httpStatusOfError, toApiError } from '../../lib/errors.js';
11
+
12
+ export function registerErrorHandling(app, { logger }) {
13
+ // 成功响应助手:reply.ok(data) —— 业务代码永远不手写 { ok:true, ... }
14
+ app.decorateReply('ok', function sendOk(data, statusCode = 200) {
15
+ this.code(statusCode).send({ ok: true, data: data === undefined ? null : data, error: null });
16
+ });
17
+
18
+ // 404(路由不存在)也必须是契约信封
19
+ app.setNotFoundHandler((request, reply) => {
20
+ reply.code(404).send({
21
+ ok: false,
22
+ data: null,
23
+ error: {
24
+ code: ERROR_CODES.PATH_NOT_FOUND,
25
+ message: `接口不存在:${request.method} ${request.url.split('?')[0]}`,
26
+ },
27
+ });
28
+ });
29
+
30
+ app.setErrorHandler((error, request, reply) => {
31
+ const apiError = toApiError(error);
32
+ const statusCode = httpStatusOfError(apiError);
33
+ const traceId = request.id || crypto.randomUUID();
34
+
35
+ const logContext = {
36
+ traceId,
37
+ method: request.method,
38
+ url: request.url.split('?')[0],
39
+ statusCode,
40
+ code: apiError.code,
41
+ };
42
+ if (statusCode >= 500) {
43
+ logger.error('request failed', { ...logContext, error: apiError.message, stack: apiError.cause ? String(apiError.cause.stack || '').split('\n').slice(0, 3).join(' | ') : undefined });
44
+ } else {
45
+ logger.warn('request rejected', { ...logContext, error: apiError.message });
46
+ }
47
+
48
+ const payload = errorPayload(apiError);
49
+ // 5xx 附带 traceId,方便对着日志查(不泄漏堆栈)
50
+ if (statusCode >= 500) payload.details = { ...(payload.details || {}), traceId };
51
+
52
+ reply.code(statusCode).send({ ok: false, data: null, error: payload });
53
+ });
54
+ }
@@ -0,0 +1,27 @@
1
+ // 请求日志(结构化 + 脱敏)
2
+ //
3
+ // 只记录「定位问题需要的最小信息」:requestId / method / path / status / 耗时 / 认证通道。
4
+ // **不记录**查询串原文(可能含 path 但更可能含将来出现的敏感参数)、不记录 Cookie/Authorization
5
+ // (lib/logger.js 的 redact 也会兜一层)。
6
+
7
+ import crypto from 'node:crypto';
8
+
9
+ export function registerRequestLogger(app, { logger }) {
10
+ app.addHook('onRequest', async (request, reply) => {
11
+ request.requestId = crypto.randomUUID();
12
+ request.startAt = process.hrtime.bigint();
13
+ reply.header('X-Request-Id', request.requestId);
14
+ });
15
+
16
+ app.addHook('onResponse', async (request, reply) => {
17
+ const durationMs = request.startAt ? Number(process.hrtime.bigint() - request.startAt) / 1e6 : null;
18
+ logger.info('request', {
19
+ requestId: request.requestId,
20
+ method: request.method,
21
+ path: request.url.split('?')[0],
22
+ status: reply.statusCode,
23
+ durationMs: durationMs === null ? null : Number(durationMs.toFixed(1)),
24
+ authType: request.auth ? request.auth.type : null,
25
+ });
26
+ });
27
+ }