pi-webdav-backup 1.0.2 → 1.3.1
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 +201 -23
- package/extensions/webdav-backup/apply.mjs +319 -0
- package/extensions/webdav-backup/backup.mjs +167 -87
- package/extensions/webdav-backup/cli.mjs +475 -357
- package/extensions/webdav-backup/collect.mjs +104 -2
- package/extensions/webdav-backup/config.mjs +53 -7
- package/extensions/webdav-backup/dav-mock.mjs +166 -145
- package/extensions/webdav-backup/extras.mjs +420 -0
- package/extensions/webdav-backup/packages.mjs +397 -0
- package/extensions/webdav-backup/restore.mjs +17 -0
- package/extensions/webdav-backup/test-apply.mjs +318 -0
- package/extensions/webdav-backup/test-extras.mjs +314 -0
- package/extensions/webdav-backup/test-plugins.mjs +363 -0
- package/extensions/webdav-backup/test-restore.mjs +286 -219
- package/extensions/webdav-backup/webdav-backup.example.json +4 -9
- package/extensions/webdav-backup/webdav.mjs +258 -218
- package/extensions/webdav-backup.ts +1024 -766
- package/package.json +2 -2
|
@@ -1,218 +1,258 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* webdav.mjs — 极简 WebDAV 客户端(零依赖,基于 node:http/https)
|
|
3
|
-
*
|
|
4
|
-
* 支持:MKCOL、PUT、GET、DELETE、PROPFIND、HEAD
|
|
5
|
-
* 认证:Basic Auth / Digest 不实现(WebDAV 主流服务用 Basic + TLS 或应用密码)
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
return
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (res.status === 200) return res.
|
|
140
|
-
if (res.status ===
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status };
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
/<(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* webdav.mjs — 极简 WebDAV 客户端(零依赖,基于 node:http/https)
|
|
3
|
+
*
|
|
4
|
+
* 支持:MKCOL、PUT、GET、DELETE、PROPFIND、HEAD
|
|
5
|
+
* 认证:Basic Auth / Digest 不实现(WebDAV 主流服务用 Basic + TLS 或应用密码)
|
|
6
|
+
*
|
|
7
|
+
* 重定向:GET/HEAD 的 301/302/303/307/308 会自动跟随(最多 MAX_REDIRECTS 跳)。
|
|
8
|
+
* Cloudreve 等网盘把 GET 重定向到对象存储(如腾讯云 COS)的带签名临时 URL,
|
|
9
|
+
* 不跟随就会拿到 302 而恢复失败。跳到其他主机时会丢弃 Authorization 头,
|
|
10
|
+
* 避免把 WebDAV 凭据泄露给对象存储。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import https from "node:https";
|
|
15
|
+
import { URL } from "node:url";
|
|
16
|
+
|
|
17
|
+
const UA = "pi-webdav-backup/1.0";
|
|
18
|
+
const MAX_REDIRECTS = 5;
|
|
19
|
+
|
|
20
|
+
function request(url, { method, headers = {}, body = null, timeoutMs = 60000, insecureTls = false, redirects = MAX_REDIRECTS }) {
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
let u;
|
|
23
|
+
try {
|
|
24
|
+
u = new URL(url);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
reject(new Error(`非法 URL: ${url} (${e.message})`));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isHttps = u.protocol === "https:";
|
|
31
|
+
const lib = isHttps ? https : http;
|
|
32
|
+
|
|
33
|
+
/** @type {import("node:http").RequestOptions} */
|
|
34
|
+
const opts = {
|
|
35
|
+
method,
|
|
36
|
+
hostname: u.hostname,
|
|
37
|
+
port: u.port || (isHttps ? 443 : 80),
|
|
38
|
+
path: `${u.pathname}${u.search}`,
|
|
39
|
+
headers: { "User-Agent": UA, ...headers },
|
|
40
|
+
agent: false,
|
|
41
|
+
};
|
|
42
|
+
if (isHttps && insecureTls) opts.rejectUnauthorized = false;
|
|
43
|
+
|
|
44
|
+
const req = lib.request(opts, (res) => {
|
|
45
|
+
const status = res.statusCode ?? 0;
|
|
46
|
+
const location = res.headers.location;
|
|
47
|
+
const isRedirect = [301, 302, 303, 307, 308].includes(status);
|
|
48
|
+
|
|
49
|
+
// 跟随重定向(仅 GET/HEAD;PUT/DELETE/PROPFIND 不跟随,避免意外丢方法体)
|
|
50
|
+
if (isRedirect && location && redirects > 0 && (method === "GET" || method === "HEAD")) {
|
|
51
|
+
res.resume(); // 丢弃重定向响应体,释放连接
|
|
52
|
+
let next;
|
|
53
|
+
try {
|
|
54
|
+
next = new URL(location, url);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
reject(new Error(`${method} ${url} 重定向地址非法: ${location} (${e.message})`));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// 跨主机重定向到对象存储时,不能把 WebDAV 凭据带过去
|
|
60
|
+
const nextHeaders = { ...headers };
|
|
61
|
+
const nextPort = Number(next.port || (next.protocol === "https:" ? 443 : 80));
|
|
62
|
+
if (next.hostname !== u.hostname || nextPort !== Number(opts.port)) delete nextHeaders.Authorization;
|
|
63
|
+
// 303 语义:后续一律 GET
|
|
64
|
+
const nextMethod = status === 303 ? "GET" : method;
|
|
65
|
+
request(next.toString(), {
|
|
66
|
+
method: nextMethod,
|
|
67
|
+
headers: nextHeaders,
|
|
68
|
+
body: null,
|
|
69
|
+
timeoutMs,
|
|
70
|
+
insecureTls,
|
|
71
|
+
redirects: redirects - 1,
|
|
72
|
+
}).then(resolve, reject);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const chunks = [];
|
|
77
|
+
res.on("data", (c) => chunks.push(c));
|
|
78
|
+
res.on("end", () => {
|
|
79
|
+
resolve({
|
|
80
|
+
status,
|
|
81
|
+
headers: res.headers,
|
|
82
|
+
body: Buffer.concat(chunks),
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
req.setTimeout(timeoutMs, () => {
|
|
88
|
+
req.destroy(new Error(`请求超时 (${timeoutMs}ms): ${method} ${url}`));
|
|
89
|
+
});
|
|
90
|
+
req.on("error", (e) => reject(new Error(`${method} ${url} 失败: ${e.message}`)));
|
|
91
|
+
|
|
92
|
+
if (body) req.write(body);
|
|
93
|
+
req.end();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function basicAuth(username, password) {
|
|
98
|
+
if (!username && !password) return {};
|
|
99
|
+
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
|
100
|
+
return { Authorization: `Basic ${token}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class WebDAVClient {
|
|
104
|
+
constructor({ baseUrl, username, password, timeoutMs = 60000, insecureTls = false }) {
|
|
105
|
+
if (!/^https?:\/\//i.test(baseUrl)) {
|
|
106
|
+
throw new Error(`WebDAV 地址必须以 http(s):// 开头:${baseUrl}`);
|
|
107
|
+
}
|
|
108
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
109
|
+
this.auth = basicAuth(username, password);
|
|
110
|
+
this.timeoutMs = timeoutMs;
|
|
111
|
+
this.insecureTls = insecureTls;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 拼接远端绝对 URL 路径 */
|
|
115
|
+
urlFor(remotePath) {
|
|
116
|
+
const clean = String(remotePath)
|
|
117
|
+
.split("/")
|
|
118
|
+
.filter((s) => s.length > 0)
|
|
119
|
+
.map((s) => encodeURIComponent(s))
|
|
120
|
+
.join("/");
|
|
121
|
+
return clean ? `${this.baseUrl}/${clean}` : this.baseUrl;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async #req(method, remotePath, { headers = {}, body = null } = {}) {
|
|
125
|
+
return request(this.urlFor(remotePath), {
|
|
126
|
+
method,
|
|
127
|
+
headers: { ...this.auth, ...headers },
|
|
128
|
+
body,
|
|
129
|
+
timeoutMs: this.timeoutMs,
|
|
130
|
+
insecureTls: this.insecureTls,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 连通性与认证检查 */
|
|
135
|
+
async check() {
|
|
136
|
+
const res = await this.#req("PROPFIND", "", {
|
|
137
|
+
headers: { Depth: "0", "Content-Type": "application/xml" },
|
|
138
|
+
});
|
|
139
|
+
if (res.status === 207 || res.status === 200) return { ok: true, status: res.status };
|
|
140
|
+
if (res.status === 401) return { ok: false, status: 401, error: "认证失败(401):用户名或密码/应用密码不对" };
|
|
141
|
+
if (res.status === 403) return { ok: false, status: 403, error: "拒绝访问(403):权限不足" };
|
|
142
|
+
if (res.status === 404) return { ok: false, status: 404, error: "路径不存在(404):检查 remote.url 是否写对" };
|
|
143
|
+
return { ok: false, status: res.status, error: `未知响应 ${res.status}` };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 递归创建目录,返回是否全部成功 */
|
|
147
|
+
async mkdirp(remotePath) {
|
|
148
|
+
const parts = String(remotePath).split("/").filter(Boolean);
|
|
149
|
+
let cur = "";
|
|
150
|
+
for (const part of parts) {
|
|
151
|
+
cur = cur ? `${cur}/${part}` : part;
|
|
152
|
+
const res = await this.#req("MKCOL", cur);
|
|
153
|
+
// 201 创建成功;405 已存在;301/302 部分服务端已存在
|
|
154
|
+
if (![201, 405, 301, 302].includes(res.status)) {
|
|
155
|
+
if (res.status === 409) {
|
|
156
|
+
// 父目录不存在,继续尝试下一层(通常意味着上层创建失败)
|
|
157
|
+
throw new Error(`创建目录失败 (409) : ${cur}`);
|
|
158
|
+
}
|
|
159
|
+
throw new Error(`创建目录 ${cur} 失败:HTTP ${res.status}`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async put(remotePath, buffer, { contentType = "application/octet-stream" } = {}) {
|
|
166
|
+
const res = await this.#req("PUT", remotePath, {
|
|
167
|
+
headers: { "Content-Type": contentType, "Content-Length": String(buffer.length) },
|
|
168
|
+
body: buffer,
|
|
169
|
+
});
|
|
170
|
+
if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status };
|
|
171
|
+
throw new Error(`上传 ${remotePath.split("/").pop()} 失败:HTTP ${res.status} ${res.body.toString("utf8").slice(0, 200)}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async get(remotePath) {
|
|
175
|
+
const res = await this.#req("GET", remotePath);
|
|
176
|
+
if (res.status === 200) return res.body;
|
|
177
|
+
if (res.status === 404) return null;
|
|
178
|
+
const hint = [301, 302, 303, 307, 308].includes(res.status)
|
|
179
|
+
? "(服务端要求重定向到对象存储,可能跳转次数超限)"
|
|
180
|
+
: "";
|
|
181
|
+
throw new Error(`下载 ${remotePath} 失败:HTTP ${res.status}${hint}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async stat(remotePath) {
|
|
185
|
+
const res = await this.#req("HEAD", remotePath);
|
|
186
|
+
if (res.status === 200) {
|
|
187
|
+
return {
|
|
188
|
+
exists: true,
|
|
189
|
+
size: Number(res.headers["content-length"] ?? 0),
|
|
190
|
+
lastModified: res.headers["last-modified"] ?? null,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (res.status === 404) return { exists: false };
|
|
194
|
+
// 有些服务端不支持 HEAD,降级为 PROPFIND
|
|
195
|
+
const p = await this.#req("PROPFIND", remotePath, { headers: { Depth: "0" } });
|
|
196
|
+
if (p.status === 207 || p.status === 200) {
|
|
197
|
+
const text = p.body.toString("utf8");
|
|
198
|
+
const size = /<D:getcontentlength>(\d+)<\/D:getcontentlength>/i.exec(text)?.[1]
|
|
199
|
+
?? /<getcontentlength[^>]*>(\d+)</i.exec(text)?.[1];
|
|
200
|
+
const lm = /<D:getlastmodified>([^<]+)<\/D:getlastmodified>/i.exec(text)?.[1]
|
|
201
|
+
?? /<getlastmodified[^>]*>([^<]+)</i.exec(text)?.[1];
|
|
202
|
+
return { exists: true, size: size ? Number(size) : 0, lastModified: lm ?? null };
|
|
203
|
+
}
|
|
204
|
+
if (p.status === 404) return { exists: false };
|
|
205
|
+
throw new Error(`查询 ${remotePath} 失败:HTTP ${p.status}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async del(remotePath) {
|
|
209
|
+
const res = await this.#req("DELETE", remotePath);
|
|
210
|
+
if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status };
|
|
211
|
+
if (res.status === 404) return { ok: true, status: 404 };
|
|
212
|
+
throw new Error(`删除 ${remotePath} 失败:HTTP ${res.status}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 列出目录内文件(只解析 href 与 getcontentlength) */
|
|
216
|
+
async list(remotePath) {
|
|
217
|
+
const res = await this.#req("PROPFIND", remotePath, {
|
|
218
|
+
headers: { Depth: "1", "Content-Type": "application/xml" },
|
|
219
|
+
});
|
|
220
|
+
if (res.status === 404) return [];
|
|
221
|
+
if (res.status !== 207 && res.status !== 200) {
|
|
222
|
+
throw new Error(`列出 ${remotePath} 失败:HTTP ${res.status}`);
|
|
223
|
+
}
|
|
224
|
+
const text = res.body.toString("utf8");
|
|
225
|
+
const entries = [];
|
|
226
|
+
const responseRe = /<(?:D:|d:|)[Rr]esponse>([\s\S]*?)<\/(?:D:|d:|)[Rr]esponse>/g;
|
|
227
|
+
let m;
|
|
228
|
+
while ((m = responseRe.exec(text))) {
|
|
229
|
+
const block = m[1];
|
|
230
|
+
const href =
|
|
231
|
+
/<(?:D:|d:|)[Hh]ref>([^<]+)<\/(?:D:|d:|)[Hh]ref>/.exec(block)?.[1] ?? "";
|
|
232
|
+
const size =
|
|
233
|
+
/<(?:D:|d:|)getcontentlength>(\d+)<\/(?:D:|d:|)getcontentlength>/i.exec(block)?.[1] ??
|
|
234
|
+
/<getcontentlength[^>]*>(\d+)</i.exec(block)?.[1];
|
|
235
|
+
const lm =
|
|
236
|
+
/<(?:D:|d:|)getlastmodified>([^<]+)<\/(?:D:|d:|)getlastmodified>/i.exec(block)?.[1] ??
|
|
237
|
+
/<getlastmodified[^>]*>([^<]+)</i.exec(block)?.[1];
|
|
238
|
+
const isCollection =
|
|
239
|
+
/<(?:D:|d:|)resourcetype>\s*<(?:D:|d:|)collection/i.test(block) ||
|
|
240
|
+
/<resourcetype[^>]*>\s*<collection/i.test(block);
|
|
241
|
+
if (!href) continue;
|
|
242
|
+
let name;
|
|
243
|
+
try {
|
|
244
|
+
name = decodeURIComponent(href.split("/").filter(Boolean).pop() ?? "");
|
|
245
|
+
} catch {
|
|
246
|
+
name = href.split("/").filter(Boolean).pop() ?? "";
|
|
247
|
+
}
|
|
248
|
+
entries.push({
|
|
249
|
+
href,
|
|
250
|
+
name,
|
|
251
|
+
size: size ? Number(size) : 0,
|
|
252
|
+
lastModified: lm ?? null,
|
|
253
|
+
isCollection,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return entries;
|
|
257
|
+
}
|
|
258
|
+
}
|