pi-webdav-backup 1.3.3 → 1.4.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/README.md CHANGED
@@ -636,6 +636,7 @@ node extensions/webdav-backup/verify-load.mjs
636
636
 
637
637
  | 版本 | 内容 |
638
638
  |---|---|
639
+ | **v1.4.0** | **跨平台健壮性**:① 合并/恢复时**过滤旧机器专属的绝对路径本地源**(`E:\...`、`/home/...`、UNC)——`pi install /绝对/路径` 会把路径写进 settings.json,跨机恢复时无效,现在不再带入目标机;② WebDAV 客户端对 **423 Locked / 429 / 5xx 与网络抖动自动重试**(指数退避,尊重 `Retry-After`,上限 4 次),修复实测中两个实例并发上传导致的 423 直接失败;新增 `test-webdav.mjs`(故障注入)与跨平台路径回归用例 |
639
640
  | **v1.3.3** | **修复合并模式会卸载本机插件**:`restore-apply` 合并模式此前只合并 `settings.json`,却用备份版**覆盖** `npm/package.json`——pi 下次启动的 npm 协调会把本机独有插件当成多余包卸载(实测于 Windows→Linux 同步)。现改为同时合并 `npm/package.json` 的 `dependencies`(本机优先),结果里标 `npm-dependencies`。另:文档补充「多机同步」与「跨平台密钥」流程 |
640
641
  | **v1.3.2** | **跨平台多机同步**:依赖体检修正(npm 提升到父级的依赖不再误报为缺失,按 Node 模块解析算法沿祖先 `node_modules` 查找);文档新增「跨平台密钥(多机同步)」与「多机同步(Windows ↔ Linux)」流程,明确多机用 `file:` / `$ENV` 而非 `dpapi:`,并给出常见坑 |
641
642
  | **v1.3.1** | 依赖体检误报修正(npm 提升的依赖被当成缺失) |
@@ -662,7 +663,9 @@ node extensions/webdav-backup/verify-load.mjs
662
663
  | 想立刻看到日志 | `/backup-log` |
663
664
  | DPAPI 解密失败 | 密文只能在本机本用户下解;换机器/换用户需重新 `/backup keys set` |
664
665
  | 提示「环境变量未设置」 | `/backup keys set` 改用 dpapi,或先设好该环境变量 |
666
+ | 恢复报 `HTTP 423` | 网盘并发锁;v1.4.0+ 会自动重试(指数退避,尊重 `Retry-After`)。若持续失败,请确认没有安装**两份** backup 插件(退出时会同时备份),可用 `/backup list` + `pi list` 检查 |
665
667
  | 恢复报 `HTTP 302` | Cloudreve/群晖等把下载重定向到对象存储(腾讯云 COS 等);客户端已自动跟随跨主机 302(不携带凭据),请升级到 v1.1.0+ |
668
+ | 恢复后 `settings.json` 多了无效路径 | v1.4.0+ 已过滤旧机器的绝对路径本地源;旧版残留可手动删除 |
666
669
  | 恢复后插件不生效 | 若备份时未开启 `includePlugins`,插件代码(`node_modules`)不在备份里,需联网补装:`/backup repair-plugins`(可带 `--dir`)。推荐下次备份开启 `includePlugins: true`,下次即可一键还原 |
667
670
  | 写回时提示跳过某些文件 | 归档里是 `__REDACTED__` 而本地已有真实密钥时会被跳过(避免把真密钥抹掉);确认要覆盖可加 `--force` |
668
671
  | 补装报 `git clone` 失败 | 确认能访问 github.com 等源;ref 是 commit SHA 时会自动回退为完整 clone + checkout,仍失败则看日志里的 git 输出 |
@@ -95,13 +95,41 @@ export function pluginRootRel(rel) {
95
95
  return null;
96
96
  }
97
97
 
98
- /** 取 packages 声明的来源字符串(兼容字符串与 { source, ... } 对象) */
98
+ /**
99
+ * 取 packages 声明的来源字符串(兼容字符串与 { source, ... } 对象)
100
+ */
99
101
  function packageSourceString(p) {
100
102
  if (typeof p === "string") return p;
101
103
  if (p && typeof p === "object" && typeof p.source === "string") return p.source;
102
104
  return null;
103
105
  }
104
106
 
107
+ /**
108
+ * 判断一条 packages 声明是否是「本机绝对路径」形式的本地源。
109
+ *
110
+ * 为何重要:`pi install /绝对/路径` 会把该机器的绝对路径写进 settings.json。
111
+ * 跨平台/跨机器恢复时,这种路径在目标机上必然无效(且可能指到完全无关的位置),
112
+ * 所以合并时要过滤掉,不要带过去。
113
+ *
114
+ * 只认「路径形」字符串;`npm:` / `git:` / `https://` / `builtin:` 等来源不受影响。
115
+ * 注意:相对路径(如 `./ext`、`ext`)是可移植的,不过滤。
116
+ */
117
+ export function isMachineSpecificPackagePath(source) {
118
+ const s = packageSourceString(source);
119
+ if (!s) return false;
120
+ const v = s.trim();
121
+ if (!v) return false;
122
+ // 显式来源前缀:不是本地路径
123
+ if (/^(npm:|git:|github:|https?:|ssh:|builtin:|file:)/i.test(v)) return false;
124
+ // Windows 盘符绝对路径:C:\foo 或 C:/foo
125
+ if (/^[A-Za-z]:[\\/]/.test(v)) return true;
126
+ // UNC 路径:\\server\share
127
+ if (/^\\\\/.test(v)) return true;
128
+ // POSIX 绝对路径:/home/me/ext(含 \\home 形式的 Windows 风格根部)
129
+ if (v.startsWith("/") || v.startsWith("\\\\")) return true;
130
+ return false;
131
+ }
132
+
105
133
  /**
106
134
  * 计算一条 packages 声明的「身份」:npm 看包名、git 看 host/path、local 看路径。
107
135
  * 用于合并时按身份去重(同身份视为同一个插件,不因版本号不同而重复)。
@@ -138,11 +166,18 @@ export function mergeSettingsPackages(backupText, localText) {
138
166
 
139
167
  const backupPkgs = Array.isArray(backup.packages) ? backup.packages : [];
140
168
  const localPkgs = Array.isArray(local?.packages) ? local.packages : [];
141
- // 本机没有任何声明 → 直接用备份版即可(全新机器场景)
142
- if (localPkgs.length === 0) return null;
169
+
170
+ // 本机没有任何声明(新机场景):用备份版,但仍过滤掉旧机器专属的绝对路径
171
+ if (localPkgs.length === 0) {
172
+ const kept = backupPkgs.filter((p) => !isMachineSpecificPackagePath(p));
173
+ if (kept.length === backupPkgs.length) return null; // 无外来源,无需改写
174
+ return JSON.stringify({ ...backup, packages: kept }, null, 2);
175
+ }
143
176
 
144
177
  const localIds = new Set(localPkgs.map(packageIdentity).filter(Boolean));
145
178
  const extra = backupPkgs.filter((p) => {
179
+ // 备份里指向旧机器绝对路径的本地源,在目标机上无效 → 不合并
180
+ if (isMachineSpecificPackagePath(p)) return false;
146
181
  const id = packageIdentity(p);
147
182
  return id && !localIds.has(id);
148
183
  });
@@ -15,6 +15,7 @@ export function startDavServer({
15
15
  password = "pass",
16
16
  requireAuth = true,
17
17
  redirectGetsTo = null,
18
+ failFirstPuts = 0,
18
19
  } = {}) {
19
20
  /** @type {Map<string, Buffer>} */
20
21
  const store = new Map();
@@ -22,6 +23,7 @@ export function startDavServer({
22
23
  const expectedAuth = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
23
24
  let putCount = 0;
24
25
  let getCount = 0;
26
+ let lockedCount = 0;
25
27
  let lastAuth = null;
26
28
 
27
29
  const server = http.createServer((req, res) => {
@@ -98,6 +100,12 @@ export function startDavServer({
98
100
  const chunks = [];
99
101
  req.on("data", (c) => chunks.push(c));
100
102
  req.on("end", () => {
103
+ // 故障注入:前 N 次 PUT 返回 423 Locked
104
+ if (lockedCount < failFirstPuts) {
105
+ lockedCount++;
106
+ res.writeHead(423, { "Retry-After": "1" }).end("locked");
107
+ return;
108
+ }
101
109
  putCount++;
102
110
  store.set(key, Buffer.concat(chunks));
103
111
  res.writeHead(201).end();
@@ -152,13 +160,14 @@ export function startDavServer({
152
160
  port,
153
161
  store,
154
162
  dirs,
155
- stats: () => ({ putCount, getCount }),
163
+ stats: () => ({ putCount, getCount, lockedCount }),
156
164
  get lastAuth() {
157
165
  return lastAuth;
158
166
  },
159
167
  reset: () => {
160
168
  putCount = 0;
161
169
  getCount = 0;
170
+ lockedCount = 0;
162
171
  },
163
172
  });
164
173
  });
@@ -29,7 +29,7 @@ import { collectFiles } from "./collect.mjs";
29
29
  import { runBackup } from "./backup.mjs";
30
30
  import { restoreBackup } from "./restore.mjs";
31
31
  import { analyzePlugins } from "./packages.mjs";
32
- import { applyRestoredFiles, listRestoredFiles, hasPluginCode, pluginRootRel, packageIdentity, mergeSettingsPackages } from "./apply.mjs";
32
+ import { applyRestoredFiles, listRestoredFiles, hasPluginCode, pluginRootRel, packageIdentity, mergeSettingsPackages, isMachineSpecificPackagePath } from "./apply.mjs";
33
33
 
34
34
  let pass = 0;
35
35
  let fail = 0;
@@ -306,6 +306,32 @@ async function main() {
306
306
  check("合并保留本机声明优先、追加备份独有", JSON.stringify(o.packages) === JSON.stringify(["npm:B@2", "npm:A"]) && o.theme === "light", String(m));
307
307
  }
308
308
 
309
+ // ── 8b. 回归:跨机同步不得带入旧机器的绝对路径本地源
310
+ {
311
+ // isMachineSpecificPackagePath 单测
312
+ check("识别 Windows 盘符路径", isMachineSpecificPackagePath("E:\\Users\\me\\ext") === true);
313
+ check("识别正斜杠盘符路径", isMachineSpecificPackagePath("C:/Users/me/ext") === true);
314
+ check("识别 POSIX 绝对路径", isMachineSpecificPackagePath("/home/me/ext") === true);
315
+ check("识别 UNC 路径", isMachineSpecificPackagePath("\\\\server\\share\\ext") === true);
316
+ check("npm 源不误判", isMachineSpecificPackagePath("npm:foo") === false);
317
+ check("git 源不误判", isMachineSpecificPackagePath("git:github.com/u/r@v1") === false);
318
+ check("https 源不误判", isMachineSpecificPackagePath("https://github.com/u/r") === false);
319
+ check("相对路径不误判(可移植)", isMachineSpecificPackagePath("./ext") === false);
320
+
321
+ // 合并时:备份里的 Windows 绝对路径不得进入 Linux 的 packages
322
+ const backup = JSON.stringify({ packages: ["npm:pi-signal-footer", "E:\\Users\\Administrator\\Desktop\\projects\\pi-webdav-backup"] });
323
+ const live = JSON.stringify({ packages: ["npm:@zhangweiii/pi-status-line"] });
324
+ const merged = JSON.parse(mergeSettingsPackages(backup, live));
325
+ check("合并过滤掉旧机器绝对路径", !merged.packages.some((p) => /^[A-Za-z]:[\\/]/.test(p)), JSON.stringify(merged.packages));
326
+ check("合并仍保留可移植的 npm 源", merged.packages.includes("npm:pi-signal-footer"), JSON.stringify(merged.packages));
327
+ check("合并保留本机声明", merged.packages.includes("npm:@zhangweiii/pi-status-line"));
328
+
329
+ // 全新机(本机无 packages):同样要过滤外来绝对路径
330
+ const fresh = mergeSettingsPackages(backup, JSON.stringify({ theme: "dark" }));
331
+ check("全新机场景也过滤外来绝对路径", fresh !== null && !JSON.parse(fresh).packages.some((p) => /^[A-Za-z]:[\\/]/.test(p)), String(fresh));
332
+ check("全新机仅剩可移植源则不改写", mergeSettingsPackages('{"packages":["npm:A"]}', '{}') === null);
333
+ }
334
+
309
335
  // ── 9. 回归:npm/package.json 必须合并 dependencies(否则 pi 会卸载本机独有插件)
310
336
  {
311
337
  // 备份来源(Windows):只依赖 A、B
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * test-webdav.mjs — WebDAV 客户端健壮性测试
4
+ *
5
+ * 背景:网盘在并发上传/维护时返回 423 Locked / 429 / 5xx;此前无任何重试,
6
+ * 一次抖动就让整次备份失败(实测:两份 backup 插件同时退出 → 423)。
7
+ *
8
+ * 验证:
9
+ * 1. PUT 遇 423 Locked → 自动重试并最终成功
10
+ * 2. Retry-After 头被尊重(不改变正确性,仅验证不报错)
11
+ * 3. 持续 423 超过重试上限时明确失败(不会无限重试)
12
+ * 4. check/list/统计仍正常
13
+ */
14
+
15
+ import { startDavServer } from "./dav-mock.mjs";
16
+ import { WebDAVClient } from "./webdav.mjs";
17
+
18
+ let pass = 0;
19
+ let fail = 0;
20
+ const lines = [];
21
+ const check = (n, c, d = "") => {
22
+ if (c) { pass++; lines.push(` ✅ ${n}`); }
23
+ else { fail++; lines.push(` ❌ ${n}${d ? ` — ${d}` : ""}`); }
24
+ };
25
+
26
+ async function main() {
27
+ console.log("pi WebDAV 备份 — WebDAV 客户端健壮性测试\n");
28
+
29
+ // ── 1. PUT 前 2 次返回 423,之后成功(验证重试)
30
+ {
31
+ const dav = await startDavServer({ failFirstPuts: 2 });
32
+ dav.dirs.add("/dav");
33
+ const c = new WebDAVClient({ baseUrl: `http://127.0.0.1:${dav.port}/dav`, username: "user", password: "pass", timeoutMs: 10000 });
34
+ let ok = false, err = null;
35
+ try {
36
+ await c.put("/dav/x.txt", Buffer.from("hello"), { contentType: "text/plain" });
37
+ ok = true;
38
+ } catch (e) { err = e.message; }
39
+ const s = dav.stats();
40
+ check("423 后自动重试并成功", ok === true, err || "");
41
+ check("确实经历了 2 次锁定", s.lockedCount === 2, JSON.stringify(s));
42
+ check("最终成功写入(putCount=1)", s.putCount === 1, JSON.stringify(s));
43
+ const got = await c.get("/dav/x.txt");
44
+ check("重试后内容正确", got && got.toString("utf8") === "hello");
45
+ dav.server.close();
46
+ }
47
+
48
+ // ── 2. 持续 423 超过上限 → 明确失败(不悬挂)
49
+ {
50
+ const dav = await startDavServer({ failFirstPuts: 999 });
51
+ dav.dirs.add("/dav");
52
+ const c = new WebDAVClient({ baseUrl: `http://127.0.0.1:${dav.port}/dav`, username: "user", password: "pass", timeoutMs: 10000 });
53
+ const t0 = Date.now();
54
+ let failed = false, msg = "";
55
+ try {
56
+ await c.put("/dav/y.txt", Buffer.from("x"));
57
+ } catch (e) { failed = true; msg = e.message; }
58
+ const ms = Date.now() - t0;
59
+ check("持续 423 最终失败(不无限重试)", failed === true);
60
+ check("错误信息含 HTTP 423", /423/.test(msg), msg);
61
+ check("重试次数有上限(<40s)", ms < 40000, `${ms}ms`);
62
+ dav.server.close();
63
+ }
64
+
65
+ // ── 3. 常规操作不受影响
66
+ {
67
+ const dav = await startDavServer();
68
+ dav.dirs.add("/dav");
69
+ const c = new WebDAVClient({ baseUrl: `http://127.0.0.1:${dav.port}/dav`, username: "user", password: "pass", timeoutMs: 10000 });
70
+ const chk = await c.check();
71
+ check("check 正常", chk.ok === true, JSON.stringify(chk));
72
+ await c.mkdirp("dav/sub");
73
+ await c.put("/dav/sub/a.txt", Buffer.from("A"));
74
+ const list = await c.list("dav/sub");
75
+ check("list 能看到刚上传的文件", list.some((e) => e.name === "a.txt" && !e.isCollection), JSON.stringify(list.map((e) => e.name)));
76
+ await c.del("/dav/sub/a.txt");
77
+ const list2 = await c.list("dav/sub");
78
+ check("delete 生效", !list2.some((e) => e.name === "a.txt"));
79
+ dav.server.close();
80
+ }
81
+
82
+ console.log(lines.join("\n"));
83
+ console.log(`\n结果:${pass} 通过,${fail} 失败`);
84
+ process.exit(fail === 0 ? 0 : 1);
85
+ }
86
+
87
+ main().catch((e) => {
88
+ console.log(lines.join("\n"));
89
+ console.error(`\n测试异常:${e?.stack ?? e}`);
90
+ process.exit(1);
91
+ });
@@ -17,6 +17,30 @@ import { URL } from "node:url";
17
17
  const UA = "pi-webdav-backup/1.0";
18
18
  const MAX_REDIRECTS = 5;
19
19
 
20
+ /**
21
+ * 瞬时错误重试参数。
22
+ *
23
+ * 背景:网盘(坚果云/群晖/Cloudreve 等)在并发上传、扫描或维护时会返回
24
+ * 423 Locked / 429 / 5xx;同一文件在稍后重试即可成功。之前没有任何重试,
25
+ * 一次抖动就导致整次备份失败(实测:两个实例同时退出备份 → 423)。
26
+ */
27
+ const MAX_RETRIES = 4;
28
+ const RETRY_BASE_DELAY_MS = 800;
29
+ const RETRYABLE_STATUS = new Set([423, 425, 429, 500, 502, 503, 504]);
30
+ const RETRYABLE_METHODS = new Set(["PUT", "MKCOL", "DELETE", "PROPFIND", "HEAD", "GET"]);
31
+
32
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
+
34
+ /** 解析 Retry-After(秒数或 HTTP 日期),返回毫秒;无法解析返回 null */
35
+ function parseRetryAfter(value) {
36
+ if (!value) return null;
37
+ const s = String(value).trim();
38
+ if (/^\d+$/.test(s)) return Number(s) * 1000;
39
+ const t = Date.parse(s);
40
+ if (!Number.isNaN(t)) return Math.max(0, t - Date.now());
41
+ return null;
42
+ }
43
+
20
44
  function request(url, { method, headers = {}, body = null, timeoutMs = 60000, insecureTls = false, redirects = MAX_REDIRECTS }) {
21
45
  return new Promise((resolve, reject) => {
22
46
  let u;
@@ -100,6 +124,34 @@ function basicAuth(username, password) {
100
124
  return { Authorization: `Basic ${token}` };
101
125
  }
102
126
 
127
+ /**
128
+ * 带重试的请求:对 423/429/5xx 与网络抖动指数据退避重试(仅幂等方法)。
129
+ * PUT 幂等(整体覆盖同名文件),故也可安全重试。
130
+ * @returns {Promise<{status:number, headers:object, body:Buffer}>}
131
+ */
132
+ async function requestWithRetry(url, opts) {
133
+ const method = String(opts.method || "GET").toUpperCase();
134
+ const retryable = RETRYABLE_METHODS.has(method);
135
+ let lastRes = null;
136
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
137
+ let res;
138
+ try {
139
+ res = await request(url, opts);
140
+ } catch (e) {
141
+ // 网络层错误(超时/断连)也重试
142
+ if (!retryable || attempt === MAX_RETRIES) throw e;
143
+ await sleep(RETRY_BASE_DELAY_MS * 2 ** attempt);
144
+ continue;
145
+ }
146
+ lastRes = res;
147
+ if (!retryable || !RETRYABLE_STATUS.has(res.status) || attempt === MAX_RETRIES) return res;
148
+ const hinted = parseRetryAfter(res.headers?.["retry-after"]);
149
+ const delay = hinted ?? RETRY_BASE_DELAY_MS * 2 ** attempt;
150
+ await sleep(Math.min(delay, 15000));
151
+ }
152
+ return lastRes;
153
+ }
154
+
103
155
  export class WebDAVClient {
104
156
  constructor({ baseUrl, username, password, timeoutMs = 60000, insecureTls = false }) {
105
157
  if (!/^https?:\/\//i.test(baseUrl)) {
@@ -122,7 +174,7 @@ export class WebDAVClient {
122
174
  }
123
175
 
124
176
  async #req(method, remotePath, { headers = {}, body = null } = {}) {
125
- return request(this.urlFor(remotePath), {
177
+ return requestWithRetry(this.urlFor(remotePath), {
126
178
  method,
127
179
  headers: { ...this.auth, ...headers },
128
180
  body,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-webdav-backup",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
4
4
  "description": "pi 配置与插件数据自动备份到 WebDAV(坚果云 / Nextcloud / 群晖 / 自建):归档与快照两种模式、AES-256-GCM 加密、DPAPI 密钥管理、敏感字段脱敏、agentDir 之外的额外数据(extraScope)、插件实现代码随备份(includePlugins)实现换机一键还原、退出自动备份与每日定时备份。",
5
5
  "keywords": [
6
6
  "pi-package",