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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// 配置加载与校验(结构照 docs/M0/数据模型.md §八)
|
|
2
|
+
//
|
|
3
|
+
// 优先级(低 → 高):
|
|
4
|
+
// 1. server/config/defaults.js 内置默认值
|
|
5
|
+
// 2. <项目根>/config.json (可选,用于引导 dataDir 等)
|
|
6
|
+
// 3. <dataDir>/config.json (运行期覆盖项;storage/ 已 gitignore,凭据不进库)
|
|
7
|
+
// 4. 环境变量白名单(ZIPCALM_*,只允许单值型部署差异)
|
|
8
|
+
//
|
|
9
|
+
// 为什么运行期配置放 <dataDir>/config.json:那里已被 .gitignore 忽略,
|
|
10
|
+
// 密码哈希/端口这类部署差异不会误提交(INV-16 凭据不入库)。
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { DEFAULT_CONFIG, ENV_OVERRIDES } from './defaults.js';
|
|
16
|
+
import { validateConfig } from './schema.js';
|
|
17
|
+
import { checkAccess, ensureDir, readJson } from '../lib/atomic-json.js';
|
|
18
|
+
|
|
19
|
+
/** 可深合并的普通对象(数组/标量/null 都不算) */
|
|
20
|
+
function isPlainObject(value) {
|
|
21
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 深合并(只合并普通对象;数组/标量直接覆盖) */
|
|
25
|
+
function deepMerge(base, patch) {
|
|
26
|
+
if (!isPlainObject(patch)) return base;
|
|
27
|
+
const out = { ...base };
|
|
28
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
29
|
+
out[key] = isPlainObject(value) && isPlainObject(out[key]) ? deepMerge(out[key], value) : value;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 按 ENV_OVERRIDES 把环境变量写进配置(数字型字段做类型转换,非法值记 warning 并忽略) */
|
|
35
|
+
function applyEnv(config, env) {
|
|
36
|
+
const applied = [];
|
|
37
|
+
const warnings = [];
|
|
38
|
+
for (const [envKey, configPath] of Object.entries(ENV_OVERRIDES)) {
|
|
39
|
+
const raw = env[envKey];
|
|
40
|
+
if (raw === undefined || raw === '') continue;
|
|
41
|
+
const [section, field] = configPath;
|
|
42
|
+
let resolvedValue = raw;
|
|
43
|
+
if (field === 'port') {
|
|
44
|
+
const parsed = Number.parseInt(raw, 10);
|
|
45
|
+
if (!Number.isInteger(parsed)) {
|
|
46
|
+
warnings.push(`环境变量 ${envKey} 不是整数,已忽略`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
resolvedValue = parsed;
|
|
50
|
+
}
|
|
51
|
+
config[section][field] = resolvedValue;
|
|
52
|
+
applied.push(`${envKey} → ${configPath.join('.')}`);
|
|
53
|
+
}
|
|
54
|
+
return { applied, warnings };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 加载并校验配置。
|
|
59
|
+
* @param {{projectRoot?: string, env?: Record<string,string>}} options
|
|
60
|
+
* @returns {Promise<{config: object, projectRoot: string, warnings: string[], sources: string[]}>}
|
|
61
|
+
*/
|
|
62
|
+
export async function loadConfig(options = {}) {
|
|
63
|
+
const projectRoot = path.resolve(options.projectRoot || process.cwd());
|
|
64
|
+
const env = options.env || process.env;
|
|
65
|
+
const warnings = [];
|
|
66
|
+
const sources = [];
|
|
67
|
+
|
|
68
|
+
// 1) 内置默认值
|
|
69
|
+
let config = deepMerge({}, DEFAULT_CONFIG);
|
|
70
|
+
|
|
71
|
+
// 2) 项目根 config.json(可选引导文件)
|
|
72
|
+
const rootConfigPath = path.join(projectRoot, 'config.json');
|
|
73
|
+
const rootConfig = await readJson(rootConfigPath, null);
|
|
74
|
+
if (rootConfig.ok && rootConfig.data) {
|
|
75
|
+
config = deepMerge(config, rootConfig.data);
|
|
76
|
+
sources.push('config.json');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 3) <dataDir>/config.json(dataDir 可能被上面引导文件改过,所以放在后面读)
|
|
80
|
+
// 注意:**必须先把 ZIPCALM_DATA_DIR 考虑进来** —— 否则 `ZIPCALM_DATA_DIR` 指到别处时,
|
|
81
|
+
// 这里仍在项目根下找 config.json,用户数据目录里那份永远读不到(npm 包形态就是这种情况:
|
|
82
|
+
// 包目录里没有 config.json,配置在 ~/.zipcalm/config.json)。
|
|
83
|
+
const envDataDir = typeof env.ZIPCALM_DATA_DIR === 'string' && env.ZIPCALM_DATA_DIR.length > 0
|
|
84
|
+
? env.ZIPCALM_DATA_DIR
|
|
85
|
+
: null;
|
|
86
|
+
const dataDir = path.resolve(projectRoot, envDataDir || config.storage.dataDir);
|
|
87
|
+
const runtimeConfigPath = path.join(dataDir, 'config.json');
|
|
88
|
+
const runtimeConfig = await readJson(runtimeConfigPath, null);
|
|
89
|
+
if (runtimeConfig.ok && runtimeConfig.data) {
|
|
90
|
+
config = deepMerge(config, runtimeConfig.data);
|
|
91
|
+
sources.push(path.relative(projectRoot, runtimeConfigPath));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 4) 环境变量(部署差异)
|
|
95
|
+
const envResult = applyEnv(config, env);
|
|
96
|
+
warnings.push(...envResult.warnings);
|
|
97
|
+
if (envResult.applied.length > 0) sources.push(`env(${envResult.applied.length} 项)`);
|
|
98
|
+
|
|
99
|
+
// ===== 校验 =====
|
|
100
|
+
const { errors, warnings: schemaWarnings } = validateConfig(config);
|
|
101
|
+
warnings.push(...schemaWarnings);
|
|
102
|
+
if (errors.length > 0) {
|
|
103
|
+
const error = new Error(`配置校验未通过:\n${errors.map((item) => ` - ${item}`).join('\n')}`);
|
|
104
|
+
error.name = 'ConfigError';
|
|
105
|
+
error.errors = errors;
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ===== 路径绝对化 =====
|
|
110
|
+
// 基准目录(项目根)只在一处决定:所有 storage 子路径都经它换算,避免四处各写一次 path.resolve
|
|
111
|
+
const resolveUnderProject = (value) => path.resolve(projectRoot, value);
|
|
112
|
+
const resolved = {
|
|
113
|
+
...config,
|
|
114
|
+
storage: {
|
|
115
|
+
...config.storage,
|
|
116
|
+
root: resolveUnderProject(config.storage.root),
|
|
117
|
+
// 每个可切换根也绝对化;**不 ensureDir**(它们可能是 NAS 卷,应用无权/不该创建)
|
|
118
|
+
roots: (config.storage.roots || []).map((root) => ({ ...root, path: resolveUnderProject(root.path) })),
|
|
119
|
+
dataDir: resolveUnderProject(config.storage.dataDir),
|
|
120
|
+
trashDir: resolveUnderProject(config.storage.trashDir),
|
|
121
|
+
logDir: resolveUnderProject(config.storage.logDir),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ===== 目录准备与可访问性校验(给出明确原因,而不是启动后才发现)=====
|
|
126
|
+
await ensureDir(resolved.storage.dataDir);
|
|
127
|
+
await ensureDir(resolved.storage.logDir);
|
|
128
|
+
await ensureDir(resolved.storage.trashDir);
|
|
129
|
+
await ensureDir(resolved.storage.root);
|
|
130
|
+
|
|
131
|
+
const rootReadWrite = await checkAccess(resolved.storage.root, fs.constants.R_OK | fs.constants.W_OK);
|
|
132
|
+
if (!rootReadWrite.ok) {
|
|
133
|
+
const error = new Error(`存储根不可读写:${resolved.storage.root}(${rootReadWrite.reason})`);
|
|
134
|
+
error.name = 'ConfigError';
|
|
135
|
+
error.errors = ['storage.root 需要可读且可写'];
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { config: resolved, projectRoot, warnings, sources };
|
|
140
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// 配置校验(零依赖,手写规则;结构照 docs/M0/数据模型.md §八)
|
|
2
|
+
//
|
|
3
|
+
// 校验分两档:
|
|
4
|
+
// - errors:必须修,否则启动失败(类型错、端口越界、连密码都没配…)
|
|
5
|
+
// - warnings:可疑但不致命(未知字段、删除模式回落…),只提示
|
|
6
|
+
//
|
|
7
|
+
// 文件系统相关的检查(root 存在且可读写)在 config/index.js 里做异步校验,
|
|
8
|
+
// 因为本文件要保持纯函数、便于单测。
|
|
9
|
+
//
|
|
10
|
+
// 结构说明:**每个配置区块一个校验函数**(server / auth / storage / limits / compress / delete / log),
|
|
11
|
+
// 主函数 validateConfig 只负责按序调用并汇总结果 —— 这样单块规则好读、好测,也不会堆成一个巨型 if。
|
|
12
|
+
|
|
13
|
+
const LOG_LEVELS = ['debug', 'info', 'warn', 'error'];
|
|
14
|
+
|
|
15
|
+
/** 本版本认识的配置段(用于"未知段"告警;新增区块时同步这里) */
|
|
16
|
+
const KNOWN_SECTIONS = ['server', 'auth', 'storage', 'limits', 'compress', 'delete', 'log'];
|
|
17
|
+
|
|
18
|
+
const SEGMENT_LENGTH_MIN = 16;
|
|
19
|
+
const SEGMENT_LENGTH_MAX = 4096;
|
|
20
|
+
|
|
21
|
+
/** 普通对象判断(数组/null 都不算) */
|
|
22
|
+
function isPlainObject(value) {
|
|
23
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 区块存在性检查:不是对象就记错误(后续细项校验直接跳过,避免刷屏与 TypeError)。
|
|
28
|
+
* @returns {boolean} 该区块是否可用于细项校验
|
|
29
|
+
*/
|
|
30
|
+
function requireSection(config, section, errors) {
|
|
31
|
+
if (!isPlainObject(config[section])) {
|
|
32
|
+
errors.push(`配置段 ${section} 必须是对象`);
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ===== 各区块校验 =====
|
|
39
|
+
|
|
40
|
+
function validateServer(config, errors) {
|
|
41
|
+
if (!requireSection(config, 'server', errors)) return;
|
|
42
|
+
const { host, port } = config.server;
|
|
43
|
+
if (typeof host !== 'string' || host.length === 0) errors.push('server.host 必须是非空字符串');
|
|
44
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) errors.push('server.port 必须是 1~65535 的整数');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 账号与口令相关校验(用户名 / passwordHash 或 password / 哈希格式) */
|
|
48
|
+
function validateAuthCredentials(auth, errors, warnings) {
|
|
49
|
+
const { username, password, passwordHash } = auth;
|
|
50
|
+
if (typeof username !== 'string' || username.length === 0) errors.push('auth.username 必须是非空字符串');
|
|
51
|
+
if (!password && !passwordHash) errors.push('auth 必须配置 passwordHash(推荐)或 password 其中之一');
|
|
52
|
+
if (passwordHash && !/^scrypt\$/.test(String(passwordHash))) {
|
|
53
|
+
warnings.push('auth.passwordHash 不是本项目的 scrypt$ 格式,可能无法通过校验(请用 scripts/hash-password.js 生成)');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 会话/令牌与登录限流相关校验 */
|
|
58
|
+
function validateAuthTokens(auth, errors) {
|
|
59
|
+
const { sessionTtlMs, tokenTtlMs, cookieName, loginRateLimit } = auth;
|
|
60
|
+
if (!Number.isInteger(sessionTtlMs) || sessionTtlMs <= 0) errors.push('auth.sessionTtlMs 必须是正整数');
|
|
61
|
+
if (!Number.isInteger(tokenTtlMs) || tokenTtlMs <= 0) errors.push('auth.tokenTtlMs 必须是正整数');
|
|
62
|
+
if (typeof cookieName !== 'string' || !/^[A-Za-z0-9_-]+$/.test(cookieName)) {
|
|
63
|
+
errors.push('auth.cookieName 只能是字母数字下划线连字符');
|
|
64
|
+
}
|
|
65
|
+
if (!isPlainObject(loginRateLimit) || !Number.isInteger(loginRateLimit.max) || !Number.isInteger(loginRateLimit.windowMs)) {
|
|
66
|
+
errors.push('auth.loginRateLimit 需要 { max, windowMs } 两个整数');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateAuth(config, errors, warnings) {
|
|
71
|
+
if (!requireSection(config, 'auth', errors)) return;
|
|
72
|
+
validateAuthCredentials(config.auth, errors, warnings);
|
|
73
|
+
validateAuthTokens(config.auth, errors);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function validateStorage(config, errors) {
|
|
77
|
+
if (!requireSection(config, 'storage', errors)) return;
|
|
78
|
+
const { root, dataDir, trashDir, logDir } = config.storage;
|
|
79
|
+
|
|
80
|
+
for (const [key, value] of Object.entries({ root, dataDir, trashDir, logDir })) {
|
|
81
|
+
if (typeof value !== 'string' || value.length === 0) errors.push(`storage.${key} 必须是非空字符串`);
|
|
82
|
+
}
|
|
83
|
+
// 防呆:存储根不允许直接指向文件系统根,否则白名单形同虚设(INV-06)
|
|
84
|
+
if (typeof root === 'string' && (root === '/' || /^[A-Za-z]:[\\/]?$/.test(root))) {
|
|
85
|
+
errors.push('storage.root 不允许指向文件系统根(会让路径白名单失去意义)');
|
|
86
|
+
}
|
|
87
|
+
validateRoots(config.storage.roots, errors);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 可切换根列表:结构 + id 唯一 + 不许指向文件系统根(同一个 INV-06 防呆) */
|
|
91
|
+
function validateRoots(roots, errors) {
|
|
92
|
+
if (roots === undefined || roots === null) return;
|
|
93
|
+
if (!Array.isArray(roots)) {
|
|
94
|
+
errors.push('storage.roots 必须是数组([{ id, label, path }]),空数组表示自动发现');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
for (const [index, entry] of roots.entries()) {
|
|
99
|
+
if (!entry || typeof entry !== 'object') {
|
|
100
|
+
errors.push(`storage.roots[${index}] 必须是对象 { id, label, path }`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
validateRootEntry(entry, index, seen, errors);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 单条根校验(拆出来是为了把 validateRoots 的圈复杂度压在阈值内) */
|
|
108
|
+
function validateRootEntry(entry, index, seen, errors) {
|
|
109
|
+
for (const key of ['id', 'path']) {
|
|
110
|
+
if (typeof entry[key] !== 'string' || entry[key].length === 0) {
|
|
111
|
+
errors.push(`storage.roots[${index}].${key} 必须是非空字符串`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (entry.label !== undefined && typeof entry.label !== 'string') {
|
|
115
|
+
errors.push(`storage.roots[${index}].label 必须是字符串`);
|
|
116
|
+
}
|
|
117
|
+
if (typeof entry.id === 'string') {
|
|
118
|
+
if (seen.has(entry.id)) errors.push(`storage.roots 里 id 重复:${entry.id}`);
|
|
119
|
+
seen.add(entry.id);
|
|
120
|
+
}
|
|
121
|
+
if (entry.path === '/' || /^[A-Za-z]:[\\/]?$/.test(String(entry.path))) {
|
|
122
|
+
errors.push(`storage.roots[${index}].path 不允许指向文件系统根`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 路径长度相关(含两者之间的相对关系) */
|
|
127
|
+
/** 批量操作上限(移动/删除):单独成函数,避免把路径校验的复杂度推到阈值外 */
|
|
128
|
+
function validateBulkLimits(limits, errors) {
|
|
129
|
+
for (const key of ['maxMoveItems', 'maxDeleteItems']) {
|
|
130
|
+
if (limits[key] !== undefined && (!Number.isInteger(limits[key]) || limits[key] < 1)) {
|
|
131
|
+
errors.push(`limits.${key} 必须是 ≥1 的整数`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validatePathLimits(limits, errors) {
|
|
137
|
+
const { maxPathLength, maxPathSegmentLength } = limits;
|
|
138
|
+
validateBulkLimits(limits, errors);
|
|
139
|
+
if (!Number.isInteger(maxPathLength) || maxPathLength < 64) errors.push('limits.maxPathLength 必须是 ≥64 的整数');
|
|
140
|
+
if (
|
|
141
|
+
!Number.isInteger(maxPathSegmentLength)
|
|
142
|
+
|| maxPathSegmentLength < SEGMENT_LENGTH_MIN
|
|
143
|
+
|| maxPathSegmentLength > SEGMENT_LENGTH_MAX
|
|
144
|
+
) {
|
|
145
|
+
errors.push('limits.maxPathSegmentLength 必须是 16~4096 的整数(单段长度上限,POSIX 常见值 255)');
|
|
146
|
+
}
|
|
147
|
+
if (Number.isInteger(maxPathSegmentLength) && Number.isInteger(maxPathLength) && maxPathSegmentLength > maxPathLength) {
|
|
148
|
+
errors.push('limits.maxPathSegmentLength 不能大于 limits.maxPathLength');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** 列表与分页相关 */
|
|
153
|
+
function validateListLimits(limits, errors) {
|
|
154
|
+
const { maxListEntries, defaultPageSize } = limits;
|
|
155
|
+
if (!Number.isInteger(maxListEntries) || maxListEntries < 1) errors.push('limits.maxListEntries 必须是正整数');
|
|
156
|
+
if (!Number.isInteger(defaultPageSize) || defaultPageSize < 1) errors.push('limits.defaultPageSize 必须是正整数');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 任务活动记录保留策略 */
|
|
160
|
+
function validateTaskRetentionLimits(limits, errors) {
|
|
161
|
+
const { taskRetentionMax, taskRetentionDays } = limits;
|
|
162
|
+
if (!Number.isInteger(taskRetentionMax) || taskRetentionMax < 1) {
|
|
163
|
+
errors.push('limits.taskRetentionMax 必须是 ≥1 的整数');
|
|
164
|
+
}
|
|
165
|
+
if (typeof taskRetentionDays !== 'number' || Number.isNaN(taskRetentionDays) || taskRetentionDays <= 0) {
|
|
166
|
+
errors.push('limits.taskRetentionDays 必须是正数(天,可小数)');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateLimits(config, errors) {
|
|
171
|
+
if (!requireSection(config, 'limits', errors)) return;
|
|
172
|
+
const limits = config.limits;
|
|
173
|
+
validatePathLimits(limits, errors);
|
|
174
|
+
validateListLimits(limits, errors);
|
|
175
|
+
validateTaskRetentionLimits(limits, errors);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validateCompress(config, errors) {
|
|
179
|
+
if (!requireSection(config, 'compress', errors)) return;
|
|
180
|
+
const { defaultFormat, defaultMode, defaultLevel, preflightFactor } = config.compress;
|
|
181
|
+
|
|
182
|
+
if (typeof defaultFormat !== 'string' || defaultFormat.length === 0) errors.push('compress.defaultFormat 必须是非空字符串');
|
|
183
|
+
if (!['single', 'bundle'].includes(defaultMode)) errors.push('compress.defaultMode 只能是 single 或 bundle');
|
|
184
|
+
if (!Number.isInteger(defaultLevel) || defaultLevel < 0 || defaultLevel > 19) errors.push('compress.defaultLevel 必须是 0~19 的整数');
|
|
185
|
+
// INV-07:2 倍是底线,配置不允许把它调小
|
|
186
|
+
if (!Number.isInteger(preflightFactor) || preflightFactor < 2) {
|
|
187
|
+
errors.push('compress.preflightFactor 必须是 ≥2 的整数(INV-07:剩余空间 ≥ 待压 × 2)');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function validateDelete(config, warnings) {
|
|
192
|
+
if (!requireSection(config, 'delete', [])) return;
|
|
193
|
+
const { mode, confirmTimeoutMs } = config.delete;
|
|
194
|
+
// 契约:未知值只告警并回落 log-only(回落动作由删除服务执行),这里不拦
|
|
195
|
+
if (!['log-only', 'trash'].includes(mode)) {
|
|
196
|
+
warnings.push(`delete.mode 只能是 log-only 或 trash,当前 "${String(mode)}" 将回落为 log-only`);
|
|
197
|
+
}
|
|
198
|
+
// 回收站后端:非法值只告警并回落 auto(回落动作由删除服务执行)
|
|
199
|
+
const { trashBackend } = config.delete;
|
|
200
|
+
if (trashBackend !== undefined && !['auto', 'system', 'project', 'none'].includes(trashBackend)) {
|
|
201
|
+
warnings.push(`delete.trashBackend 只能是 auto / system / project / none,当前 "${String(trashBackend)}" 将回落为 auto`);
|
|
202
|
+
}
|
|
203
|
+
// 契约 §八:confirmTimeoutMs 非法值 → 告警并回落默认(回落动作由消费方执行),这里不拦
|
|
204
|
+
if (confirmTimeoutMs !== undefined && (typeof confirmTimeoutMs !== 'number' || !Number.isInteger(confirmTimeoutMs) || confirmTimeoutMs < 0)) {
|
|
205
|
+
warnings.push(`delete.confirmTimeoutMs 必须是非负整数毫秒,当前 "${String(confirmTimeoutMs)}" 将回落为默认值 1800000`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function validateLog(config, errors) {
|
|
210
|
+
if (!requireSection(config, 'log', errors)) return;
|
|
211
|
+
if (!LOG_LEVELS.includes(config.log.level)) {
|
|
212
|
+
errors.push(`log.level 必须是 ${LOG_LEVELS.join(' / ')} 之一`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 未知段只警告不拦截(向前兼容:以后新增区块时不至于启动失败) */
|
|
217
|
+
function warnUnknownSections(config, warnings) {
|
|
218
|
+
for (const key of Object.keys(config)) {
|
|
219
|
+
if (!KNOWN_SECTIONS.includes(key)) {
|
|
220
|
+
warnings.push(`配置里存在未知段 ${key}(当前版本未使用,可能是以后里程碑的配置)`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 校验配置。
|
|
227
|
+
* @param {object} config 待校验配置(已合并默认值/文件/env)
|
|
228
|
+
* @returns {{errors: string[], warnings: string[]}}
|
|
229
|
+
*/
|
|
230
|
+
export function validateConfig(config) {
|
|
231
|
+
const errors = [];
|
|
232
|
+
const warnings = [];
|
|
233
|
+
|
|
234
|
+
validateServer(config, errors);
|
|
235
|
+
validateAuth(config, errors, warnings);
|
|
236
|
+
validateStorage(config, errors);
|
|
237
|
+
validateLimits(config, errors);
|
|
238
|
+
validateCompress(config, errors);
|
|
239
|
+
validateDelete(config, warnings);
|
|
240
|
+
validateLog(config, errors);
|
|
241
|
+
warnUnknownSections(config, warnings);
|
|
242
|
+
|
|
243
|
+
return { errors, warnings };
|
|
244
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// 密码哈希(scrypt,Node 内置,零依赖)
|
|
2
|
+
//
|
|
3
|
+
// 为什么不用明文比较、也不引 bcrypt:NAS 上不想再引原生编译依赖(项目原则:优先成熟库,但
|
|
4
|
+
// 这里的"成熟库"bcrypt/argon2 都要原生编译,在 noexec 挂载等受限环境下风险高)。
|
|
5
|
+
// Node 内置 scrypt 属标准方案,格式自带参数,便于将来升级参数而不破坏老哈希。
|
|
6
|
+
//
|
|
7
|
+
// 存储格式:scrypt$N$r$p$<salt-base64>$<hash-base64>
|
|
8
|
+
// 只存哈希与随机盐,不存明文(INV-16)。
|
|
9
|
+
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
|
|
12
|
+
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 64 };
|
|
13
|
+
|
|
14
|
+
function scryptAsync(password, salt, params) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
crypto.scrypt(password, salt, params.keylen, { N: params.N, r: params.r, p: params.p }, (error, derived) => {
|
|
17
|
+
if (error) reject(error);
|
|
18
|
+
else resolve(derived);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 生成密码哈希。
|
|
25
|
+
* @param {string} password 明文
|
|
26
|
+
* @returns {Promise<string>} scrypt$ 开头的字符串
|
|
27
|
+
*/
|
|
28
|
+
export async function hashPassword(password) {
|
|
29
|
+
if (typeof password !== 'string' || password.length === 0) {
|
|
30
|
+
throw new Error('hashPassword 需要非空字符串');
|
|
31
|
+
}
|
|
32
|
+
const salt = crypto.randomBytes(16);
|
|
33
|
+
const derived = await scryptAsync(password, salt, SCRYPT_PARAMS);
|
|
34
|
+
return [
|
|
35
|
+
'scrypt',
|
|
36
|
+
SCRYPT_PARAMS.N,
|
|
37
|
+
SCRYPT_PARAMS.r,
|
|
38
|
+
SCRYPT_PARAMS.p,
|
|
39
|
+
salt.toString('base64'),
|
|
40
|
+
derived.toString('base64'),
|
|
41
|
+
].join('$');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 解析 scrypt$ 哈希串 → 派生参数;格式不对返回 null(不抛错,调用方按"校验失败"处理) */
|
|
45
|
+
function parseScryptHash(stored) {
|
|
46
|
+
const parts = stored.split('$');
|
|
47
|
+
if (parts.length !== 6 || parts[0] !== 'scrypt') return null;
|
|
48
|
+
|
|
49
|
+
const [, nRaw, rRaw, pRaw, saltB64, hashB64] = parts;
|
|
50
|
+
const params = {
|
|
51
|
+
N: Number.parseInt(nRaw, 10),
|
|
52
|
+
r: Number.parseInt(rRaw, 10),
|
|
53
|
+
p: Number.parseInt(pRaw, 10),
|
|
54
|
+
keylen: Buffer.from(hashB64, 'base64').length,
|
|
55
|
+
};
|
|
56
|
+
const valid = Number.isInteger(params.N) && Number.isInteger(params.r) && Number.isInteger(params.p) && params.keylen > 0;
|
|
57
|
+
return valid ? { params, saltB64, hashB64 } : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 校验密码(timing-safe 比较)。
|
|
62
|
+
* @param {string} password 明文
|
|
63
|
+
* @param {string} stored 存储的哈希(scrypt$ 格式)
|
|
64
|
+
* @returns {Promise<boolean>}
|
|
65
|
+
*/
|
|
66
|
+
export async function verifyPassword(password, stored) {
|
|
67
|
+
if (typeof password !== 'string' || typeof stored !== 'string') return false;
|
|
68
|
+
const parsed = parseScryptHash(stored);
|
|
69
|
+
if (!parsed) return false;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const derived = await scryptAsync(password, Buffer.from(parsed.saltB64, 'base64'), parsed.params);
|
|
73
|
+
const expected = Buffer.from(parsed.hashB64, 'base64');
|
|
74
|
+
if (expected.length !== derived.length) return false;
|
|
75
|
+
return crypto.timingSafeEqual(derived, expected);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 生成不透明随机串(session id / bearer token 的明文形态) */
|
|
82
|
+
export function randomToken(bytes = 32) {
|
|
83
|
+
return crypto.randomBytes(bytes).toString('base64url');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 只存储哈希:日志与磁盘里都不会出现可用于登录的明文(INV-16) */
|
|
87
|
+
export function sha256(value) {
|
|
88
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
89
|
+
}
|