pi-webdav-backup 1.0.1 → 1.3.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.
@@ -3,7 +3,54 @@
3
3
  */
4
4
 
5
5
  import { readdirSync, statSync, lstatSync } from "node:fs";
6
- import { join, relative, sep, posix } from "node:path";
6
+ import { join, relative, sep, posix, resolve } from "node:path";
7
+
8
+ /**
9
+ * 把 agentDir 之外的绝对路径映射成归档内安全、无歧义的相对路径。
10
+ * C:\Users\me\.pi\settings.json → extra/C/Users/me/.pi/settings.json
11
+ * /home/me/.pi/settings.json → extra/home/me/.pi/settings.json
12
+ */
13
+ export function extraArchivePath(absPath) {
14
+ let p = resolve(String(absPath)).replace(/\\/g, "/");
15
+ p = p.replace(/^([A-Za-z]):\//, "$1/"); // 去掉盘符后的冒号,保留盘符作为首段
16
+ const segs = p.split("/").filter((s) => s && s !== "." && s !== "..");
17
+ return `extra/${segs.join("/")}`;
18
+ }
19
+
20
+ /**
21
+ * 该额外路径是否是“配置/数据类”文件(值得一并脱敏保护)。
22
+ * 目录一律视为需要保护(其下内容逐文件判断)。
23
+ */
24
+ export function isDataLikeExtraPath(absPath) {
25
+ const b = String(absPath).replace(/\\/g, "/").split("/").pop() ?? "";
26
+ const lower = b.toLowerCase();
27
+ return (
28
+ lower.endsWith(".json") ||
29
+ lower === ".env" ||
30
+ lower.startsWith(".env.") ||
31
+ /\.(ya?ml|toml|ini|conf|cfg|txt)$/i.test(lower) ||
32
+ ["config", "config.json", "settings", "auth", "secret", "secrets", "credentials"].includes(lower)
33
+ );
34
+ }
35
+
36
+ /**
37
+ * 从 extraScope 推导出归档内“需要脱敏”的路径集合。
38
+ * 目录 → "extra/.../dir/"(前缀匹配);数据类文件 → "extra/.../file"(精确匹配)。
39
+ */
40
+ export function deriveSensitivePaths(extraScope) {
41
+ const out = [];
42
+ for (const p of extraScope ?? []) {
43
+ try {
44
+ const st = statSync(p);
45
+ if (st.isDirectory()) out.push(`${extraArchivePath(p)}/`);
46
+ else if (st.isFile() && isDataLikeExtraPath(p)) out.push(extraArchivePath(p));
47
+ } catch {
48
+ /* 不存在就跳过(反正不会被收集) */
49
+ }
50
+ }
51
+ return out;
52
+ }
53
+
7
54
 
8
55
  /** glob -> RegExp(支持 **、*、?) */
9
56
  export function globToRegExp(glob) {
@@ -57,9 +104,10 @@ export function makeExcluder(patterns) {
57
104
  * @param {string} opts.agentDir 基准目录
58
105
  * @param {string[]} opts.scope 相对 agentDir 的路径列表
59
106
  * @param {string[]} opts.exclude glob 排除规则
107
+ * @param {string[]} [opts.extraScope] agentDir 之外的绝对路径(文件或目录)
60
108
  * @returns {{files: {abs: string, rel: string, size: number, mtimeMs: number}[], skipped: number, totalSize: number}}
61
109
  */
62
- export function collectFiles({ agentDir, scope, exclude, followSymlinks = false }) {
110
+ export function collectFiles({ agentDir, scope, exclude, extraScope = [], followSymlinks = false }) {
63
111
  const isExcluded = makeExcluder(exclude);
64
112
  const files = [];
65
113
  let skipped = 0;
@@ -119,6 +167,60 @@ export function collectFiles({ agentDir, scope, exclude, followSymlinks = false
119
167
  }
120
168
  }
121
169
 
170
+ // agentDir 之外的额外路径(~/.pi/settings.json、~/.config/rpiv-* 等)
171
+ for (const extra of extraScope) {
172
+ let st;
173
+ try {
174
+ st = lstatSync(extra);
175
+ } catch {
176
+ skipped++;
177
+ continue;
178
+ }
179
+ const baseRel = extraArchivePath(extra);
180
+ if (st.isDirectory()) {
181
+ walkExtra(extra, baseRel);
182
+ } else if (st.isFile()) {
183
+ if (!isExcluded(baseRel)) {
184
+ files.push({ abs: extra, rel: baseRel, size: st.size, mtimeMs: Math.floor(st.mtimeMs) });
185
+ }
186
+ } else {
187
+ skipped++;
188
+ }
189
+ }
190
+
191
+ function walkExtra(abs, relPath) {
192
+ let entries;
193
+ try {
194
+ entries = readdirSync(abs, { withFileTypes: true });
195
+ } catch {
196
+ return;
197
+ }
198
+ for (const ent of entries) {
199
+ const absPath = join(abs, ent.name);
200
+ const rel = `${relPath}/${ent.name}`;
201
+ if (isExcluded(rel)) {
202
+ skipped++;
203
+ continue;
204
+ }
205
+ let st2;
206
+ try {
207
+ st2 = followSymlinks ? statSync(absPath) : lstatSync(absPath);
208
+ } catch {
209
+ continue;
210
+ }
211
+ if (st2.isSymbolicLink()) {
212
+ skipped++;
213
+ continue;
214
+ }
215
+ if (st2.isDirectory()) walkExtra(absPath, rel);
216
+ else if (st2.isFile()) {
217
+ files.push({ abs: absPath, rel, size: st2.size, mtimeMs: Math.floor(st2.mtimeMs) });
218
+ } else {
219
+ skipped++;
220
+ }
221
+ }
222
+ }
223
+
122
224
  files.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
123
225
  const totalSize = files.reduce((n, f) => n + f.size, 0);
124
226
  return { files, skipped, totalSize };
@@ -25,13 +25,16 @@ function stripBom(text) {
25
25
  return text.replace(/^\uFEFF/, "");
26
26
  }
27
27
 
28
- const DEFAULT_EXCLUDE = [
28
+ /**
29
+ * 基础排除:这些内容要么是缓存、要么可重建、要么是密钥,任何情况下都不备份。
30
+ */
31
+ const BASE_EXCLUDE = [
29
32
  "tmp/**",
30
33
  "web-search-cache/**",
31
- "npm/node_modules/**",
32
34
  "sessions/**",
33
35
  "**/*.log",
34
- "**/node_modules/**",
36
+ // pi 管理的 fd/rg 二进制,换机后会自动重新下载,不值得备份
37
+ "bin/**",
35
38
  // 密钥相关,绝不能进备份
36
39
  "secrets/**",
37
40
  "secrets.dpapi.json",
@@ -43,6 +46,37 @@ const DEFAULT_EXCLUDE = [
43
46
  "**/*.save",
44
47
  ];
45
48
 
49
+ /**
50
+ * 插件实现代码的排除规则。
51
+ * - 默认(includePlugins: false):完全排除 node_modules(瘦备份,恢复后靠联网补装)
52
+ * - 开启(includePlugins: true):保留 node_modules,只排除其中运行时用不到、体积大的部分,
53
+ * 这样换机恢复后插件即可直接使用,无需联网重装。
54
+ */
55
+ const PLUGIN_EXCLUDE = ["**/node_modules/**", "npm/node_modules/**"];
56
+ const PLUGIN_KEEP_EXCLUDE = [
57
+ // git 仓库内部数据:运行时不需要,却可能很大、且含平台相关对象
58
+ "**/.git/**",
59
+ "**/node_modules/.cache/**",
60
+ "**/node_modules/.vite/**",
61
+ "**/node_modules/.turbo/**",
62
+ "**/node_modules/.bin/**",
63
+ ];
64
+
65
+ /** 按 includePlugins 组装最终排除列表 */
66
+ function buildExclude(userExclude, includePlugins) {
67
+ const extra = Array.isArray(userExclude) ? userExclude.map(String) : [];
68
+ const pluginRules = includePlugins ? PLUGIN_KEEP_EXCLUDE : PLUGIN_EXCLUDE;
69
+ return [...BASE_EXCLUDE, ...pluginRules, ...extra];
70
+ }
71
+
72
+ function expandTildeValue(p) {
73
+ const s = String(p ?? "").trim();
74
+ if (!s) return "";
75
+ if (s === "~") return homedir();
76
+ if (s.startsWith("~/") || s.startsWith("~\\")) return join(homedir(), s.slice(2));
77
+ return s;
78
+ }
79
+
46
80
  /**
47
81
  * 读取配置。返回 { ok, config, errors, warnings, raw }
48
82
  */
@@ -97,9 +131,14 @@ export function loadConfig() {
97
131
  remoteDir: String(remote.remoteDir ?? raw.remoteDir ?? "pi-backup").replace(/^\/+|\/+$/g, ""),
98
132
  remoteName: String(remote.remoteName ?? raw.remoteName ?? "pi-agent").trim(),
99
133
  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,
134
+ // 是否把插件实现代码(node_modules)一起备份。开启后换机恢复即可直接使用,无需联网重装。
135
+ includePlugins: raw.includePlugins === true,
136
+ // agentDir 之外的额外备份路径(绝对路径或 ~/...)。用于把 ~/.pi/settings.json、
137
+ // ~/.config/rpiv-* 等插件写在 agentDir 之外的数据一并带上。
138
+ extraScope: Array.isArray(raw.extraScope)
139
+ ? [...new Set(raw.extraScope.map(expandTildeValue).filter(Boolean))]
140
+ : [],
141
+ exclude: buildExclude(raw.exclude, raw.includePlugins === true),
103
142
  includeSessions: raw.includeSessions === true,
104
143
  snapshot: raw.snapshot === true,
105
144
  encrypt: raw.encrypt === true,
@@ -174,8 +213,15 @@ export function configTemplate() {
174
213
  },
175
214
  _说明_scope: "要备份的路径,相对 ~/.pi/agent",
176
215
  scope: ["."],
177
- _说明_exclude: "额外排除规则(glob),内置已排除 tmp/、sessions/、缓存、node_modules",
216
+ _说明_includePlugins:
217
+ "是否把插件实现代码(node_modules)一起备份。true=换机恢复后插件可直接用、无需联网重装(推荐);false=瘦备份,恢复后需 /backup repair-plugins 补装",
218
+ includePlugins: false,
219
+ _说明_exclude:
220
+ "额外排除规则(glob)。内置已排除 tmp/、sessions/、缓存、bin/、密钥副本;includePlugins=false 时另排除 node_modules",
178
221
  exclude: [],
222
+ _说明_extraScope:
223
+ "agentDir 之外的额外备份路径(绝对路径或 ~/...),例如 \"~/.pi/settings.json\"、\"~/.pi/web-search.json\"、\"~/.config/rpiv-todo\";归档内落在 extra/ 前缀下,恢复时按当前机器的家目录/XDG 锚点重映射(支持换用户名/盘符)",
224
+ extraScope: [],
179
225
  _说明_includeSessions: "是否包含会话历史(可能很大)",
180
226
  includeSessions: false,
181
227
  _说明_snapshot: "true=逐文件快照上传(增量,可单文件恢复);false=打包成单个 tar.gz(默认)",
@@ -1,145 +1,166 @@
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
- }
1
+ /**
2
+ * dav-mock.mjs — 内存 WebDAV 服务端(供测试使用)
3
+ * 支持 PROPFIND / MKCOL / PUT / GET / HEAD / DELETE + Basic Auth
4
+ *
5
+ * 可选:
6
+ * requireAuth: false 不校验认证(用于模拟对象存储)
7
+ * redirectGetsTo 所有 GET 都 302 到该地址 + 原路径
8
+ * (模拟 Cloudreve/群晖把下载重定向到对象存储)
9
+ */
10
+
11
+ import http from "node:http";
12
+
13
+ export function startDavServer({
14
+ username = "user",
15
+ password = "pass",
16
+ requireAuth = true,
17
+ redirectGetsTo = null,
18
+ } = {}) {
19
+ /** @type {Map<string, Buffer>} */
20
+ const store = new Map();
21
+ const dirs = new Set(["/"]);
22
+ const expectedAuth = "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
23
+ let putCount = 0;
24
+ let getCount = 0;
25
+ let lastAuth = null;
26
+
27
+ const server = http.createServer((req, res) => {
28
+ const path = decodeURIComponent((req.url ?? "/").split("?")[0]);
29
+ const key = path.replace(/\/+$/, "") || "/";
30
+ const method = (req.method ?? "GET").toUpperCase();
31
+ lastAuth = req.headers.authorization ?? null;
32
+
33
+ if (requireAuth && req.headers.authorization !== expectedAuth) {
34
+ res.writeHead(401, { "WWW-Authenticate": 'Basic realm="dav"' });
35
+ res.end("unauthorized");
36
+ return;
37
+ }
38
+
39
+ if (method === "PROPFIND") {
40
+ const depth = req.headers.depth ?? "0";
41
+ if (!dirs.has(key) && !store.has(key)) {
42
+ res.writeHead(404).end("not found");
43
+ return;
44
+ }
45
+ const prefix = key === "/" ? "/" : `${key}/`;
46
+ const children = [];
47
+ if (depth !== "0") {
48
+ for (const d of dirs) {
49
+ if (d === key) continue;
50
+ if (d.startsWith(prefix)) {
51
+ const rest = d.slice(prefix.length);
52
+ if (rest && !rest.includes("/")) {
53
+ children.push({ href: d, isDir: true, size: 0, lm: new Date().toUTCString() });
54
+ }
55
+ }
56
+ }
57
+ for (const [p, buf] of store) {
58
+ if (p.startsWith(prefix)) {
59
+ const rest = p.slice(prefix.length);
60
+ if (rest && !rest.includes("/")) {
61
+ children.push({ href: p, isDir: false, size: buf.length, lm: new Date().toUTCString() });
62
+ }
63
+ }
64
+ }
65
+ }
66
+ const responses = [
67
+ { href: key, isDir: !store.has(key), size: store.get(key)?.length ?? 0, lm: new Date().toUTCString() },
68
+ ...children,
69
+ ];
70
+ const xml =
71
+ `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n` +
72
+ responses
73
+ .map(
74
+ (e) =>
75
+ ` <D:response><D:href>${e.href}</D:href><D:propstat><D:prop>` +
76
+ `<D:getcontentlength>${e.size}</D:getcontentlength>` +
77
+ `<D:getlastmodified>${e.lm}</D:getlastmodified>` +
78
+ `<D:resourcetype>${e.isDir ? "<D:collection/>" : ""}</D:resourcetype>` +
79
+ `</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat></D:response>`,
80
+ )
81
+ .join("\n") +
82
+ `\n</D:multistatus>`;
83
+ res.writeHead(207, { "Content-Type": "application/xml" }).end(xml);
84
+ return;
85
+ }
86
+
87
+ if (method === "MKCOL") {
88
+ if (dirs.has(key) || store.has(key)) {
89
+ res.writeHead(405).end("exists");
90
+ return;
91
+ }
92
+ dirs.add(key);
93
+ res.writeHead(201).end();
94
+ return;
95
+ }
96
+
97
+ if (method === "PUT") {
98
+ const chunks = [];
99
+ req.on("data", (c) => chunks.push(c));
100
+ req.on("end", () => {
101
+ putCount++;
102
+ store.set(key, Buffer.concat(chunks));
103
+ res.writeHead(201).end();
104
+ });
105
+ return;
106
+ }
107
+
108
+ if (method === "GET") {
109
+ // 网盘行为:下载不在 WebDAV 侧,302 到对象存储的带签名临时 URL
110
+ if (redirectGetsTo) {
111
+ res.writeHead(302, { Location: `${redirectGetsTo}${path}` });
112
+ res.end();
113
+ return;
114
+ }
115
+ if (!store.has(key)) {
116
+ res.writeHead(404).end();
117
+ return;
118
+ }
119
+ getCount++;
120
+ const buf = store.get(key);
121
+ res.writeHead(200, { "Content-Length": String(buf.length) }).end(buf);
122
+ return;
123
+ }
124
+
125
+ if (method === "HEAD") {
126
+ if (!store.has(key)) {
127
+ res.writeHead(404).end();
128
+ return;
129
+ }
130
+ res.writeHead(200, { "Content-Length": String(store.get(key).length) }).end();
131
+ return;
132
+ }
133
+
134
+ if (method === "DELETE") {
135
+ if (store.delete(key) || dirs.delete(key)) {
136
+ res.writeHead(204).end();
137
+ return;
138
+ }
139
+ res.writeHead(404).end();
140
+ return;
141
+ }
142
+
143
+ res.writeHead(405).end("method not allowed");
144
+ });
145
+
146
+ return new Promise((resolve) => {
147
+ server.listen(0, "127.0.0.1", () => {
148
+ const addr = server.address();
149
+ const port = typeof addr === "object" && addr ? addr.port : 0;
150
+ resolve({
151
+ server,
152
+ port,
153
+ store,
154
+ dirs,
155
+ stats: () => ({ putCount, getCount }),
156
+ get lastAuth() {
157
+ return lastAuth;
158
+ },
159
+ reset: () => {
160
+ putCount = 0;
161
+ getCount = 0;
162
+ },
163
+ });
164
+ });
165
+ });
166
+ }