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.
- package/LICENSE +21 -0
- package/README.md +260 -2
- package/bin/zipcalm.js +191 -0
- package/package.json +67 -4
- package/server/app.js +309 -0
- package/server/boot.js +145 -0
- package/server/config/defaults.js +153 -0
- package/server/config/index.js +140 -0
- package/server/config/schema.js +244 -0
- package/server/core/auth/password.js +89 -0
- package/server/core/auth/store.js +261 -0
- package/server/core/compress/blacklist.js +236 -0
- package/server/core/compress/capRegistry.js +171 -0
- package/server/core/compress/deleter.js +285 -0
- package/server/core/compress/hardlinkScan.js +67 -0
- package/server/core/compress/orchestrator.js +783 -0
- package/server/core/compress/preflight.js +376 -0
- package/server/core/compress/registry.js +240 -0
- package/server/core/compress/sourceWalk.js +84 -0
- package/server/core/compress/verify.js +47 -0
- package/server/core/engineRunner.js +369 -0
- package/server/core/fs/list.js +164 -0
- package/server/core/fs/platformProfile.js +167 -0
- package/server/core/fs/repairNames.js +150 -0
- package/server/core/fs/roots.js +178 -0
- package/server/core/fs/safePath.js +173 -0
- package/server/core/fs/search.js +108 -0
- package/server/core/fs/stat.js +110 -0
- package/server/core/fs/systemTrash.js +212 -0
- package/server/core/fs/writeOps.js +257 -0
- package/server/core/hardlink/executor.js +106 -0
- package/server/core/hardlink/scanner.js +193 -0
- package/server/core/metrics/collector.js +279 -0
- package/server/core/metrics/procfs.js +107 -0
- package/server/core/task/events.js +82 -0
- package/server/core/task/manager.js +987 -0
- package/server/core/task/recovery.js +116 -0
- package/server/core/task/store.js +240 -0
- package/server/http/middleware/auth.js +130 -0
- package/server/http/middleware/error.js +54 -0
- package/server/http/middleware/logger.js +27 -0
- package/server/http/middleware/pathGuard.js +119 -0
- package/server/http/router.js +29 -0
- package/server/http/routes/auth.js +115 -0
- package/server/http/routes/blacklist.js +97 -0
- package/server/http/routes/capabilities.js +21 -0
- package/server/http/routes/compress.js +339 -0
- package/server/http/routes/fs.js +109 -0
- package/server/http/routes/hardlink.js +160 -0
- package/server/http/routes/health.js +52 -0
- package/server/http/routes/metrics.js +36 -0
- package/server/http/routes/tasks.js +304 -0
- package/server/lib/atomic-json.js +118 -0
- package/server/lib/errors.js +145 -0
- package/server/lib/fs-probe.js +25 -0
- package/server/lib/logger.js +68 -0
- package/server/lib/runtime-guard.js +49 -0
- package/server/lib/throttle.js +42 -0
- package/server/plugins/capabilities/password/index.js +300 -0
- package/server/plugins/capabilities/password/methods/aes256.js +31 -0
- package/server/plugins/capabilities/password/methods/zip20.js +29 -0
- package/server/plugins/formats/_shared/createGuards.js +26 -0
- package/server/plugins/formats/_shared/entrySafety.js +174 -0
- package/server/plugins/formats/_shared/nameRepair.js +123 -0
- package/server/plugins/formats/_shared/streams.js +18 -0
- package/server/plugins/formats/_shared/tar/compression.js +114 -0
- package/server/plugins/formats/_shared/tar/reader.js +74 -0
- package/server/plugins/formats/_shared/tar/writer.js +206 -0
- package/server/plugins/formats/_shared/tarFamily.js +207 -0
- package/server/plugins/formats/rar/index.js +354 -0
- package/server/plugins/formats/rar/js-engine.js +122 -0
- package/server/plugins/formats/rar/js-runner.mjs +129 -0
- package/server/plugins/formats/rar/unrar.js +266 -0
- package/server/plugins/formats/sevenzip/engine.js +355 -0
- package/server/plugins/formats/sevenzip/index.js +461 -0
- package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
- package/server/plugins/formats/tar/index.js +19 -0
- package/server/plugins/formats/targz/index.js +19 -0
- package/server/plugins/formats/tarzst/index.js +26 -0
- package/server/plugins/formats/zip/index.js +587 -0
- package/shared/app-version.js +7 -0
- package/shared/capabilities.js +21 -0
- package/shared/compress-modes.js +38 -0
- package/shared/error-codes.js +117 -0
- package/shared/format-list.js +101 -0
- package/shared/hardlink-hint.js +21 -0
- package/shared/task-phase.js +62 -0
- package/shared/task-status.js +84 -0
- package/web/dist/assets/index-D3zjq7N2.js +131 -0
- package/web/dist/assets/index-D9Vdmrb9.css +1 -0
- package/web/dist/index.html +18 -0
package/server/app.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// 应用装配(Fastify 实例 + 插件与路由挂载)
|
|
2
|
+
//
|
|
3
|
+
// 层次(docs/12 §二):http/ 只做入口(中间件 + 路由),core/ 放业务,lib/ 放工具。
|
|
4
|
+
// 本文件只负责"接线",不含任何业务逻辑,便于将来换 Web 框架时只改这一处。
|
|
5
|
+
//
|
|
6
|
+
// 结构:每个装配步骤是一个**模块级小函数**(实例 / 观测 / 安全 / 任务层 / 压缩层 / 前端 / 关停钩子),
|
|
7
|
+
// buildApp 只按顺序把它们串起来 —— 顺序本身有讲究(见各步骤注释)。
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
|
|
12
|
+
import Fastify from 'fastify';
|
|
13
|
+
import cookie from '@fastify/cookie';
|
|
14
|
+
import fastifyStatic from '@fastify/static';
|
|
15
|
+
|
|
16
|
+
import { pathExists } from './lib/fs-probe.js';
|
|
17
|
+
import { createAuthStore } from './core/auth/store.js';
|
|
18
|
+
import { verifyPassword } from './core/auth/password.js';
|
|
19
|
+
import { createDeleteService } from './core/compress/deleter.js';
|
|
20
|
+
import { createBlacklistService } from './core/compress/blacklist.js';
|
|
21
|
+
import { createMetricsCollector } from './core/metrics/collector.js';
|
|
22
|
+
import { createOrchestrator } from './core/compress/orchestrator.js';
|
|
23
|
+
import { createCapabilityRegistry } from './core/compress/capRegistry.js';
|
|
24
|
+
import { createRegistry } from './core/compress/registry.js';
|
|
25
|
+
import { createTaskEvents } from './core/task/events.js';
|
|
26
|
+
import { createTaskManager } from './core/task/manager.js';
|
|
27
|
+
import { createTaskStore } from './core/task/store.js';
|
|
28
|
+
import { registerAuth } from './http/middleware/auth.js';
|
|
29
|
+
import { registerErrorHandling } from './http/middleware/error.js';
|
|
30
|
+
import { registerRequestLogger } from './http/middleware/logger.js';
|
|
31
|
+
import { registerPathGuard } from './http/middleware/pathGuard.js';
|
|
32
|
+
import router from './http/router.js';
|
|
33
|
+
import { APP_VERSION } from '../shared/app-version.js';
|
|
34
|
+
|
|
35
|
+
/** 版本号:与 web 共用 shared/app-version.js 一处定义(避免两处各写一个后漂移) */
|
|
36
|
+
export { APP_VERSION };
|
|
37
|
+
|
|
38
|
+
/** 请求体上限:M1 只有登录表单,1MB 足够;压缩上传在 M2 之后再评估 */
|
|
39
|
+
const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 构造密码校验函数。
|
|
43
|
+
* - 优先 passwordHash(scrypt,推荐);
|
|
44
|
+
* - 允许 password 明文配置(仅本机开发用),但比较时走 sha256 + timingSafeEqual,避免长度侧信道。
|
|
45
|
+
*/
|
|
46
|
+
function createPasswordVerifier(authConfig) {
|
|
47
|
+
if (authConfig.passwordHash) {
|
|
48
|
+
return (plain) => verifyPassword(plain, authConfig.passwordHash);
|
|
49
|
+
}
|
|
50
|
+
if (authConfig.password) {
|
|
51
|
+
const expected = crypto.createHash('sha256').update(String(authConfig.password)).digest();
|
|
52
|
+
return async (plain) => {
|
|
53
|
+
const actual = crypto.createHash('sha256').update(String(plain)).digest();
|
|
54
|
+
return crypto.timingSafeEqual(expected, actual);
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// schema 校验已拦截这种情况,这里只是兜底
|
|
58
|
+
throw new Error('auth.passwordHash 或 auth.password 必须配置其中之一');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createFastifyInstance(config) {
|
|
62
|
+
return Fastify({
|
|
63
|
+
logger: false, // 用我们自己的结构化日志(lib/logger.js),避免双份日志格式
|
|
64
|
+
trustProxy: config.server.trustProxy === true,
|
|
65
|
+
bodyLimit: BODY_LIMIT_BYTES,
|
|
66
|
+
disableRequestLogging: true,
|
|
67
|
+
genReqId: () => crypto.randomUUID(),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 容错 JSON 解析:**空 body 视为无请求体**,而不是 400。
|
|
73
|
+
* Fastify 默认对"声明了 application/json 但 body 为空"的请求直接回 400
|
|
74
|
+
* (FST_ERR_CTP_EMPTY_JSON_BODY),这对 `DELETE /tasks/{id}` 这类天然无 body 的请求很不友好 ——
|
|
75
|
+
* 客户端只要习惯性带上 Content-Type: application/json 就会被拒。
|
|
76
|
+
*/
|
|
77
|
+
function registerTolerantJsonParser(app) {
|
|
78
|
+
app.addContentTypeParser('application/json', { parseAs: 'string' }, (request, body, done) => {
|
|
79
|
+
if (body === undefined || body === null || body === '') {
|
|
80
|
+
done(null, undefined);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
done(null, JSON.parse(body));
|
|
85
|
+
} catch (error) {
|
|
86
|
+
error.statusCode = 400;
|
|
87
|
+
done(error, undefined);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 认证与路径白名单:authStore 先加载;pathGuard 提供 fsContext(后续所有层都依赖它) */
|
|
93
|
+
async function registerSecurityLayer(app, { config, logger }) {
|
|
94
|
+
const authStore = createAuthStore({ config: config.auth, logger, dataDir: config.storage.dataDir });
|
|
95
|
+
await authStore.load();
|
|
96
|
+
app.decorate('authStore', authStore);
|
|
97
|
+
|
|
98
|
+
registerAuth(app, { store: authStore, config: config.auth });
|
|
99
|
+
await registerPathGuard(app, { config, logger });
|
|
100
|
+
return authStore;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* M3 任务层:落盘 store → 事件总线 → 管理器(含启动恢复)。
|
|
105
|
+
* 恢复必须在路由挂载前完成:这样第一个请求看到的就已是 interrupted 终态(INV-10)。
|
|
106
|
+
*/
|
|
107
|
+
async function createTaskLayer({ config, logger }) {
|
|
108
|
+
const store = createTaskStore({
|
|
109
|
+
dataDir: config.storage.dataDir,
|
|
110
|
+
historyMax: config.limits.taskHistoryMax,
|
|
111
|
+
logger,
|
|
112
|
+
});
|
|
113
|
+
await store.init();
|
|
114
|
+
|
|
115
|
+
const events = createTaskEvents({ retention: config.limits.sseRetention, logger });
|
|
116
|
+
const manager = createTaskManager({
|
|
117
|
+
logger,
|
|
118
|
+
store,
|
|
119
|
+
events,
|
|
120
|
+
// 任务活动记录保留策略(failed/interrupted 的上限,来自配置、不写死)
|
|
121
|
+
retention: {
|
|
122
|
+
max: config.limits.taskRetentionMax,
|
|
123
|
+
days: config.limits.taskRetentionDays,
|
|
124
|
+
},
|
|
125
|
+
// waiting_confirm 最长等待(数据模型 §八:默认 30 分钟,0=不限;非法值已在 config 层告警)
|
|
126
|
+
confirmTimeoutMs: config.delete.confirmTimeoutMs,
|
|
127
|
+
// M5-F09 启动对账所需路径(trash 孤儿补记 + deleting 中断收敛)
|
|
128
|
+
trashDir: config.storage.trashDir,
|
|
129
|
+
logDir: config.storage.logDir,
|
|
130
|
+
rootAbs: config.storage.root,
|
|
131
|
+
});
|
|
132
|
+
const recovery = await manager.recover();
|
|
133
|
+
logger.info('任务状态已加载', { ...recovery, bootId: manager.bootId });
|
|
134
|
+
|
|
135
|
+
return { store, events, manager };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** M2 压缩层:格式注册表 → 删除服务 → 编排器(编排器要用 rootAbs,必须在 pathGuard 之后) */
|
|
139
|
+
async function createCompressLayer({ config, logger, projectRoot, manager, blacklist }) {
|
|
140
|
+
const registry = createRegistry({ logger });
|
|
141
|
+
await registry.discover();
|
|
142
|
+
|
|
143
|
+
// 能力注册表(docs/19):扫描能力包 + 与格式包 capabilities 声明对账(PW-4 数据面)
|
|
144
|
+
const capRegistry = createCapabilityRegistry({ logger, formatIds: registry.ids() });
|
|
145
|
+
await capRegistry.rescan();
|
|
146
|
+
for (const failure of capRegistry.loadFailures()) {
|
|
147
|
+
logger.warn('能力插件加载失败(该能力禁用,不影响其他包)', { failure });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const deleteService = createDeleteService({
|
|
151
|
+
config,
|
|
152
|
+
logger,
|
|
153
|
+
logDir: config.storage.logDir,
|
|
154
|
+
trashDir: config.storage.trashDir,
|
|
155
|
+
projectRoot,
|
|
156
|
+
rootAbs: config.storage.root,
|
|
157
|
+
});
|
|
158
|
+
const orchestrator = createOrchestrator({
|
|
159
|
+
config,
|
|
160
|
+
logger,
|
|
161
|
+
registry,
|
|
162
|
+
capRegistry,
|
|
163
|
+
manager,
|
|
164
|
+
deleteService,
|
|
165
|
+
// M6:打包阶段用它解析黑名单层(与预检同一套判定)
|
|
166
|
+
blacklist,
|
|
167
|
+
rootAbs: config.storage.root,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return { registry, capRegistry, deleteService, orchestrator };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 优雅关闭分两步(顺序很关键):
|
|
175
|
+
* - preClose:Fastify 关闭服务器**之前**的钩子,用来结束 SSE 长连接 ——
|
|
176
|
+
* 否则服务器会一直等这些 hijack 的连接,导致优雅停止超时被 KILL;
|
|
177
|
+
* - onClose:服务器关闭后收尾,把节流中的任务状态落盘(避免丢最后 500ms 的进度)。
|
|
178
|
+
*/
|
|
179
|
+
function registerShutdownHooks(app, { sseClients, manager, authStore, metrics }) {
|
|
180
|
+
// 指标定时器必须先停:它们已 unref、但明确 clearInterval 才能保证"停了就是停了"
|
|
181
|
+
app.addHook('onClose', async () => {
|
|
182
|
+
if (metrics) metrics.stop();
|
|
183
|
+
});
|
|
184
|
+
app.addHook('preClose', async () => {
|
|
185
|
+
for (const response of sseClients) {
|
|
186
|
+
try {
|
|
187
|
+
response.end();
|
|
188
|
+
} catch {
|
|
189
|
+
/* 连接可能已断 */
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
sseClients.clear();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
app.addHook('onClose', async () => {
|
|
196
|
+
await manager.flush();
|
|
197
|
+
// 认证存储同样要收尾:刚签发的 token 若还压在 50ms 节流里,不 flush 就会随进程一起丢(重启后客户端突然 401)
|
|
198
|
+
if (authStore && typeof authStore.flush === 'function') await authStore.flush();
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** 静态前端(可选):web/dist 存在时同源提供,浏览器地址即 API 地址,无需代理或 CORS */
|
|
203
|
+
async function registerFrontend(app, { projectRoot, logger }) {
|
|
204
|
+
const distDir = path.join(projectRoot, 'web', 'dist');
|
|
205
|
+
if (await pathExists(distDir)) {
|
|
206
|
+
await app.register(fastifyStatic, { root: distDir, prefix: '/', wildcard: false });
|
|
207
|
+
logger.info('static frontend enabled', { dir: path.relative(projectRoot, distDir) });
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
logger.warn('web/dist 不存在,仅提供 API(如需同源 UI,先执行 npm run build)', {
|
|
211
|
+
dir: path.relative(projectRoot, distDir),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 装配 M2(压缩/解压)与 M3(任务)两层,并把它们挂到 app 上。
|
|
217
|
+
* 单独成函数是为了让 buildApp 只保留"顺序"这一件事(每层内部各有自己的依赖顺序)。
|
|
218
|
+
*/
|
|
219
|
+
async function attachCompressAndTaskLayers(app, { config, logger, projectRoot }) {
|
|
220
|
+
// 黑名单:用户层落盘在 storage/blacklist-user.json(config.json 是启动期只读快照,不适合放可改设置)
|
|
221
|
+
const blacklist = createBlacklistService({ dataDir: config.storage.dataDir, logger });
|
|
222
|
+
await blacklist.loadUser(); // 启动即加载一次用户层(坏文件只告警、不影响启动)
|
|
223
|
+
app.decorate('blacklist', blacklist);
|
|
224
|
+
|
|
225
|
+
// 硬链接去重日志目录(docs/15:storage/logs/hardlink-*.jsonl)
|
|
226
|
+
app.decorate('hardlinkLogDir', config.storage.logDir);
|
|
227
|
+
|
|
228
|
+
const taskLayer = await createTaskLayer({ config, logger });
|
|
229
|
+
const compressLayer = await createCompressLayer({
|
|
230
|
+
config,
|
|
231
|
+
logger,
|
|
232
|
+
projectRoot,
|
|
233
|
+
manager: taskLayer.manager,
|
|
234
|
+
blacklist,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// PW-1:把 capRegistry 的通用脱敏器注入 manager(落盘前剔除 capabilities 里的明文)。
|
|
238
|
+
// compressLayer 在 taskLayer 之后创建,这里补挂(manager 的 ctx 是闭包,走公开方法不可行——
|
|
239
|
+
// 直接改 deps 引用不行,所以 createTaskLayer 接受可选注入参数)。
|
|
240
|
+
taskLayer.manager.__setCapabilitySanitizer(compressLayer.capRegistry.sanitizeOptionsForPersist);
|
|
241
|
+
|
|
242
|
+
app.decorate('m2', {
|
|
243
|
+
registry: compressLayer.registry,
|
|
244
|
+
capRegistry: compressLayer.capRegistry,
|
|
245
|
+
manager: taskLayer.manager,
|
|
246
|
+
orchestrator: compressLayer.orchestrator,
|
|
247
|
+
deleteService: compressLayer.deleteService,
|
|
248
|
+
config,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// M3 的任务三件套(路由与 /health 都从这里取;store/events 不进 API 响应)
|
|
252
|
+
// sseClients:活跃的 SSE 响应集合,关停时统一 end(),否则长连接会让优雅停止超时
|
|
253
|
+
const sseClients = new Set();
|
|
254
|
+
app.decorate('tasks', {
|
|
255
|
+
store: taskLayer.store,
|
|
256
|
+
events: taskLayer.events,
|
|
257
|
+
manager: taskLayer.manager,
|
|
258
|
+
sseClients,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
return { taskLayer, sseClients };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 构建 Fastify 应用(不监听端口,便于测试里直接 inject)。
|
|
266
|
+
* @param {{config: object, logger: object, projectRoot: string}} options
|
|
267
|
+
*/
|
|
268
|
+
export async function buildApp(options) {
|
|
269
|
+
const { config, logger, projectRoot } = options;
|
|
270
|
+
|
|
271
|
+
const app = createFastifyInstance(config);
|
|
272
|
+
await app.register(cookie);
|
|
273
|
+
registerTolerantJsonParser(app);
|
|
274
|
+
|
|
275
|
+
// 顺序有讲究:请求日志 → 错误处理 → 认证 → 路径白名单 → 任务/压缩层 → 路由
|
|
276
|
+
registerRequestLogger(app, { logger });
|
|
277
|
+
registerErrorHandling(app, { logger });
|
|
278
|
+
const authStore = await registerSecurityLayer(app, { config, logger });
|
|
279
|
+
|
|
280
|
+
const { taskLayer, sseClients } = await attachCompressAndTaskLayers(app, { config, logger, projectRoot });
|
|
281
|
+
|
|
282
|
+
// M7 指标采集:两个定时器(系统 5s / 进程 1s),随服务启停
|
|
283
|
+
const metrics = createMetricsCollector({ logger, taskStats: () => taskLayer.manager.stats() });
|
|
284
|
+
metrics.start();
|
|
285
|
+
app.decorate('metrics', metrics);
|
|
286
|
+
|
|
287
|
+
registerShutdownHooks(app, { sseClients, manager: taskLayer.manager, authStore, metrics });
|
|
288
|
+
|
|
289
|
+
const meta = {
|
|
290
|
+
version: APP_VERSION,
|
|
291
|
+
startedAt: Date.now(),
|
|
292
|
+
bootId: taskLayer.manager.bootId,
|
|
293
|
+
rootWritable: true,
|
|
294
|
+
};
|
|
295
|
+
app.decorate('meta', meta);
|
|
296
|
+
|
|
297
|
+
await app.register(router, {
|
|
298
|
+
prefix: '/api/v1',
|
|
299
|
+
config,
|
|
300
|
+
logger,
|
|
301
|
+
store: authStore,
|
|
302
|
+
meta,
|
|
303
|
+
verifyPassword: createPasswordVerifier(config.auth),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
await registerFrontend(app, { projectRoot, logger });
|
|
307
|
+
|
|
308
|
+
return { app, authStore, meta };
|
|
309
|
+
}
|
package/server/boot.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// 启动入口(M1)
|
|
2
|
+
//
|
|
3
|
+
// 职责:加载配置 → 建日志 → 装配应用 → 监听 → 优雅退出。
|
|
4
|
+
// 进程管理(PID 文件、后台运行)交给 scripts/start.sh,这里保持"前台进程"语义,
|
|
5
|
+
// 这样 systemd/supervisor/nohup 都能直接用。
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import { buildApp } from './app.js';
|
|
12
|
+
import { loadConfig } from './config/index.js';
|
|
13
|
+
import { createLogger } from './lib/logger.js';
|
|
14
|
+
import { checkNodeVersion } from './lib/runtime-guard.js';
|
|
15
|
+
|
|
16
|
+
// 项目根 = 本文件所在目录(server/)的上一级;不写死绝对路径
|
|
17
|
+
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
|
|
19
|
+
/** PID 文件路径:由 start.sh 通过环境变量传入(脚本与进程"谁写 PID"只能有一个权威) */
|
|
20
|
+
const PID_FILE = process.env.ZIPCALM_PID_FILE || null;
|
|
21
|
+
|
|
22
|
+
/** 由进程自己写 PID:比启动器用 $! 猜更可靠(shell 里 `&` 作用于复合列表时 $! 会拿到子 shell 的 PID) */
|
|
23
|
+
function writePidFile() {
|
|
24
|
+
if (!PID_FILE) return;
|
|
25
|
+
try {
|
|
26
|
+
// 先确保目录存在:PID 路径可由 ZIPCALM_PID_FILE 指定到任意位置,父目录不存在时 writeFile 会 ENOENT
|
|
27
|
+
fs.mkdirSync(path.dirname(PID_FILE), { recursive: true });
|
|
28
|
+
fs.writeFileSync(PID_FILE, `${process.pid}\n`, 'utf8');
|
|
29
|
+
} catch (error) {
|
|
30
|
+
process.stderr.write(`[ZipCalm] 写入 PID 文件失败:${error.message}\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 退出时清理 PID 文件(只删属于本进程的,避免误删新实例的) */
|
|
35
|
+
function removePidFile() {
|
|
36
|
+
if (!PID_FILE) return;
|
|
37
|
+
try {
|
|
38
|
+
if (fs.existsSync(PID_FILE)) {
|
|
39
|
+
const recorded = fs.readFileSync(PID_FILE, 'utf8').trim();
|
|
40
|
+
if (recorded === String(process.pid)) fs.unlinkSync(PID_FILE);
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// 清理失败不影响退出
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
const env = process.env;
|
|
49
|
+
|
|
50
|
+
// ===== 0. 运行期自检(版本不够就明确拒绝启动,别等用户点压缩才炸)=====
|
|
51
|
+
// 放在配置之前:这是"环境根本不满足"的问题,与配置无关,越早说清越好
|
|
52
|
+
const nodeCheck = checkNodeVersion();
|
|
53
|
+
if (!nodeCheck.ok) {
|
|
54
|
+
process.stderr.write(`[ZipCalm] 运行环境不满足要求,已拒绝启动:\n${nodeCheck.message}\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ===== 1. 配置 =====
|
|
59
|
+
let loaded;
|
|
60
|
+
try {
|
|
61
|
+
loaded = await loadConfig({ projectRoot: PROJECT_ROOT, env });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// 配置错误必须"人话"输出到 stderr 并退出(否则用户只会看到一个堆栈)
|
|
64
|
+
process.stderr.write(`[ZipCalm] 配置错误:${error.message}\n`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
const { config, warnings, sources } = loaded;
|
|
68
|
+
|
|
69
|
+
const logger = createLogger({ level: config.log.level });
|
|
70
|
+
// 自检结果入日志(版本号 + 通过状态),便于事后核"这台机器的运行时到底够不够"
|
|
71
|
+
logger.info('runtime check passed', {
|
|
72
|
+
node: nodeCheck.current,
|
|
73
|
+
requiredNode: nodeCheck.required,
|
|
74
|
+
message: nodeCheck.message,
|
|
75
|
+
});
|
|
76
|
+
for (const warning of warnings) logger.warn('config warning', { message: warning });
|
|
77
|
+
|
|
78
|
+
logger.info('config loaded', {
|
|
79
|
+
sources: sources.length > 0 ? sources : ['defaults'],
|
|
80
|
+
port: config.server.port,
|
|
81
|
+
host: config.server.host,
|
|
82
|
+
storageRoot: config.storage.root,
|
|
83
|
+
logLevel: config.log.level,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ===== 2. 装配 =====
|
|
87
|
+
const { app, authStore, meta } = await buildApp({ config, logger, projectRoot: PROJECT_ROOT });
|
|
88
|
+
const stopCleanup = authStore.startCleanup();
|
|
89
|
+
|
|
90
|
+
// ===== 3. 信号与异常处理器(**必须在 listen 之前注册**)=====
|
|
91
|
+
// 为什么提前:启动期收到 SIGTERM 时若还没有处理器,Node 走默认行为**直接退出且不留任何日志**,
|
|
92
|
+
// 表现为"日志停在半路、进程消失",排查时看不出是谁杀的(实测踩过)。
|
|
93
|
+
let listening = false;
|
|
94
|
+
let shuttingDown = false;
|
|
95
|
+
const shutdown = async (reason) => {
|
|
96
|
+
if (shuttingDown) return;
|
|
97
|
+
shuttingDown = true;
|
|
98
|
+
if (listening) {
|
|
99
|
+
logger.info('shutting down', { reason });
|
|
100
|
+
} else {
|
|
101
|
+
// 启动未完成就收到退出信号:单独一条,便于区分"被外部杀掉"和"启动失败"
|
|
102
|
+
logger.warn('shutting down during startup', { reason, phase: 'before-listen' });
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
await app.close();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
logger.error('close failed', { error: error.message });
|
|
108
|
+
}
|
|
109
|
+
stopCleanup();
|
|
110
|
+
removePidFile();
|
|
111
|
+
process.exit(0);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
115
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
116
|
+
|
|
117
|
+
// 兜底:未捕获异常不能"静默把服务带走"(先记日志再优雅退出,让 start.sh 能重启)
|
|
118
|
+
process.on('unhandledRejection', (reason) => {
|
|
119
|
+
logger.error('unhandledRejection', { reason: reason instanceof Error ? reason.message : String(reason) });
|
|
120
|
+
});
|
|
121
|
+
process.on('uncaughtException', (error) => {
|
|
122
|
+
logger.error('uncaughtException', {
|
|
123
|
+
error: error.message,
|
|
124
|
+
stack: String(error.stack || '').split('\n').slice(0, 4).join(' | '),
|
|
125
|
+
});
|
|
126
|
+
shutdown('uncaughtException');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ===== 4. 监听 =====
|
|
130
|
+
try {
|
|
131
|
+
await app.listen({ host: config.server.host, port: config.server.port });
|
|
132
|
+
} catch (error) {
|
|
133
|
+
logger.error('listen failed', { error: error.message, port: config.server.port, host: config.server.host });
|
|
134
|
+
process.stderr.write(`[ZipCalm] 端口监听失败:${error.message}\n`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
listening = true;
|
|
138
|
+
|
|
139
|
+
logger.info('ZipCalm server ready', { port: config.server.port, bootId: meta.bootId });
|
|
140
|
+
|
|
141
|
+
// 监听成功后再写 PID 文件:这样"PID 文件存在"就等于"服务已就绪"
|
|
142
|
+
writePidFile();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
main();
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// 默认配置(docs/M0/数据模型.md §八 的 M1 子集)
|
|
2
|
+
//
|
|
3
|
+
// 只放 M1 真正用到的段:server / auth / storage / limits / log。
|
|
4
|
+
// compress(M2)、blacklist(M6)、metrics(M7)等到各自里程碑再加,避免出现"配了但没人读"的死配置。
|
|
5
|
+
//
|
|
6
|
+
// 覆盖优先级(低 → 高):本文件默认值 → <dataDir>/config.json → 环境变量。
|
|
7
|
+
|
|
8
|
+
/** POSIX 单段文件名长度上限(NAME_MAX 的常见值):给 maxPathSegmentLength 当默认值用 */
|
|
9
|
+
const POSIX_NAME_MAX = 255;
|
|
10
|
+
/** 单次列目录返回的默认最大条目数(超出截断并置 truncated=true) */
|
|
11
|
+
const DEFAULT_MAX_LIST_ENTRIES = 5000;
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_CONFIG = {
|
|
14
|
+
server: {
|
|
15
|
+
/** 监听地址:0.0.0.0 表示所有网卡(NAS 场景方便局域网访问) */
|
|
16
|
+
host: '0.0.0.0',
|
|
17
|
+
port: 3010,
|
|
18
|
+
trustProxy: false,
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
auth: {
|
|
22
|
+
username: 'admin',
|
|
23
|
+
/** 二选一:passwordHash 优先(推荐);都为空则启动失败并提示生成方式 */
|
|
24
|
+
password: null,
|
|
25
|
+
passwordHash: null,
|
|
26
|
+
/** Session(HttpOnly Cookie)有效期:7 天滑动续期(API 规范 §二) */
|
|
27
|
+
sessionTtlMs: 7 * 24 * 60 * 60 * 1000,
|
|
28
|
+
/** Bearer Token 有效期:30 天(API 规范 §二) */
|
|
29
|
+
tokenTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
30
|
+
/** 登录失败限流 */
|
|
31
|
+
loginRateLimit: { max: 5, windowMs: 60 * 1000 },
|
|
32
|
+
/** Cookie 名(API 规范 §二:zc_session) */
|
|
33
|
+
cookieName: 'zc_session',
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
storage: {
|
|
37
|
+
/** 可浏览/可压缩的默认存储根:单根回退,也是侧栏里的「应用数据」项 */
|
|
38
|
+
root: './storage/fs-root',
|
|
39
|
+
/**
|
|
40
|
+
* 可切换的根列表(docs/16 §三):[{ id, label, path }]。
|
|
41
|
+
* **空数组 = 自动发现**(群晖列 /volumeN 下的共享;其他系统列卷/挂载点)。
|
|
42
|
+
* 显式配置优先于自动发现;相对 path 与 root 同规则按项目根解析。
|
|
43
|
+
*/
|
|
44
|
+
roots: [],
|
|
45
|
+
/** 运行态数据目录(任务状态、令牌、日志、回收站都在这里;已 gitignore) */
|
|
46
|
+
dataDir: './storage',
|
|
47
|
+
trashDir: './storage/trash',
|
|
48
|
+
logDir: './storage/logs',
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
compress: {
|
|
52
|
+
/** 默认压缩格式(M2 只有 zip) */
|
|
53
|
+
defaultFormat: 'zip',
|
|
54
|
+
/** 默认压缩模式:single(每个源各一个包)/ bundle(合成一个包) */
|
|
55
|
+
defaultMode: 'single',
|
|
56
|
+
/** 默认保留原文件(删除必须显式请求 + 二次确认,docs/06 §一) */
|
|
57
|
+
defaultKeepOriginal: true,
|
|
58
|
+
/** zip 默认压缩级别 */
|
|
59
|
+
defaultLevel: 6,
|
|
60
|
+
/** 并行开关(占位:v1 强制串行,docs/05 §二) */
|
|
61
|
+
parallelEnabled: false,
|
|
62
|
+
/** INV-07:剩余空间必须 ≥ 待压总量 × preflightFactor(不允许 < 2) */
|
|
63
|
+
preflightFactor: 2,
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
delete: {
|
|
67
|
+
/**
|
|
68
|
+
* 删除执行模式(docs/M0 数据模型 §八):
|
|
69
|
+
* - log-only(默认):只写删除日志、**不真删** —— 开发期核对"校验通过 → 触发删除"的时机;
|
|
70
|
+
* - trash:真删并进 storage/trash/<batchId>/(可恢复),正式部署用;
|
|
71
|
+
* 未知值 → 告警并回落 log-only(安全默认:绝不因配置写错而真删数据)。
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* 回收站后端(mode=trash 时生效):
|
|
75
|
+
* - auto(默认):探测到群晖 `/usr/syno/bin/synorecycle` 就用系统回收站(删除进 DSM 的 #recycle,
|
|
76
|
+
* 用户可在 File Station 里恢复/清空),否则用项目 storage/trash;
|
|
77
|
+
* - system:强制系统回收站,不可用/失败时**回退项目 trash**(不报错、绝不真删);
|
|
78
|
+
* - project:强制项目 storage/trash;
|
|
79
|
+
* - none:不启用回收站 —— 删除会被拒绝并记 failed(绝不退化成真删)。
|
|
80
|
+
*/
|
|
81
|
+
mode: 'log-only',
|
|
82
|
+
trashBackend: 'auto',
|
|
83
|
+
/**
|
|
84
|
+
* waiting_confirm 最长等待(数据模型 §八 / §3.3 硬要求 1):
|
|
85
|
+
* 默认 30 分钟;0 = 不超时;非法值(负数/非数字)→ 告警并回落默认。
|
|
86
|
+
* 超时按放弃删除结算(delete.status='skipped',reason=confirm_timeout)——超时绝不等于确认。
|
|
87
|
+
*/
|
|
88
|
+
confirmTimeoutMs: 1800000,
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
limits: {
|
|
92
|
+
/** 路径字符串长度上限(整条路径,超长直接判非法路径) */
|
|
93
|
+
maxPathLength: 4096,
|
|
94
|
+
/**
|
|
95
|
+
* 单个路径段的长度上限(对应 POSIX NAME_MAX)。
|
|
96
|
+
* 为什么不写死 255:不同文件系统(ext4/btrfs/APFS/NTFS、以及各种 FUSE/网络盘)上限不同,
|
|
97
|
+
* 这里是**可配置**的:默认取 POSIX 常见值 255,部署到特殊文件系统时可调。
|
|
98
|
+
* 超过即在进入磁盘调用前返回 INVALID_PATH(400),不让底层 ENAMETOOLONG 冒成 500。
|
|
99
|
+
*/
|
|
100
|
+
maxPathSegmentLength: POSIX_NAME_MAX,
|
|
101
|
+
/** 单次列目录返回的最大条目数(超出截断并置 truncated=true) */
|
|
102
|
+
maxListEntries: DEFAULT_MAX_LIST_ENTRIES,
|
|
103
|
+
/** 单次压缩任务最多源条目数 */
|
|
104
|
+
maxSourcesPerTask: 200,
|
|
105
|
+
/**
|
|
106
|
+
* 单次「移动」最多条目数(API 规范 §4.2:超限 VALIDATION_FAILED)。
|
|
107
|
+
* 为什么要有上限:v1 是同步操作,一次几百个 rename 会让请求超时;超过应分批或改用复制任务。
|
|
108
|
+
*/
|
|
109
|
+
maxMoveItems: 200,
|
|
110
|
+
/** 单次「删除」最多条目数(同上,删除还要逐条走回收站,代价更高) */
|
|
111
|
+
maxDeleteItems: 200,
|
|
112
|
+
/** 前端默认分页大小 */
|
|
113
|
+
defaultPageSize: 200,
|
|
114
|
+
/**
|
|
115
|
+
* 任务历史保留条数(task-history.jsonl 的裁剪上限,数据模型 §六)
|
|
116
|
+
*/
|
|
117
|
+
taskHistoryMax: 200,
|
|
118
|
+
/** SSE 环形缓冲保留的事件条数(`since` 超出窗口即改用快照兜底,API 规范 §五 规则 2) */
|
|
119
|
+
sseRetention: 1000,
|
|
120
|
+
/** SSE 心跳间隔(防中间设备把长连接当空闲连接掐掉) */
|
|
121
|
+
sseHeartbeatMs: 15000,
|
|
122
|
+
/**
|
|
123
|
+
* 任务活动记录保留条数(`failed`/`interrupted` 这两类"为可重试而保留"的终态上限,
|
|
124
|
+
* 数据模型 §六 规则 6):超出即清理最旧的,避免 tasks.json 全量重写越来越慢。
|
|
125
|
+
* 上限走配置、不写死;`done`/`aborted` 本来就即写即移出,不参与该计数。
|
|
126
|
+
*/
|
|
127
|
+
taskRetentionMax: 100,
|
|
128
|
+
/** 任务活动记录保留天数(与条数任一先到即清理) */
|
|
129
|
+
taskRetentionDays: 7,
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
log: {
|
|
133
|
+
level: 'info',
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** 环境变量覆盖白名单(只允许"单值"型部署差异,结构性配置一律走 config.json) */
|
|
138
|
+
export const ENV_OVERRIDES = {
|
|
139
|
+
ZIPCALM_HOST: ['server', 'host'],
|
|
140
|
+
ZIPCALM_PORT: ['server', 'port'],
|
|
141
|
+
ZIPCALM_STORAGE_ROOT: ['storage', 'root'],
|
|
142
|
+
ZIPCALM_DATA_DIR: ['storage', 'dataDir'],
|
|
143
|
+
// CLI(npx 安装形态)需要把回收站与日志也指到用户数据目录,否则它们会落在包目录里
|
|
144
|
+
ZIPCALM_TRASH_DIR: ['storage', 'trashDir'],
|
|
145
|
+
ZIPCALM_LOG_DIR: ['storage', 'logDir'],
|
|
146
|
+
ZIPCALM_LOG_LEVEL: ['log', 'level'],
|
|
147
|
+
ZIPCALM_USERNAME: ['auth', 'username'],
|
|
148
|
+
ZIPCALM_PASSWORD: ['auth', 'password'],
|
|
149
|
+
ZIPCALM_PASSWORD_HASH: ['auth', 'passwordHash'],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** 敏感字段:任何日志/响应都不允许带明文(INV-16) */
|
|
153
|
+
export const SENSITIVE_CONFIG_KEYS = ['password', 'passwordHash'];
|