@narumitw/pi-sync 0.30.0 → 0.34.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.
@@ -0,0 +1,390 @@
1
+ import { XMLParser } from "fast-xml-parser";
2
+ import type { RemoteObject, ResolvedWebDavBackend } from "./types.js";
3
+
4
+ const JSON_LIMIT = 1024 * 1024;
5
+ const ERROR_LIMIT = 64 * 1024;
6
+ const SNAPSHOT_LIMIT = 256 * 1024 * 1024;
7
+ const XML_LIMIT = 1024 * 1024;
8
+ const REQUEST_TIMEOUT_MS = 30_000;
9
+ const MAX_REDIRECTS = 3;
10
+
11
+ export interface WebDavPutOptions {
12
+ ifAbsent?: boolean;
13
+ ifMatch?: string;
14
+ }
15
+
16
+ export interface WebDavEntry {
17
+ href: string;
18
+ etag?: string;
19
+ collection: boolean;
20
+ }
21
+
22
+ export class WebDavHttpError extends Error {
23
+ constructor(
24
+ message: string,
25
+ readonly status: number,
26
+ options?: ErrorOptions,
27
+ ) {
28
+ super(message, options);
29
+ this.name = "WebDavHttpError";
30
+ }
31
+ }
32
+
33
+ export class WebDavPreconditionError extends WebDavHttpError {
34
+ constructor(message = "WebDAV precondition failed.") {
35
+ super(message, 412);
36
+ this.name = "WebDavPreconditionError";
37
+ }
38
+ }
39
+
40
+ export class WebDavClient {
41
+ private readonly baseUrl: URL;
42
+ private readonly authorization: string;
43
+
44
+ constructor(
45
+ private readonly config: ResolvedWebDavBackend,
46
+ private readonly signal?: AbortSignal,
47
+ private readonly timeoutMs = REQUEST_TIMEOUT_MS,
48
+ ) {
49
+ this.baseUrl = new URL(config.profile.url);
50
+ assertSafeAuthenticatedUrl(this.baseUrl);
51
+ if (
52
+ !config.profile.username ||
53
+ !config.profile.password ||
54
+ config.profile.username.includes(":") ||
55
+ hasControlCharacter(config.profile.username) ||
56
+ hasControlCharacter(config.profile.password)
57
+ ) {
58
+ throw new Error("Invalid WebDAV credentials.");
59
+ }
60
+ this.authorization = `Basic ${Buffer.from(`${config.profile.username}:${config.profile.password}`).toString("base64")}`;
61
+ }
62
+
63
+ async getBuffer(remotePath: string): Promise<RemoteObject<Buffer>> {
64
+ const response = await this.request(remotePath, { method: "GET" });
65
+ if (response.status === 404) {
66
+ await response.body?.cancel();
67
+ return { missing: true };
68
+ }
69
+ await this.requireOk(response, "read");
70
+ return {
71
+ value: await readBounded(response, SNAPSHOT_LIMIT, "WebDAV response is too large"),
72
+ etag: response.headers.get("etag") ?? undefined,
73
+ missing: false,
74
+ };
75
+ }
76
+
77
+ async getJson<T>(remotePath: string): Promise<RemoteObject<T>> {
78
+ const response = await this.request(remotePath, { method: "GET" });
79
+ if (response.status === 404) {
80
+ await response.body?.cancel();
81
+ return { missing: true };
82
+ }
83
+ await this.requireOk(response, "read");
84
+ const bytes = await readBounded(response, JSON_LIMIT, "WebDAV JSON response is too large");
85
+ try {
86
+ return {
87
+ value: JSON.parse(bytes.toString("utf8")) as T,
88
+ etag: response.headers.get("etag") ?? undefined,
89
+ missing: false,
90
+ };
91
+ } catch (error) {
92
+ throw new Error("WebDAV JSON response is malformed.", { cause: error });
93
+ }
94
+ }
95
+
96
+ async putBuffer(
97
+ remotePath: string,
98
+ body: Buffer,
99
+ contentType: string,
100
+ options: WebDavPutOptions = {},
101
+ ) {
102
+ const headers: Record<string, string> = { "content-type": contentType };
103
+ if (options.ifAbsent) headers["if-none-match"] = "*";
104
+ if (options.ifMatch) headers["if-match"] = options.ifMatch;
105
+ const response = await this.request(remotePath, {
106
+ method: "PUT",
107
+ headers,
108
+ body: body as unknown as BodyInit,
109
+ });
110
+ if (response.status === 412) {
111
+ await response.body?.cancel();
112
+ throw new WebDavPreconditionError();
113
+ }
114
+ await this.requireOk(response, "write");
115
+ await response.body?.cancel();
116
+ return response.headers.get("etag") ?? undefined;
117
+ }
118
+
119
+ async putJson(remotePath: string, value: unknown, options: WebDavPutOptions = {}) {
120
+ return this.putBuffer(
121
+ remotePath,
122
+ Buffer.from(JSON.stringify(value)),
123
+ "application/json",
124
+ options,
125
+ );
126
+ }
127
+
128
+ async makeCollection(remotePath: string) {
129
+ const response = await this.request(remotePath, { method: "MKCOL" });
130
+ if (response.status === 405) {
131
+ await response.body?.cancel();
132
+ return false;
133
+ }
134
+ await this.requireOk(response, "create collection");
135
+ await response.body?.cancel();
136
+ return true;
137
+ }
138
+
139
+ async ensureCollection(remotePath: string) {
140
+ const segments = safeSegments(remotePath);
141
+ for (let index = 1; index <= segments.length; index += 1) {
142
+ await this.makeCollection(segments.slice(0, index).join("/"));
143
+ }
144
+ }
145
+
146
+ async listCollection(remotePath: string): Promise<WebDavEntry[]> {
147
+ const response = await this.request(remotePath, {
148
+ method: "PROPFIND",
149
+ headers: { depth: "1", "content-type": "application/xml; charset=utf-8" },
150
+ body: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>',
151
+ });
152
+ if (response.status === 404) {
153
+ await response.body?.cancel();
154
+ throw new WebDavHttpError("WebDAV collection is missing.", 404);
155
+ }
156
+ if (response.status !== 207) await this.requireOk(response, "list collection");
157
+ const xml = (
158
+ await readBounded(response, XML_LIMIT, "WebDAV directory response is too large")
159
+ ).toString("utf8");
160
+ try {
161
+ const parsed = new XMLParser({
162
+ removeNSPrefix: true,
163
+ ignoreAttributes: false,
164
+ processEntities: false,
165
+ }).parse(xml) as {
166
+ multistatus?: { response?: unknown };
167
+ };
168
+ if (!parsed.multistatus || parsed.multistatus.response === undefined) {
169
+ throw new Error("Missing DAV multistatus response.");
170
+ }
171
+ const responses = asArray(parsed.multistatus.response);
172
+ return responses.map(parseEntry);
173
+ } catch (error) {
174
+ throw new Error("WebDAV directory response is malformed.", { cause: error });
175
+ }
176
+ }
177
+
178
+ async delete(remotePath: string) {
179
+ const response = await this.request(remotePath, { method: "DELETE" });
180
+ if (response.status === 404) {
181
+ await response.body?.cancel();
182
+ return;
183
+ }
184
+ if (response.status === 207) {
185
+ await response.body?.cancel();
186
+ throw new WebDavHttpError("WebDAV probe cleanup returned a partial response.", 207);
187
+ }
188
+ await this.requireOk(response, "delete probe resource");
189
+ await response.body?.cancel();
190
+ }
191
+
192
+ private async request(remotePath: string, init: RequestInit) {
193
+ throwIfAborted(this.signal);
194
+ let url = appendRemotePath(this.baseUrl, remotePath);
195
+ for (let redirects = 0; ; redirects += 1) {
196
+ const timeout = AbortSignal.timeout(this.timeoutMs);
197
+ const signal = this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
198
+ let response: Response;
199
+ try {
200
+ response = await fetch(url, {
201
+ ...init,
202
+ headers: {
203
+ accept: "*/*",
204
+ authorization: this.authorization,
205
+ ...headersObject(init.headers),
206
+ },
207
+ redirect: "manual",
208
+ signal,
209
+ });
210
+ } catch (error) {
211
+ throwIfAborted(this.signal);
212
+ throw new Error(
213
+ `WebDAV ${String(init.method ?? "GET")} request failed for ${redactText(safeUrl(url), this.config)}.`,
214
+ {
215
+ cause: redactError(error, this.config),
216
+ },
217
+ );
218
+ }
219
+ if (![301, 302, 303, 307, 308].includes(response.status)) return response;
220
+ await response.body?.cancel();
221
+ const method = String(init.method ?? "GET").toUpperCase();
222
+ if ([301, 302, 303].includes(response.status) && method !== "GET" && method !== "HEAD") {
223
+ throw new Error(
224
+ `WebDAV refused an ambiguous HTTP ${response.status} redirect for ${method}; configure the canonical collection URL or require HTTP 307/308.`,
225
+ );
226
+ }
227
+ if (redirects >= MAX_REDIRECTS) throw new Error("WebDAV redirect limit exceeded.");
228
+ const location = response.headers.get("location");
229
+ if (!location) throw new Error("WebDAV redirect is missing Location.");
230
+ const next = new URL(location, url);
231
+ if (next.origin !== this.baseUrl.origin) {
232
+ throw new Error("WebDAV refused a cross-origin authenticated redirect.");
233
+ }
234
+ url = next;
235
+ }
236
+ }
237
+
238
+ private async requireOk(response: Response, action: string) {
239
+ if (response.ok) return;
240
+ const body = (await readBounded(response, ERROR_LIMIT, "WebDAV error body is too large"))
241
+ .toString("utf8")
242
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote error text is untrusted terminal input.
243
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
244
+ .trim();
245
+ const detail = body ? `: ${redactText(body, this.config)}` : "";
246
+ throw new WebDavHttpError(
247
+ `WebDAV ${action} failed with HTTP ${response.status}${detail}`,
248
+ response.status,
249
+ );
250
+ }
251
+ }
252
+
253
+ function assertSafeAuthenticatedUrl(url: URL) {
254
+ const loopback =
255
+ url.hostname === "127.0.0.1" ||
256
+ url.hostname === "localhost" ||
257
+ url.hostname === "[::1]" ||
258
+ url.hostname === "::1";
259
+ if (
260
+ url.username ||
261
+ url.password ||
262
+ url.search ||
263
+ url.hash ||
264
+ (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
265
+ ) {
266
+ throw new Error(
267
+ "Invalid WebDAV URL: HTTPS is required and embedded credentials, query, or fragment are not allowed.",
268
+ );
269
+ }
270
+ }
271
+
272
+ function hasControlCharacter(value: string) {
273
+ return [...value].some((character) => {
274
+ const code = character.codePointAt(0) ?? 0;
275
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
276
+ });
277
+ }
278
+
279
+ function appendRemotePath(base: URL, remotePath: string) {
280
+ const url = new URL(base);
281
+ const suffix = safeSegments(remotePath).map(encodeURIComponent).join("/");
282
+ url.pathname = `${base.pathname.replace(/\/+$/u, "")}/${suffix}`;
283
+ return url;
284
+ }
285
+
286
+ function safeSegments(value: string) {
287
+ const segments = value
288
+ .replace(/^\/+|\/+$/gu, "")
289
+ .split("/")
290
+ .filter(Boolean);
291
+ if (
292
+ segments.some(
293
+ (segment) =>
294
+ segment === "." ||
295
+ segment === ".." ||
296
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: Remote path segments cannot contain controls.
297
+ /[\u0000-\u001f]/u.test(segment),
298
+ )
299
+ ) {
300
+ throw new Error("Invalid WebDAV remote path.");
301
+ }
302
+ return segments;
303
+ }
304
+
305
+ async function readBounded(response: Response, limit: number, message: string) {
306
+ const length = Number(response.headers.get("content-length"));
307
+ if (Number.isFinite(length) && length > limit) {
308
+ await response.body?.cancel();
309
+ throw new Error(message);
310
+ }
311
+ if (!response.body) return Buffer.alloc(0);
312
+ const reader = response.body.getReader();
313
+ const chunks: Buffer[] = [];
314
+ let total = 0;
315
+ try {
316
+ while (true) {
317
+ const { done, value } = await reader.read();
318
+ if (done) break;
319
+ total += value.byteLength;
320
+ if (total > limit) throw new Error(message);
321
+ chunks.push(Buffer.from(value));
322
+ }
323
+ } catch (error) {
324
+ await reader.cancel().catch(() => undefined);
325
+ throw error;
326
+ }
327
+ return Buffer.concat(chunks, total);
328
+ }
329
+
330
+ function parseEntry(value: unknown): WebDavEntry {
331
+ if (!value || typeof value !== "object") throw new Error("Invalid WebDAV response entry.");
332
+ const record = value as Record<string, unknown>;
333
+ const href = typeof record.href === "string" ? record.href : undefined;
334
+ if (!href) throw new Error("Invalid WebDAV response href.");
335
+ const propstat = asArray(record.propstat).find((item) => {
336
+ if (!item || typeof item !== "object") return false;
337
+ return String((item as Record<string, unknown>).status ?? "").includes(" 200 ");
338
+ }) as Record<string, unknown> | undefined;
339
+ const prop = propstat?.prop as Record<string, unknown> | undefined;
340
+ return {
341
+ href,
342
+ etag: typeof prop?.getetag === "string" ? prop.getetag : undefined,
343
+ collection:
344
+ !!prop?.resourcetype &&
345
+ typeof prop.resourcetype === "object" &&
346
+ Object.hasOwn(prop.resourcetype, "collection"),
347
+ };
348
+ }
349
+
350
+ function asArray(value: unknown): unknown[] {
351
+ if (value === undefined) return [];
352
+ return Array.isArray(value) ? value : [value];
353
+ }
354
+
355
+ function headersObject(headers: HeadersInit | undefined) {
356
+ return Object.fromEntries(new Headers(headers).entries());
357
+ }
358
+
359
+ function safeUrl(url: URL) {
360
+ return `${url.protocol}//${url.host}${url.pathname}`;
361
+ }
362
+
363
+ function redactError(error: unknown, config: ResolvedWebDavBackend) {
364
+ const message = error instanceof Error ? error.message : String(error);
365
+ return new Error(redactText(message, config));
366
+ }
367
+
368
+ function redactText(value: string, config: ResolvedWebDavBackend) {
369
+ let result = value;
370
+ for (const secret of [config.profile.username, config.profile.password, config.profile.url]) {
371
+ if (!secret) continue;
372
+ for (const variant of new Set([secret, encodeURIComponent(secret)])) {
373
+ result = result.replace(new RegExp(escapeRegExp(variant), "giu"), "[redacted]");
374
+ }
375
+ }
376
+ return result
377
+ .replace(/basic\s+[a-z0-9+/=]+/giu, "Basic [redacted]")
378
+ .replace(/\?[^\s]*/gu, "?[redacted]");
379
+ }
380
+
381
+ function escapeRegExp(value: string) {
382
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
383
+ }
384
+
385
+ function throwIfAborted(signal?: AbortSignal) {
386
+ if (!signal?.aborted) return;
387
+ throw signal.reason instanceof Error
388
+ ? signal.reason
389
+ : new DOMException("The operation was aborted", "AbortError");
390
+ }
@@ -0,0 +1,92 @@
1
+ const DEFAULT_PATH = "pi-sync";
2
+
3
+ export function normalizeWebDavIdentityUrl(value: string) {
4
+ try {
5
+ const url = new URL(value.trim());
6
+ url.username = "";
7
+ url.password = "";
8
+ url.search = "";
9
+ url.hash = "";
10
+ url.pathname = `${url.pathname.replace(/\/+$/u, "")}/`;
11
+ return url.toString();
12
+ } catch {
13
+ return value.trim();
14
+ }
15
+ }
16
+
17
+ export function normalizeWebDavUrl(value: string | undefined) {
18
+ const normalized = normalizeOptionalString(value);
19
+ if (!normalized) return undefined;
20
+ let url: URL;
21
+ try {
22
+ url = new URL(normalized);
23
+ } catch {
24
+ throw new Error("Invalid pi-sync WebDAV URL.");
25
+ }
26
+ if (url.username || url.password || url.search || url.hash) {
27
+ throw new Error(
28
+ "Invalid pi-sync WebDAV URL: credentials, query, and fragment are not allowed.",
29
+ );
30
+ }
31
+ const loopback =
32
+ url.hostname === "127.0.0.1" ||
33
+ url.hostname === "localhost" ||
34
+ url.hostname === "[::1]" ||
35
+ url.hostname === "::1";
36
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
37
+ throw new Error("Invalid pi-sync WebDAV URL: HTTPS is required except for loopback.");
38
+ }
39
+ url.pathname = `${url.pathname.replace(/\/+$/u, "")}/`;
40
+ return url.toString();
41
+ }
42
+
43
+ export function normalizeWebDavPath(value: string | undefined) {
44
+ const normalized = trimSlashes(normalizeOptionalString(value) ?? DEFAULT_PATH);
45
+ if (
46
+ !normalized ||
47
+ normalized.includes("\\") ||
48
+ hasControlCharacter(normalized) ||
49
+ normalized.split("/").some((segment) => !segment || segment === "." || segment === "..")
50
+ ) {
51
+ throw new Error("Invalid pi-sync WebDAV path.");
52
+ }
53
+ return normalized;
54
+ }
55
+
56
+ export function validateWebDavNamespace(value: string) {
57
+ if (
58
+ value === "." ||
59
+ value === ".." ||
60
+ value.includes("/") ||
61
+ value.includes("\\") ||
62
+ hasControlCharacter(value)
63
+ ) {
64
+ throw new Error("Invalid pi-sync WebDAV namespace.");
65
+ }
66
+ }
67
+
68
+ export function validateWebDavCredentials(username: string, password?: string) {
69
+ if (
70
+ username.includes(":") ||
71
+ hasControlCharacter(username) ||
72
+ (password !== undefined && hasControlCharacter(password))
73
+ ) {
74
+ throw new Error("Invalid pi-sync WebDAV credentials.");
75
+ }
76
+ }
77
+
78
+ function normalizeOptionalString(value: string | undefined) {
79
+ const normalized = value?.trim();
80
+ return normalized || undefined;
81
+ }
82
+
83
+ function trimSlashes(value: string) {
84
+ return value.replace(/^\/+|\/+$/gu, "");
85
+ }
86
+
87
+ function hasControlCharacter(value: string) {
88
+ return [...value].some((character) => {
89
+ const code = character.codePointAt(0) ?? 0;
90
+ return code < 0x20 || (code >= 0x7f && code <= 0x9f);
91
+ });
92
+ }