pi-webdav-backup 1.0.0
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 +355 -0
- package/extensions/webdav-backup/audit-real.mjs +84 -0
- package/extensions/webdav-backup/auth-migrate.mjs +242 -0
- package/extensions/webdav-backup/backup.mjs +684 -0
- package/extensions/webdav-backup/cli.mjs +352 -0
- package/extensions/webdav-backup/collect.mjs +125 -0
- package/extensions/webdav-backup/config-paths.mjs +32 -0
- package/extensions/webdav-backup/config.mjs +199 -0
- package/extensions/webdav-backup/cron.mjs +90 -0
- package/extensions/webdav-backup/crypto.mjs +163 -0
- package/extensions/webdav-backup/dav-mock.mjs +145 -0
- package/extensions/webdav-backup/migrate.mjs +167 -0
- package/extensions/webdav-backup/restore.mjs +220 -0
- package/extensions/webdav-backup/run-tests.mjs +41 -0
- package/extensions/webdav-backup/secrets.mjs +404 -0
- package/extensions/webdav-backup/tar.mjs +111 -0
- package/extensions/webdav-backup/test-auth-migrate.mjs +187 -0
- package/extensions/webdav-backup/test-cli.mjs +331 -0
- package/extensions/webdav-backup/test-redact.mjs +271 -0
- package/extensions/webdav-backup/test-restore.mjs +219 -0
- package/extensions/webdav-backup/test-secrets.mjs +268 -0
- package/extensions/webdav-backup/test.mjs +442 -0
- package/extensions/webdav-backup/verify-load.mjs +89 -0
- package/extensions/webdav-backup/webdav-backup.example.json +38 -0
- package/extensions/webdav-backup/webdav.mjs +218 -0
- package/extensions/webdav-backup.ts +766 -0
- package/package.json +41 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.mjs — 配置读取与校验
|
|
3
|
+
*
|
|
4
|
+
* 配置文件:~/.pi/agent/webdav-backup.json
|
|
5
|
+
* 兼容:环境变量 PI_WEBDAV_BACKUP_PASSWORD / PI_WEBDAV_BACKUP_PASS / PI_WEBDAV_BACKUP_KEY
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { resolveSecret, isPlaintextSecret, dpapiAvailable } from "./secrets.mjs";
|
|
13
|
+
import {
|
|
14
|
+
AGENT_DIR_ENV,
|
|
15
|
+
resolveAgentDir,
|
|
16
|
+
getAgentDir,
|
|
17
|
+
configPath,
|
|
18
|
+
statePath,
|
|
19
|
+
logPath,
|
|
20
|
+
} from "./config-paths.mjs";
|
|
21
|
+
|
|
22
|
+
export { AGENT_DIR_ENV, resolveAgentDir, getAgentDir, configPath, statePath, logPath };
|
|
23
|
+
|
|
24
|
+
function stripBom(text) {
|
|
25
|
+
return text.replace(/^\uFEFF/, "");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_EXCLUDE = [
|
|
29
|
+
"tmp/**",
|
|
30
|
+
"web-search-cache/**",
|
|
31
|
+
"npm/node_modules/**",
|
|
32
|
+
"sessions/**",
|
|
33
|
+
"**/*.log",
|
|
34
|
+
"**/node_modules/**",
|
|
35
|
+
// 密钥相关,绝不能进备份
|
|
36
|
+
"secrets/**",
|
|
37
|
+
"secrets.dpapi.json",
|
|
38
|
+
// 迁移/编辑留下的备份副本可能含明文密钥
|
|
39
|
+
"**/*.bak-*",
|
|
40
|
+
"**/*.bak",
|
|
41
|
+
"**/*.orig",
|
|
42
|
+
"**/*.old",
|
|
43
|
+
"**/*.save",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 读取配置。返回 { ok, config, errors, warnings, raw }
|
|
48
|
+
*/
|
|
49
|
+
export function loadConfig() {
|
|
50
|
+
const errors = [];
|
|
51
|
+
const warnings = [];
|
|
52
|
+
const p = configPath();
|
|
53
|
+
|
|
54
|
+
if (!existsSync(p)) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
config: null,
|
|
58
|
+
errors: [`配置文件不存在:${p}`],
|
|
59
|
+
warnings,
|
|
60
|
+
raw: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let raw;
|
|
65
|
+
try {
|
|
66
|
+
raw = JSON.parse(stripBom(readFileSync(p, "utf8")));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
config: null,
|
|
71
|
+
errors: [`配置文件不是合法 JSON:${e.message}`],
|
|
72
|
+
warnings,
|
|
73
|
+
raw: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const remote = raw.remote ?? raw.webdav ?? {};
|
|
78
|
+
|
|
79
|
+
// ── 解析密钥引用(不明文落盘)
|
|
80
|
+
// password 回退链:配置值 → 专用环境变量
|
|
81
|
+
const passwordRaw =
|
|
82
|
+
remote.password ??
|
|
83
|
+
process.env.PI_WEBDAV_BACKUP_PASSWORD ??
|
|
84
|
+
process.env.PI_WEBDAV_BACKUP_PASS ??
|
|
85
|
+
"";
|
|
86
|
+
const passwordRes = resolveSecret(passwordRaw, { allowCommand: true, allowPlain: true });
|
|
87
|
+
|
|
88
|
+
const keyRaw = raw.encryptKey ?? process.env.PI_WEBDAV_BACKUP_KEY ?? "";
|
|
89
|
+
const keyRes = resolveSecret(keyRaw, { allowCommand: true, allowPlain: true });
|
|
90
|
+
|
|
91
|
+
const config = {
|
|
92
|
+
enabled: raw.enabled !== false,
|
|
93
|
+
url: String(remote.url ?? "").trim(),
|
|
94
|
+
username: String(remote.username ?? "").trim(),
|
|
95
|
+
password: passwordRes.value ?? "",
|
|
96
|
+
passwordSource: passwordRes.source,
|
|
97
|
+
remoteDir: String(remote.remoteDir ?? raw.remoteDir ?? "pi-backup").replace(/^\/+|\/+$/g, ""),
|
|
98
|
+
remoteName: String(remote.remoteName ?? raw.remoteName ?? "pi-agent").trim(),
|
|
99
|
+
scope: Array.isArray(raw.scope) && raw.scope.length > 0 ? raw.scope.map(String) : ["."],
|
|
100
|
+
exclude: Array.isArray(raw.exclude)
|
|
101
|
+
? [...DEFAULT_EXCLUDE, ...raw.exclude.map(String)]
|
|
102
|
+
: DEFAULT_EXCLUDE,
|
|
103
|
+
includeSessions: raw.includeSessions === true,
|
|
104
|
+
snapshot: raw.snapshot === true,
|
|
105
|
+
encrypt: raw.encrypt === true,
|
|
106
|
+
encryptKey: keyRes.value ?? null,
|
|
107
|
+
encryptKeySource: keyRes.source,
|
|
108
|
+
backupOnExit: raw.backupOnExit !== false,
|
|
109
|
+
backupOnExitMinIntervalMinutes: Number(raw.backupOnExitMinIntervalMinutes ?? 30),
|
|
110
|
+
backupOnExitTimeoutMs: Number(raw.backupOnExitTimeoutMs ?? 10 * 60 * 1000),
|
|
111
|
+
timeoutMs: Number(raw.timeoutMs ?? 60_000),
|
|
112
|
+
insecureTls: raw.insecureTls === true,
|
|
113
|
+
keepVersions: Number(raw.keepVersions ?? 10),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (!config.url) errors.push("缺少 remote.url(WebDAV 地址)");
|
|
117
|
+
else if (!/^https?:\/\//i.test(config.url)) errors.push("remote.url 必须以 http:// 或 https:// 开头");
|
|
118
|
+
|
|
119
|
+
// 密钥解析错误直接阻断(否则会拿空密码去连)
|
|
120
|
+
if (passwordRes.error) errors.push(`remote.password 解析失败:${passwordRes.error}`);
|
|
121
|
+
if (keyRes.error) errors.push(`encryptKey 解析失败:${keyRes.error}`);
|
|
122
|
+
|
|
123
|
+
// 明文告警
|
|
124
|
+
if (isPlaintextSecret(passwordRaw)) {
|
|
125
|
+
warnings.push(
|
|
126
|
+
"remote.password 是明文,建议改为 dpapi:webdav-password" +
|
|
127
|
+
(dpapiAvailable() ? "(/backup-setup 可自动转换)" : " 或 $PI_WEBDAV_BACKUP_PASSWORD"),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
if (passwordRaw && isPlaintextSecret(passwordRaw) === false && passwordRes.source === "env") {
|
|
131
|
+
// 来自环境变量,无需告警
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (config.username && !config.password) {
|
|
135
|
+
const hint = passwordRaw
|
|
136
|
+
? `(已配置 remote.password 引用但解析为空,来源:${passwordRes.source})`
|
|
137
|
+
: "(可设置 remote.password 为 dpapi:名称,或环境变量 PI_WEBDAV_BACKUP_PASSWORD)";
|
|
138
|
+
warnings.push(`配置了 username 但没有可用 password ${hint}`);
|
|
139
|
+
}
|
|
140
|
+
if (!config.username && !config.password) {
|
|
141
|
+
warnings.push("未配置用户名/密码,将以匿名方式连接(部分服务端会拒绝)");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (config.encrypt && !config.encryptKey) {
|
|
145
|
+
errors.push("encrypt 为 true,但缺少可用 encryptKey(可设为 dpapi:名称 或 $PI_WEBDAV_BACKUP_KEY)");
|
|
146
|
+
}
|
|
147
|
+
if (config.encryptKey && String(config.encryptKey).length < 16) {
|
|
148
|
+
errors.push("encryptKey 至少需要 16 个字符");
|
|
149
|
+
}
|
|
150
|
+
if (config.encryptKey && isPlaintextSecret(keyRaw)) {
|
|
151
|
+
warnings.push("encryptKey 是明文,建议改为 dpapi:backup-key");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { ok: errors.length === 0, config, errors, warnings, raw, secrets: { passwordSource: passwordRes.source, encryptKeySource: keyRes.source, passwordWarning: passwordRes.warning, encryptKeyWarning: keyRes.warning } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 生成一份带注释的配置模板(写盘时用,JSON 不支持注释故存为 _ 前缀键) */
|
|
158
|
+
export function configTemplate() {
|
|
159
|
+
return {
|
|
160
|
+
_说明: "pi WebDAV 备份配置。修改后运行 /reload 或 /backup-setup 生效。",
|
|
161
|
+
enabled: true,
|
|
162
|
+
remote: {
|
|
163
|
+
_说明_url: "WebDAV 服务地址,例如坚果云 https://dav.jianguoyun.com/dav/",
|
|
164
|
+
url: "",
|
|
165
|
+
_说明_username: "登录邮箱 / 用户名",
|
|
166
|
+
username: "",
|
|
167
|
+
_说明_password:
|
|
168
|
+
"密钥引用,推荐 dpapi:webdav-password(Windows 用户级加密,无需主密码);也可 $ENV_VAR 或 file:路径。留空则读环境变量 PI_WEBDAV_BACKUP_PASSWORD",
|
|
169
|
+
password: "",
|
|
170
|
+
_说明_remoteDir: "远端保存目录",
|
|
171
|
+
remoteDir: "pi-backup",
|
|
172
|
+
_说明_remoteName: "本次备份的目录名(生成 pi-agent-<时间戳>.tar.gz)",
|
|
173
|
+
remoteName: "pi-agent",
|
|
174
|
+
},
|
|
175
|
+
_说明_scope: "要备份的路径,相对 ~/.pi/agent",
|
|
176
|
+
scope: ["."],
|
|
177
|
+
_说明_exclude: "额外排除规则(glob),内置已排除 tmp/、sessions/、缓存、node_modules",
|
|
178
|
+
exclude: [],
|
|
179
|
+
_说明_includeSessions: "是否包含会话历史(可能很大)",
|
|
180
|
+
includeSessions: false,
|
|
181
|
+
_说明_snapshot: "true=逐文件快照上传(增量,可单文件恢复);false=打包成单个 tar.gz(默认)",
|
|
182
|
+
snapshot: false,
|
|
183
|
+
_说明_encrypt: "是否加密(AES-256-GCM),强烈建议开启",
|
|
184
|
+
encrypt: false,
|
|
185
|
+
_说明_encryptKey: "密钥引用,推荐 dpapi:backup-key;至少 16 位",
|
|
186
|
+
encryptKey: "",
|
|
187
|
+
_说明_backupOnExit: "退出 pi 时自动备份",
|
|
188
|
+
backupOnExit: true,
|
|
189
|
+
_说明_backupOnExitMinIntervalMinutes: "两次自动备份的最小间隔(分钟),防止频繁开关 pi 造成重复上传",
|
|
190
|
+
backupOnExitMinIntervalMinutes: 30,
|
|
191
|
+
_说明_backupOnExitTimeoutMs: "自动备份最长等待时间(毫秒)",
|
|
192
|
+
backupOnExitTimeoutMs: 600000,
|
|
193
|
+
_说明_keepVersions: "保留最近 N 个备份,更早的自动删除(0 = 不清理)",
|
|
194
|
+
keepVersions: 10,
|
|
195
|
+
_说明_timeoutMs: "单次 HTTP 请求超时(毫秒)",
|
|
196
|
+
timeoutMs: 60000,
|
|
197
|
+
insecureTls: false,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron.mjs — 安装/移除 Windows 计划任务(每日定时备份)
|
|
3
|
+
*
|
|
4
|
+
* 用 schtasks.exe。任务名默认 PiWebDavBackup。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
export const TASK_NAME = "PiWebDavBackup";
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const cliPath = join(here, "cli.mjs");
|
|
16
|
+
|
|
17
|
+
function run(exe, args) {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
execFile(exe, args, { windowsHide: true }, (err, stdout, stderr) => {
|
|
20
|
+
resolve({
|
|
21
|
+
ok: !err,
|
|
22
|
+
code: err?.code ?? 0,
|
|
23
|
+
stdout: String(stdout ?? ""),
|
|
24
|
+
stderr: String(stderr ?? ""),
|
|
25
|
+
error: err ? String(err.message) : null,
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function findNode() {
|
|
32
|
+
// schtasks 需要绝对路径;优先用当前 node 可执行文件
|
|
33
|
+
return process.execPath;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function installCron({ time = "03:00", log = () => {} } = {}) {
|
|
37
|
+
if (process.platform !== "win32") {
|
|
38
|
+
return { ok: false, error: "自动安装计划任务目前仅支持 Windows;其他平台请自行用 cron/systemd 调用 cli.mjs backup" };
|
|
39
|
+
}
|
|
40
|
+
if (!existsSync(cliPath)) {
|
|
41
|
+
return { ok: false, error: `找不到 cli.mjs:${cliPath}` };
|
|
42
|
+
}
|
|
43
|
+
if (!/^\d{1,2}:\d{2}$/.test(time)) {
|
|
44
|
+
return { ok: false, error: `时间格式应为 HH:MM,收到:${time}` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const node = findNode();
|
|
48
|
+
const cmd = `"${node}" "${cliPath}" backup --reason cron`;
|
|
49
|
+
|
|
50
|
+
log(`创建计划任务 ${TASK_NAME}(每日 ${time})…`);
|
|
51
|
+
const create = await run("schtasks.exe", [
|
|
52
|
+
"/Create",
|
|
53
|
+
"/TN",
|
|
54
|
+
TASK_NAME,
|
|
55
|
+
"/TR",
|
|
56
|
+
cmd,
|
|
57
|
+
"/SC",
|
|
58
|
+
"DAILY",
|
|
59
|
+
"/ST",
|
|
60
|
+
time,
|
|
61
|
+
"/F",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
if (!create.ok) {
|
|
65
|
+
const msg = `${create.stderr || create.stdout || create.error}`.trim();
|
|
66
|
+
// 常见原因:非管理员权限
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
error: `创建计划任务失败:${msg}${/拒绝访问|Access is denied/i.test(msg) ? "(请以管理员身份运行)" : ""}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
log(`已创建计划任务:${TASK_NAME}`);
|
|
73
|
+
return { ok: true, taskName: TASK_NAME, time, command: cmd };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function uninstallCron({ log = () => {} } = {}) {
|
|
77
|
+
if (process.platform !== "win32") {
|
|
78
|
+
return { ok: false, error: "仅支持 Windows" };
|
|
79
|
+
}
|
|
80
|
+
log(`删除计划任务 ${TASK_NAME} …`);
|
|
81
|
+
const r = await run("schtasks.exe", ["/Delete", "/TN", TASK_NAME, "/F"]);
|
|
82
|
+
if (!r.ok) {
|
|
83
|
+
const msg = `${r.stderr || r.stdout || r.error}`.trim();
|
|
84
|
+
if (/找不到|cannot find|does not exist/i.test(msg)) {
|
|
85
|
+
return { ok: true, taskName: TASK_NAME, removed: false, note: "任务本来就不存在" };
|
|
86
|
+
}
|
|
87
|
+
return { ok: false, error: `删除失败:${msg}` };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, taskName: TASK_NAME, removed: true };
|
|
90
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crypto.mjs — AES-256-GCM 加密(scrypt 派生密钥)
|
|
3
|
+
*
|
|
4
|
+
* 文件格式(*.pibak):
|
|
5
|
+
* magic "PIBAK1\n" 7 字节
|
|
6
|
+
* salt 16 字节
|
|
7
|
+
* iv 12 字节
|
|
8
|
+
* hdrLen 4 字节 (BE)
|
|
9
|
+
* header JSON(明文,含文件名/大小/校验)
|
|
10
|
+
* ciphertext
|
|
11
|
+
* tag 16 字节
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
createCipheriv,
|
|
16
|
+
createDecipheriv,
|
|
17
|
+
createHash,
|
|
18
|
+
randomBytes,
|
|
19
|
+
scryptSync,
|
|
20
|
+
timingSafeEqual,
|
|
21
|
+
} from "node:crypto";
|
|
22
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
23
|
+
import { open } from "node:fs/promises";
|
|
24
|
+
import { pipeline } from "node:stream/promises";
|
|
25
|
+
import { Transform } from "node:stream";
|
|
26
|
+
|
|
27
|
+
const MAGIC = Buffer.from("PIBAK1\n", "utf8");
|
|
28
|
+
const CHUNK = 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
function deriveKey(passphrase, salt) {
|
|
31
|
+
return scryptSync(passphrase, salt, 32, { N: 16384, r: 8, p: 1 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 流式加密 srcFile -> dstFile,返回明文 sha256 与密文大小 */
|
|
35
|
+
export async function encryptFile(srcFile, dstFile, passphrase, meta = {}) {
|
|
36
|
+
const salt = randomBytes(16);
|
|
37
|
+
const iv = randomBytes(12);
|
|
38
|
+
const key = deriveKey(passphrase, salt);
|
|
39
|
+
|
|
40
|
+
const header = Buffer.from(
|
|
41
|
+
JSON.stringify({ v: 1, alg: "aes-256-gcm", createdAt: new Date().toISOString(), ...meta }),
|
|
42
|
+
"utf8",
|
|
43
|
+
);
|
|
44
|
+
const hdrLen = Buffer.alloc(4);
|
|
45
|
+
hdrLen.writeUInt32BE(header.length, 0);
|
|
46
|
+
|
|
47
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
|
48
|
+
const hash = createHash("sha256");
|
|
49
|
+
const out = createWriteStream(dstFile);
|
|
50
|
+
|
|
51
|
+
out.write(MAGIC);
|
|
52
|
+
out.write(salt);
|
|
53
|
+
out.write(iv);
|
|
54
|
+
out.write(hdrLen);
|
|
55
|
+
out.write(header);
|
|
56
|
+
|
|
57
|
+
const source = createReadStream(srcFile, { highWaterMark: CHUNK });
|
|
58
|
+
source.on("data", (c) => hash.update(c));
|
|
59
|
+
|
|
60
|
+
await pipeline(
|
|
61
|
+
source,
|
|
62
|
+
new Transform({
|
|
63
|
+
transform(chunk, _enc, cb) {
|
|
64
|
+
cb(null, cipher.update(chunk));
|
|
65
|
+
},
|
|
66
|
+
flush(cb) {
|
|
67
|
+
this.push(cipher.final());
|
|
68
|
+
this.push(cipher.getAuthTag());
|
|
69
|
+
cb();
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
out,
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return { sha256: hash.digest("hex"), header: JSON.parse(header.toString("utf8")) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 流式解密 .pibak -> dstFile */
|
|
79
|
+
export async function decryptFile(srcFile, dstFile, passphrase) {
|
|
80
|
+
const fh = await open(srcFile, "r");
|
|
81
|
+
let pos = 0;
|
|
82
|
+
const readExact = async (n) => {
|
|
83
|
+
const buf = Buffer.alloc(n);
|
|
84
|
+
const { bytesRead } = await fh.read(buf, 0, n, pos);
|
|
85
|
+
if (bytesRead !== n) throw new Error("文件损坏:读取头部失败");
|
|
86
|
+
pos += n;
|
|
87
|
+
return buf;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const magic = await readExact(7);
|
|
92
|
+
if (!magic.equals(MAGIC)) throw new Error("不是有效的 .pibak 加密文件(magic 不匹配)");
|
|
93
|
+
|
|
94
|
+
const salt = await readExact(16);
|
|
95
|
+
const iv = await readExact(12);
|
|
96
|
+
const hdrLen = (await readExact(4)).readUInt32BE(0);
|
|
97
|
+
if (hdrLen <= 0 || hdrLen > 64 * 1024) throw new Error("文件损坏:头部长度异常");
|
|
98
|
+
const header = JSON.parse((await readExact(hdrLen)).toString("utf8"));
|
|
99
|
+
|
|
100
|
+
const key = deriveKey(passphrase, salt);
|
|
101
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
|
102
|
+
|
|
103
|
+
// 剩余流 = ciphertext + 末尾 16 字节 tag
|
|
104
|
+
const src = createReadStream(null, { fd: fh.fd, start: pos, autoClose: true });
|
|
105
|
+
let tail = Buffer.alloc(0);
|
|
106
|
+
const out = createWriteStream(dstFile);
|
|
107
|
+
|
|
108
|
+
await pipeline(
|
|
109
|
+
src,
|
|
110
|
+
new Transform({
|
|
111
|
+
transform(chunk, _enc, cb) {
|
|
112
|
+
const buf = Buffer.concat([tail, chunk]);
|
|
113
|
+
if (buf.length <= 16) {
|
|
114
|
+
tail = buf;
|
|
115
|
+
cb();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
tail = buf.subarray(buf.length - 16);
|
|
119
|
+
cb(null, decipher.update(buf.subarray(0, buf.length - 16)));
|
|
120
|
+
},
|
|
121
|
+
flush(cb) {
|
|
122
|
+
if (tail.length !== 16) return cb(new Error("文件损坏:认证标签缺失"));
|
|
123
|
+
decipher.setAuthTag(tail);
|
|
124
|
+
try {
|
|
125
|
+
this.push(decipher.final());
|
|
126
|
+
cb();
|
|
127
|
+
} catch (e) {
|
|
128
|
+
cb(new Error(`解密失败(密码错误或文件损坏):${e.message}`));
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
out,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return { header };
|
|
136
|
+
} finally {
|
|
137
|
+
await fh.close().catch(() => {});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function sha256File(file) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const h = createHash("sha256");
|
|
144
|
+
createReadStream(file)
|
|
145
|
+
.on("data", (c) => h.update(c))
|
|
146
|
+
.on("end", () => resolve(h.digest("hex")))
|
|
147
|
+
.on("error", reject);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function sha256Buffer(buf) {
|
|
152
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export { MAGIC };
|
|
156
|
+
|
|
157
|
+
/** 校验两个密码是否一致(常量时间),用于 /backup-setup 输入确认 */
|
|
158
|
+
export function samePassphrase(a, b) {
|
|
159
|
+
const ba = Buffer.from(String(a), "utf8");
|
|
160
|
+
const bb = Buffer.from(String(b), "utf8");
|
|
161
|
+
if (ba.length !== bb.length) return false;
|
|
162
|
+
return timingSafeEqual(ba, bb);
|
|
163
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dav-mock.mjs — 内存 WebDAV 服务端(供测试使用)
|
|
3
|
+
* 支持 PROPFIND / MKCOL / PUT / GET / HEAD / DELETE + Basic Auth
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import http from "node:http";
|
|
7
|
+
|
|
8
|
+
export function startDavServer({ username = "user", password = "pass" } = {}) {
|
|
9
|
+
/** @type {Map<string, Buffer>} */
|
|
10
|
+
const store = new Map();
|
|
11
|
+
const dirs = new Set(["/"]);
|
|
12
|
+
const expectedAuth = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
|
|
13
|
+
let putCount = 0;
|
|
14
|
+
let getCount = 0;
|
|
15
|
+
|
|
16
|
+
const server = http.createServer((req, res) => {
|
|
17
|
+
const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
|
|
18
|
+
const key = path.replace(/\/+$/, "") || "/";
|
|
19
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
20
|
+
|
|
21
|
+
if (req.headers.authorization !== expectedAuth) {
|
|
22
|
+
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="dav"' });
|
|
23
|
+
res.end("unauthorized");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (method === "PROPFIND") {
|
|
28
|
+
const depth = req.headers.depth ?? "0";
|
|
29
|
+
if (!dirs.has(key) && !store.has(key)) {
|
|
30
|
+
res.writeHead(404).end("not found");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const prefix = key === "/" ? "/" : `${key}/`;
|
|
34
|
+
const children = [];
|
|
35
|
+
if (depth !== "0") {
|
|
36
|
+
for (const d of dirs) {
|
|
37
|
+
if (d === key) continue;
|
|
38
|
+
if (d.startsWith(prefix)) {
|
|
39
|
+
const rest = d.slice(prefix.length);
|
|
40
|
+
if (rest && !rest.includes("/")) {
|
|
41
|
+
children.push({ href: d, isDir: true, size: 0, lm: new Date().toUTCString() });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const [p, buf] of store) {
|
|
46
|
+
if (p.startsWith(prefix)) {
|
|
47
|
+
const rest = p.slice(prefix.length);
|
|
48
|
+
if (rest && !rest.includes("/")) {
|
|
49
|
+
children.push({ href: p, isDir: false, size: buf.length, lm: new Date().toUTCString() });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const responses = [
|
|
55
|
+
{ href: key, isDir: !store.has(key), size: store.get(key)?.length ?? 0, lm: new Date().toUTCString() },
|
|
56
|
+
...children,
|
|
57
|
+
];
|
|
58
|
+
const xml =
|
|
59
|
+
`<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n` +
|
|
60
|
+
responses
|
|
61
|
+
.map(
|
|
62
|
+
(e) =>
|
|
63
|
+
` <D:response><D:href>${e.href}</D:href><D:propstat><D:prop>` +
|
|
64
|
+
`<D:getcontentlength>${e.size}</D:getcontentlength>` +
|
|
65
|
+
`<D:getlastmodified>${e.lm}</D:getlastmodified>` +
|
|
66
|
+
`<D:resourcetype>${e.isDir ? "<D:collection/>" : ""}</D:resourcetype>` +
|
|
67
|
+
`</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`,
|
|
68
|
+
)
|
|
69
|
+
.join("\n") +
|
|
70
|
+
`\n</D:multistatus>`;
|
|
71
|
+
res.writeHead(207, { "Content-Type": "application/xml" }).end(xml);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (method === "MKCOL") {
|
|
76
|
+
if (dirs.has(key) || store.has(key)) {
|
|
77
|
+
res.writeHead(405).end("exists");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
dirs.add(key);
|
|
81
|
+
res.writeHead(201).end();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (method === "PUT") {
|
|
86
|
+
const chunks = [];
|
|
87
|
+
req.on("data", (c) => chunks.push(c));
|
|
88
|
+
req.on("end", () => {
|
|
89
|
+
putCount++;
|
|
90
|
+
store.set(key, Buffer.concat(chunks));
|
|
91
|
+
res.writeHead(201).end();
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (method === "GET") {
|
|
97
|
+
if (!store.has(key)) {
|
|
98
|
+
res.writeHead(404).end();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
getCount++;
|
|
102
|
+
const buf = store.get(key);
|
|
103
|
+
res.writeHead(200, { "Content-Length": String(buf.length) }).end(buf);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (method === "HEAD") {
|
|
108
|
+
if (!store.has(key)) {
|
|
109
|
+
res.writeHead(404).end();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
res.writeHead(200, { "Content-Length": String(store.get(key).length) }).end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (method === "DELETE") {
|
|
117
|
+
if (store.delete(key) || dirs.delete(key)) {
|
|
118
|
+
res.writeHead(204).end();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
res.writeHead(404).end();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
res.writeHead(405).end("method not allowed");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return new Promise((resolve) => {
|
|
129
|
+
server.listen(0, "127.0.0.1", () => {
|
|
130
|
+
const addr = server.address();
|
|
131
|
+
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
132
|
+
resolve({
|
|
133
|
+
server,
|
|
134
|
+
port,
|
|
135
|
+
store,
|
|
136
|
+
dirs,
|
|
137
|
+
stats: () => ({ putCount, getCount }),
|
|
138
|
+
reset: () => {
|
|
139
|
+
putCount = 0;
|
|
140
|
+
getCount = 0;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|