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 +3 -0
- package/extensions/webdav-backup/apply.mjs +38 -3
- package/extensions/webdav-backup/dav-mock.mjs +10 -1
- package/extensions/webdav-backup/test-apply.mjs +27 -1
- package/extensions/webdav-backup/test-webdav.mjs +91 -0
- package/extensions/webdav-backup/webdav.mjs +53 -1
- package/package.json +1 -1
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "pi 配置与插件数据自动备份到 WebDAV(坚果云 / Nextcloud / 群晖 / 自建):归档与快照两种模式、AES-256-GCM 加密、DPAPI 密钥管理、敏感字段脱敏、agentDir 之外的额外数据(extraScope)、插件实现代码随备份(includePlugins)实现换机一键还原、退出自动备份与每日定时备份。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|