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,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
|
+
|
|
8
|
+
import http from "node:http";
|
|
9
|
+
import https from "node:https";
|
|
10
|
+
import { URL } from "node:url";
|
|
11
|
+
|
|
12
|
+
const UA = "pi-webdav-backup/1.0";
|
|
13
|
+
|
|
14
|
+
function request(url, { method, headers = {}, body = null, timeoutMs = 60000, insecureTls = false }) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
let u;
|
|
17
|
+
try {
|
|
18
|
+
u = new URL(url);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
reject(new Error(`非法 URL: ${url} (${e.message})`));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const isHttps = u.protocol === "https:";
|
|
25
|
+
const lib = isHttps ? https : http;
|
|
26
|
+
|
|
27
|
+
/** @type {import("node:http").RequestOptions} */
|
|
28
|
+
const opts = {
|
|
29
|
+
method,
|
|
30
|
+
hostname: u.hostname,
|
|
31
|
+
port: u.port || (isHttps ? 443 : 80),
|
|
32
|
+
path: `${u.pathname}${u.search}`,
|
|
33
|
+
headers: { "User-Agent": UA, ...headers },
|
|
34
|
+
agent: false,
|
|
35
|
+
};
|
|
36
|
+
if (isHttps && insecureTls) opts.rejectUnauthorized = false;
|
|
37
|
+
|
|
38
|
+
const req = lib.request(opts, (res) => {
|
|
39
|
+
const chunks = [];
|
|
40
|
+
res.on("data", (c) => chunks.push(c));
|
|
41
|
+
res.on("end", () => {
|
|
42
|
+
resolve({
|
|
43
|
+
status: res.statusCode ?? 0,
|
|
44
|
+
headers: res.headers,
|
|
45
|
+
body: Buffer.concat(chunks),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
req.setTimeout(timeoutMs, () => {
|
|
51
|
+
req.destroy(new Error(`请求超时 (${timeoutMs}ms): ${method} ${url}`));
|
|
52
|
+
});
|
|
53
|
+
req.on("error", (e) => reject(new Error(`${method} ${url} 失败: ${e.message}`)));
|
|
54
|
+
|
|
55
|
+
if (body) req.write(body);
|
|
56
|
+
req.end();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function basicAuth(username, password) {
|
|
61
|
+
if (!username && !password) return {};
|
|
62
|
+
const token = Buffer.from(`${username}:${password}`, "utf8").toString("base64");
|
|
63
|
+
return { Authorization: `Basic ${token}` };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class WebDAVClient {
|
|
67
|
+
constructor({ baseUrl, username, password, timeoutMs = 60000, insecureTls = false }) {
|
|
68
|
+
if (!/^https?:\/\//i.test(baseUrl)) {
|
|
69
|
+
throw new Error(`WebDAV 地址必须以 http(s):// 开头:${baseUrl}`);
|
|
70
|
+
}
|
|
71
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
72
|
+
this.auth = basicAuth(username, password);
|
|
73
|
+
this.timeoutMs = timeoutMs;
|
|
74
|
+
this.insecureTls = insecureTls;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 拼接远端绝对 URL 路径 */
|
|
78
|
+
urlFor(remotePath) {
|
|
79
|
+
const clean = String(remotePath)
|
|
80
|
+
.split("/")
|
|
81
|
+
.filter((s) => s.length > 0)
|
|
82
|
+
.map((s) => encodeURIComponent(s))
|
|
83
|
+
.join("/");
|
|
84
|
+
return clean ? `${this.baseUrl}/${clean}` : this.baseUrl;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async #req(method, remotePath, { headers = {}, body = null } = {}) {
|
|
88
|
+
return request(this.urlFor(remotePath), {
|
|
89
|
+
method,
|
|
90
|
+
headers: { ...this.auth, ...headers },
|
|
91
|
+
body,
|
|
92
|
+
timeoutMs: this.timeoutMs,
|
|
93
|
+
insecureTls: this.insecureTls,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 连通性与认证检查 */
|
|
98
|
+
async check() {
|
|
99
|
+
const res = await this.#req("PROPFIND", "", {
|
|
100
|
+
headers: { Depth: "0", "Content-Type": "application/xml" },
|
|
101
|
+
});
|
|
102
|
+
if (res.status === 207 || res.status === 200) return { ok: true, status: res.status };
|
|
103
|
+
if (res.status === 401) return { ok: false, status: 401, error: "认证失败(401):用户名或密码/应用密码不对" };
|
|
104
|
+
if (res.status === 403) return { ok: false, status: 403, error: "拒绝访问(403):权限不足" };
|
|
105
|
+
if (res.status === 404) return { ok: false, status: 404, error: "路径不存在(404):检查 remote.url 是否写对" };
|
|
106
|
+
return { ok: false, status: res.status, error: `未知响应 ${res.status}` };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 递归创建目录,返回是否全部成功 */
|
|
110
|
+
async mkdirp(remotePath) {
|
|
111
|
+
const parts = String(remotePath).split("/").filter(Boolean);
|
|
112
|
+
let cur = "";
|
|
113
|
+
for (const part of parts) {
|
|
114
|
+
cur = cur ? `${cur}/${part}` : part;
|
|
115
|
+
const res = await this.#req("MKCOL", cur);
|
|
116
|
+
// 201 创建成功;405 已存在;301/302 部分服务端已存在
|
|
117
|
+
if (![201, 405, 301, 302].includes(res.status)) {
|
|
118
|
+
if (res.status === 409) {
|
|
119
|
+
// 父目录不存在,继续尝试下一层(通常意味着上层创建失败)
|
|
120
|
+
throw new Error(`创建目录失败 (409) : ${cur}`);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`创建目录 ${cur} 失败:HTTP ${res.status}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async put(remotePath, buffer, { contentType = "application/octet-stream" } = {}) {
|
|
129
|
+
const res = await this.#req("PUT", remotePath, {
|
|
130
|
+
headers: { "Content-Type": contentType, "Content-Length": String(buffer.length) },
|
|
131
|
+
body: buffer,
|
|
132
|
+
});
|
|
133
|
+
if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status };
|
|
134
|
+
throw new Error(`上传 ${remotePath.split("/").pop()} 失败:HTTP ${res.status} ${res.body.toString("utf8").slice(0, 200)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async get(remotePath) {
|
|
138
|
+
const res = await this.#req("GET", remotePath);
|
|
139
|
+
if (res.status === 200) return res.body;
|
|
140
|
+
if (res.status === 404) return null;
|
|
141
|
+
throw new Error(`下载 ${remotePath} 失败:HTTP ${res.status}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async stat(remotePath) {
|
|
145
|
+
const res = await this.#req("HEAD", remotePath);
|
|
146
|
+
if (res.status === 200) {
|
|
147
|
+
return {
|
|
148
|
+
exists: true,
|
|
149
|
+
size: Number(res.headers["content-length"] ?? 0),
|
|
150
|
+
lastModified: res.headers["last-modified"] ?? null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (res.status === 404) return { exists: false };
|
|
154
|
+
// 有些服务端不支持 HEAD,降级为 PROPFIND
|
|
155
|
+
const p = await this.#req("PROPFIND", remotePath, { headers: { Depth: "0" } });
|
|
156
|
+
if (p.status === 207 || p.status === 200) {
|
|
157
|
+
const text = p.body.toString("utf8");
|
|
158
|
+
const size = /<D:getcontentlength>(\d+)<\/D:getcontentlength>/i.exec(text)?.[1]
|
|
159
|
+
?? /<getcontentlength[^>]*>(\d+)</i.exec(text)?.[1];
|
|
160
|
+
const lm = /<D:getlastmodified>([^<]+)<\/D:getlastmodified>/i.exec(text)?.[1]
|
|
161
|
+
?? /<getlastmodified[^>]*>([^<]+)</i.exec(text)?.[1];
|
|
162
|
+
return { exists: true, size: size ? Number(size) : 0, lastModified: lm ?? null };
|
|
163
|
+
}
|
|
164
|
+
if (p.status === 404) return { exists: false };
|
|
165
|
+
throw new Error(`查询 ${remotePath} 失败:HTTP ${p.status}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async del(remotePath) {
|
|
169
|
+
const res = await this.#req("DELETE", remotePath);
|
|
170
|
+
if (res.status >= 200 && res.status < 300) return { ok: true, status: res.status };
|
|
171
|
+
if (res.status === 404) return { ok: true, status: 404 };
|
|
172
|
+
throw new Error(`删除 ${remotePath} 失败:HTTP ${res.status}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 列出目录内文件(只解析 href 与 getcontentlength) */
|
|
176
|
+
async list(remotePath) {
|
|
177
|
+
const res = await this.#req("PROPFIND", remotePath, {
|
|
178
|
+
headers: { Depth: "1", "Content-Type": "application/xml" },
|
|
179
|
+
});
|
|
180
|
+
if (res.status === 404) return [];
|
|
181
|
+
if (res.status !== 207 && res.status !== 200) {
|
|
182
|
+
throw new Error(`列出 ${remotePath} 失败:HTTP ${res.status}`);
|
|
183
|
+
}
|
|
184
|
+
const text = res.body.toString("utf8");
|
|
185
|
+
const entries = [];
|
|
186
|
+
const responseRe = /<(?:D:|d:|)[Rr]esponse>([\s\S]*?)<\/(?:D:|d:|)[Rr]esponse>/g;
|
|
187
|
+
let m;
|
|
188
|
+
while ((m = responseRe.exec(text))) {
|
|
189
|
+
const block = m[1];
|
|
190
|
+
const href =
|
|
191
|
+
/<(?:D:|d:|)[Hh]ref>([^<]+)<\/(?:D:|d:|)[Hh]ref>/.exec(block)?.[1] ?? "";
|
|
192
|
+
const size =
|
|
193
|
+
/<(?:D:|d:|)getcontentlength>(\d+)<\/(?:D:|d:|)getcontentlength>/i.exec(block)?.[1] ??
|
|
194
|
+
/<getcontentlength[^>]*>(\d+)</i.exec(block)?.[1];
|
|
195
|
+
const lm =
|
|
196
|
+
/<(?:D:|d:|)getlastmodified>([^<]+)<\/(?:D:|d:|)getlastmodified>/i.exec(block)?.[1] ??
|
|
197
|
+
/<getlastmodified[^>]*>([^<]+)</i.exec(block)?.[1];
|
|
198
|
+
const isCollection =
|
|
199
|
+
/<(?:D:|d:|)resourcetype>\s*<(?:D:|d:|)collection/i.test(block) ||
|
|
200
|
+
/<resourcetype[^>]*>\s*<collection/i.test(block);
|
|
201
|
+
if (!href) continue;
|
|
202
|
+
let name;
|
|
203
|
+
try {
|
|
204
|
+
name = decodeURIComponent(href.split("/").filter(Boolean).pop() ?? "");
|
|
205
|
+
} catch {
|
|
206
|
+
name = href.split("/").filter(Boolean).pop() ?? "";
|
|
207
|
+
}
|
|
208
|
+
entries.push({
|
|
209
|
+
href,
|
|
210
|
+
name,
|
|
211
|
+
size: size ? Number(size) : 0,
|
|
212
|
+
lastModified: lm ?? null,
|
|
213
|
+
isCollection,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return entries;
|
|
217
|
+
}
|
|
218
|
+
}
|