@narumitw/pi-sync 0.50.2 → 0.51.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 +4 -1
- package/dist/chunks/{chunk-6LP3MCR2.ts → chunk-2JZFHNLM.ts} +3 -5
- package/dist/chunks/chunk-2JZFHNLM.ts.map +7 -0
- package/dist/chunks/{chunk-LCRI3ABW.ts → chunk-2RZSQWBH.ts} +8 -19
- package/dist/chunks/chunk-2RZSQWBH.ts.map +7 -0
- package/dist/chunks/{chunk-IJ6FJFA5.ts → chunk-4AIHXRKE.ts} +11 -17
- package/dist/chunks/chunk-4AIHXRKE.ts.map +7 -0
- package/dist/chunks/{chunk-C7XY5NF5.ts → chunk-4MPIH46N.ts} +29 -51
- package/dist/chunks/chunk-4MPIH46N.ts.map +7 -0
- package/dist/chunks/{chunk-5B3FXBLE.ts → chunk-6546TS54.ts} +16 -20
- package/dist/chunks/chunk-6546TS54.ts.map +7 -0
- package/dist/chunks/{chunk-Q2TUM5X6.ts → chunk-6NT3YFGY.ts} +2 -2
- package/dist/chunks/chunk-6NT3YFGY.ts.map +7 -0
- package/dist/chunks/{chunk-2B2ELUF5.ts → chunk-7D5SF7P3.ts} +1 -1
- package/dist/chunks/chunk-7D5SF7P3.ts.map +7 -0
- package/dist/chunks/{chunk-4CHTZMEO.ts → chunk-7KHKWL3Q.ts} +37 -55
- package/dist/chunks/chunk-7KHKWL3Q.ts.map +7 -0
- package/dist/chunks/{chunk-ZI5MXMEX.ts → chunk-BVVP33U5.ts} +2 -4
- package/dist/chunks/chunk-BVVP33U5.ts.map +7 -0
- package/dist/chunks/{chunk-SZWBGPID.ts → chunk-DM4X4FIA.ts} +8 -13
- package/dist/chunks/chunk-DM4X4FIA.ts.map +7 -0
- package/dist/chunks/{chunk-SL64RLXR.ts → chunk-DZUSTGQV.ts} +8 -16
- package/dist/chunks/chunk-DZUSTGQV.ts.map +7 -0
- package/dist/chunks/{chunk-G4RNQQZS.ts → chunk-E42GXD6D.ts} +13 -19
- package/dist/chunks/chunk-E42GXD6D.ts.map +7 -0
- package/dist/chunks/{chunk-Z4DQF6L3.ts → chunk-ECYCNZOE.ts} +1 -1
- package/dist/chunks/chunk-ECYCNZOE.ts.map +7 -0
- package/dist/chunks/{chunk-OEX4ABY6.ts → chunk-EEJPYTWE.ts} +11 -21
- package/dist/chunks/chunk-EEJPYTWE.ts.map +7 -0
- package/dist/chunks/{chunk-CPEHGXJV.ts → chunk-G4RUXRWZ.ts} +3 -5
- package/dist/chunks/chunk-G4RUXRWZ.ts.map +7 -0
- package/dist/chunks/{chunk-T2B5IU74.ts → chunk-HGJH56KR.ts} +4 -4
- package/dist/chunks/chunk-HGJH56KR.ts.map +7 -0
- package/dist/chunks/{chunk-JAAKLL6M.ts → chunk-IVXEDPAD.ts} +10 -34
- package/dist/chunks/chunk-IVXEDPAD.ts.map +7 -0
- package/dist/chunks/chunk-K3RIEL6J.ts +20 -0
- package/dist/chunks/chunk-K3RIEL6J.ts.map +7 -0
- package/dist/chunks/{chunk-LTBWGSBV.ts → chunk-MSSW6F3R.ts} +1 -1
- package/dist/chunks/chunk-MSSW6F3R.ts.map +7 -0
- package/dist/chunks/{chunk-T3HH3UEJ.ts → chunk-OVDTF5CH.ts} +4 -10
- package/dist/chunks/chunk-OVDTF5CH.ts.map +7 -0
- package/dist/chunks/{chunk-NJFUIVYL.ts → chunk-PWMWOJP4.ts} +1 -1
- package/dist/chunks/chunk-PWMWOJP4.ts.map +7 -0
- package/dist/chunks/{chunk-HUDQATCL.ts → chunk-Q32CQWPM.ts} +6 -8
- package/dist/chunks/chunk-Q32CQWPM.ts.map +7 -0
- package/dist/chunks/{chunk-ETTRJDNP.ts → chunk-QLSN7AGX.ts} +6 -13
- package/dist/chunks/chunk-QLSN7AGX.ts.map +7 -0
- package/dist/chunks/{chunk-THMF4BVL.ts → chunk-SWUXA3MH.ts} +1 -1
- package/dist/chunks/chunk-SWUXA3MH.ts.map +7 -0
- package/dist/chunks/{chunk-TEN2ZBJV.ts → chunk-T2SJD3N7.ts} +2 -4
- package/dist/chunks/chunk-T2SJD3N7.ts.map +7 -0
- package/dist/chunks/{chunk-AUSDLD6F.ts → chunk-T56RNRZJ.ts} +10 -26
- package/dist/chunks/chunk-T56RNRZJ.ts.map +7 -0
- package/dist/chunks/{chunk-KW7YQEZN.ts → chunk-UA7RXOIT.ts} +48 -100
- package/dist/chunks/chunk-UA7RXOIT.ts.map +7 -0
- package/dist/chunks/{chunk-D5X27LCA.ts → chunk-UETBKTDI.ts} +1 -1
- package/dist/chunks/chunk-UETBKTDI.ts.map +7 -0
- package/dist/chunks/{chunk-CHH26D4F.ts → chunk-YFADF5ZQ.ts} +7 -6
- package/dist/chunks/chunk-YFADF5ZQ.ts.map +7 -0
- package/dist/chunks/{chunk-CCSN3F47.ts → chunk-YPXGU5OR.ts} +4 -4
- package/dist/chunks/chunk-YPXGU5OR.ts.map +7 -0
- package/dist/chunks/{file-selection-FTTKAOYX.ts → file-selection-RAVHRYFG.ts} +14 -26
- package/dist/chunks/file-selection-RAVHRYFG.ts.map +7 -0
- package/dist/chunks/{git-backend-GMVS4PD4.ts → git-backend-IAANDL3K.ts} +41 -104
- package/dist/chunks/git-backend-IAANDL3K.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-OTUMQM26.ts +23 -0
- package/dist/chunks/{manager-ui-5KLKVMRC.ts → manager-ui-763NEKJY.ts} +124 -251
- package/dist/chunks/manager-ui-763NEKJY.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-TZTJF32Y.ts +22 -0
- package/dist/chunks/{s3-backend-66O4FGBH.ts → s3-backend-XGXUOPUN.ts} +23 -54
- package/dist/chunks/s3-backend-XGXUOPUN.ts.map +7 -0
- package/dist/chunks/{setup-switch-IS2PEZ3J.ts → setup-switch-5G2VNJHL.ts} +9 -9
- package/dist/chunks/{setup-wizard-IUB6ESEU.ts → setup-wizard-YIEWGX32.ts} +12 -12
- package/dist/chunks/setup-wizard-YIEWGX32.ts.map +7 -0
- package/dist/chunks/{snapshot-HKEWCVK6.ts → snapshot-JL5DFSXI.ts} +5 -5
- package/dist/chunks/sync-inspection-P3ENJLNF.ts +20 -0
- package/dist/chunks/{sync-operations-LBX3GKCM.ts → sync-operations-AR76EB6C.ts} +85 -201
- package/dist/chunks/sync-operations-AR76EB6C.ts.map +7 -0
- package/dist/chunks/{sync-state-Q2FTXTW6.ts → sync-state-VHJUZ4YG.ts} +6 -6
- package/dist/chunks/{webdav-backend-ECIKD7SO.ts → webdav-backend-3EGUONAT.ts} +20 -51
- package/dist/chunks/webdav-backend-3EGUONAT.ts.map +7 -0
- package/dist/index.ts +49 -74
- package/dist/index.ts.map +3 -3
- package/docs/settings.md +9 -0
- package/package.json +61 -62
- package/src/backends/backend-factory.ts +15 -15
- package/src/backends/backend-identity.ts +31 -31
- package/src/backends/backend-types.ts +42 -42
- package/src/backends/git/git-backend.ts +714 -788
- package/src/backends/git/git-config.ts +104 -111
- package/src/backends/git/git-runner.ts +230 -247
- package/src/backends/git/git-storage.ts +228 -245
- package/src/backends/s3/s3-backend.ts +408 -441
- package/src/backends/s3/s3-client.ts +288 -333
- package/src/backends/sync-backend.ts +65 -65
- package/src/backends/webdav/webdav-backend.ts +542 -589
- package/src/backends/webdav/webdav-client.ts +292 -316
- package/src/backends/webdav/webdav-config.ts +57 -68
- package/src/commands/command-execution.ts +270 -289
- package/src/commands/command-handler.ts +160 -163
- package/src/commands/command-types.ts +13 -13
- package/src/commands/command.ts +190 -196
- package/src/commands/setup-completions.ts +1 -1
- package/src/paths.ts +47 -47
- package/src/settings/config-errors.ts +4 -5
- package/src/settings/config-file.ts +473 -506
- package/src/settings/config.ts +171 -174
- package/src/settings/settings-management.ts +191 -216
- package/src/settings/settings-store.ts +63 -77
- package/src/settings/settings-types.ts +78 -75
- package/src/settings/settings-validation.ts +328 -364
- package/src/snapshot/session-paths.ts +41 -55
- package/src/snapshot/snapshot-apply.ts +197 -229
- package/src/snapshot/snapshot-codec.ts +29 -31
- package/src/snapshot/snapshot-paths.ts +4 -6
- package/src/snapshot/snapshot-transaction.ts +161 -170
- package/src/snapshot/snapshot-types.ts +24 -24
- package/src/snapshot/snapshot.ts +310 -343
- package/src/state/json-file.ts +11 -11
- package/src/state/lock.ts +226 -242
- package/src/state/lockfile-fs.ts +20 -20
- package/src/state/operation-availability.ts +36 -41
- package/src/state/state-directory.ts +171 -173
- package/src/state/state-types.ts +15 -15
- package/src/state/sync-state-store.ts +23 -23
- package/src/sync/automatic-sync.ts +75 -80
- package/src/sync/remote-snapshot.ts +52 -60
- package/src/sync/setup-switch.ts +99 -114
- package/src/sync/signals.ts +3 -5
- package/src/sync/startup-check.ts +97 -111
- package/src/sync/sync-decision.ts +76 -80
- package/src/sync/sync-error-guidance.ts +41 -48
- package/src/sync/sync-errors.ts +10 -10
- package/src/sync/sync-inspection.ts +29 -33
- package/src/sync/sync-loaders.ts +28 -36
- package/src/sync/sync-mutations.ts +510 -622
- package/src/sync/sync-operations.ts +7 -7
- package/src/sync/sync-policy.ts +296 -326
- package/src/sync/sync-queries.ts +175 -194
- package/src/sync/sync-state.ts +74 -100
- package/src/sync-extension.ts +89 -101
- package/src/sync.ts +26 -26
- package/src/types.ts +37 -37
- package/src/ui/automatic-sync-summary.ts +1 -3
- package/src/ui/cancellable-operation.ts +92 -112
- package/src/ui/file-selection.ts +250 -274
- package/src/ui/manager-attention.ts +51 -63
- package/src/ui/manager-recovery.ts +48 -57
- package/src/ui/manager-result-dispatcher.ts +62 -76
- package/src/ui/manager-state.ts +200 -232
- package/src/ui/manager-ui.ts +264 -362
- package/src/ui/remote-selection-ui.ts +547 -588
- package/src/ui/secret-input.ts +198 -214
- package/src/ui/settings-ui.ts +175 -172
- package/src/ui/setup/git-ui.ts +232 -261
- package/src/ui/setup/s3-credentials-ui.ts +58 -65
- package/src/ui/setup/s3-ui.ts +171 -195
- package/src/ui/setup/setup-actions.ts +91 -101
- package/src/ui/setup/setup-location-ui.ts +121 -137
- package/src/ui/setup/setup-name-ui.ts +27 -27
- package/src/ui/setup/setup-prompts.ts +65 -65
- package/src/ui/setup/setup-review.ts +58 -59
- package/src/ui/setup/setup-switcher.ts +156 -160
- package/src/ui/setup/setup-wizard.ts +28 -28
- package/src/ui/setup/text-input.ts +54 -58
- package/src/ui/setup/webdav-ui.ts +246 -293
- package/src/ui/storage-connections-ui.ts +317 -369
- package/src/ui/sync-attention.ts +148 -164
- package/src/ui/sync-format.ts +110 -121
- package/src/ui/sync-resolution-ui.ts +147 -153
- package/src/ui/sync-setups-ui.ts +218 -238
- package/src/ui/sync-status.ts +17 -0
- package/src/ui/terminal-text.ts +2 -2
- package/dist/chunks/chunk-2B2ELUF5.ts.map +0 -7
- package/dist/chunks/chunk-4CHTZMEO.ts.map +0 -7
- package/dist/chunks/chunk-5B3FXBLE.ts.map +0 -7
- package/dist/chunks/chunk-6LP3MCR2.ts.map +0 -7
- package/dist/chunks/chunk-AUSDLD6F.ts.map +0 -7
- package/dist/chunks/chunk-C7XY5NF5.ts.map +0 -7
- package/dist/chunks/chunk-CCSN3F47.ts.map +0 -7
- package/dist/chunks/chunk-CHH26D4F.ts.map +0 -7
- package/dist/chunks/chunk-CPEHGXJV.ts.map +0 -7
- package/dist/chunks/chunk-D5X27LCA.ts.map +0 -7
- package/dist/chunks/chunk-ETTRJDNP.ts.map +0 -7
- package/dist/chunks/chunk-G4RNQQZS.ts.map +0 -7
- package/dist/chunks/chunk-HUDQATCL.ts.map +0 -7
- package/dist/chunks/chunk-IJ6FJFA5.ts.map +0 -7
- package/dist/chunks/chunk-JAAKLL6M.ts.map +0 -7
- package/dist/chunks/chunk-KW7YQEZN.ts.map +0 -7
- package/dist/chunks/chunk-LCRI3ABW.ts.map +0 -7
- package/dist/chunks/chunk-LTBWGSBV.ts.map +0 -7
- package/dist/chunks/chunk-NJFUIVYL.ts.map +0 -7
- package/dist/chunks/chunk-OEX4ABY6.ts.map +0 -7
- package/dist/chunks/chunk-Q2TUM5X6.ts.map +0 -7
- package/dist/chunks/chunk-SL64RLXR.ts.map +0 -7
- package/dist/chunks/chunk-SZWBGPID.ts.map +0 -7
- package/dist/chunks/chunk-T2B5IU74.ts.map +0 -7
- package/dist/chunks/chunk-T3HH3UEJ.ts.map +0 -7
- package/dist/chunks/chunk-TEN2ZBJV.ts.map +0 -7
- package/dist/chunks/chunk-THMF4BVL.ts.map +0 -7
- package/dist/chunks/chunk-Z4DQF6L3.ts.map +0 -7
- package/dist/chunks/chunk-ZI5MXMEX.ts.map +0 -7
- package/dist/chunks/file-selection-FTTKAOYX.ts.map +0 -7
- package/dist/chunks/git-backend-GMVS4PD4.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-FKIBM3GO.ts +0 -22
- package/dist/chunks/manager-ui-5KLKVMRC.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-D7RBRJNL.ts +0 -21
- package/dist/chunks/s3-backend-66O4FGBH.ts.map +0 -7
- package/dist/chunks/setup-wizard-IUB6ESEU.ts.map +0 -7
- package/dist/chunks/sync-inspection-CST2KL65.ts +0 -20
- package/dist/chunks/sync-operations-LBX3GKCM.ts.map +0 -7
- package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +0 -7
- /package/dist/chunks/{manager-result-dispatcher-FKIBM3GO.ts.map → manager-result-dispatcher-OTUMQM26.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-D7RBRJNL.ts.map → remote-selection-ui-TZTJF32Y.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-IS2PEZ3J.ts.map → setup-switch-5G2VNJHL.ts.map} +0 -0
- /package/dist/chunks/{snapshot-HKEWCVK6.ts.map → snapshot-JL5DFSXI.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-CST2KL65.ts.map → sync-inspection-P3ENJLNF.ts.map} +0 -0
- /package/dist/chunks/{sync-state-Q2FTXTW6.ts.map → sync-state-VHJUZ4YG.ts.map} +0 -0
|
@@ -9,382 +9,358 @@ const REQUEST_TIMEOUT_MS = 30_000;
|
|
|
9
9
|
const MAX_REDIRECTS = 3;
|
|
10
10
|
|
|
11
11
|
export interface WebDavPutOptions {
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
ifAbsent?: boolean;
|
|
13
|
+
ifMatch?: string;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface WebDavEntry {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
href: string;
|
|
18
|
+
etag?: string;
|
|
19
|
+
collection: boolean;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export class WebDavHttpError extends Error {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
constructor(
|
|
24
|
+
message: string,
|
|
25
|
+
readonly status: number,
|
|
26
|
+
options?: ErrorOptions,
|
|
27
|
+
) {
|
|
28
|
+
super(message, options);
|
|
29
|
+
this.name = "WebDavHttpError";
|
|
30
|
+
}
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export class WebDavPreconditionError extends WebDavHttpError {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
constructor(message = "WebDAV precondition failed.") {
|
|
35
|
+
super(message, 412);
|
|
36
|
+
this.name = "WebDavPreconditionError";
|
|
37
|
+
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
export class WebDavClient {
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
private readonly baseUrl: URL;
|
|
42
|
+
private readonly authorization: string;
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
76
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
await this.requireOk(response, "write");
|
|
115
|
-
await response.body?.cancel();
|
|
116
|
-
return response.headers.get("etag") ?? undefined;
|
|
117
|
-
}
|
|
96
|
+
async putBuffer(remotePath: string, body: Buffer, contentType: string, options: WebDavPutOptions = {}) {
|
|
97
|
+
const headers: Record<string, string> = { "content-type": contentType };
|
|
98
|
+
if (options.ifAbsent) headers["if-none-match"] = "*";
|
|
99
|
+
if (options.ifMatch) headers["if-match"] = options.ifMatch;
|
|
100
|
+
const response = await this.request(remotePath, {
|
|
101
|
+
method: "PUT",
|
|
102
|
+
headers,
|
|
103
|
+
body: body as unknown as BodyInit,
|
|
104
|
+
});
|
|
105
|
+
if (response.status === 412) {
|
|
106
|
+
await response.body?.cancel();
|
|
107
|
+
throw new WebDavPreconditionError();
|
|
108
|
+
}
|
|
109
|
+
await this.requireOk(response, "write");
|
|
110
|
+
await response.body?.cancel();
|
|
111
|
+
return response.headers.get("etag") ?? undefined;
|
|
112
|
+
}
|
|
118
113
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
Buffer.from(JSON.stringify(value)),
|
|
123
|
-
"application/json",
|
|
124
|
-
options,
|
|
125
|
-
);
|
|
126
|
-
}
|
|
114
|
+
async putJson(remotePath: string, value: unknown, options: WebDavPutOptions = {}) {
|
|
115
|
+
return this.putBuffer(remotePath, Buffer.from(JSON.stringify(value)), "application/json", options);
|
|
116
|
+
}
|
|
127
117
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
118
|
+
async makeCollection(remotePath: string) {
|
|
119
|
+
const response = await this.request(remotePath, { method: "MKCOL" });
|
|
120
|
+
if (response.status === 405) {
|
|
121
|
+
await response.body?.cancel();
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
await this.requireOk(response, "create collection");
|
|
125
|
+
await response.body?.cancel();
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
138
128
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
129
|
+
async ensureCollection(remotePath: string) {
|
|
130
|
+
const segments = safeSegments(remotePath);
|
|
131
|
+
for (let index = 1; index <= segments.length; index += 1) {
|
|
132
|
+
await this.makeCollection(segments.slice(0, index).join("/"));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
145
135
|
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
}
|
|
136
|
+
async listCollection(remotePath: string): Promise<WebDavEntry[]> {
|
|
137
|
+
const response = await this.request(remotePath, {
|
|
138
|
+
method: "PROPFIND",
|
|
139
|
+
headers: { depth: "1", "content-type": "application/xml; charset=utf-8" },
|
|
140
|
+
body: '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>',
|
|
141
|
+
});
|
|
142
|
+
if (response.status === 404) {
|
|
143
|
+
await response.body?.cancel();
|
|
144
|
+
throw new WebDavHttpError("WebDAV collection is missing.", 404);
|
|
145
|
+
}
|
|
146
|
+
if (response.status !== 207) await this.requireOk(response, "list collection");
|
|
147
|
+
const xml = (await readBounded(response, XML_LIMIT, "WebDAV directory response is too large")).toString("utf8");
|
|
148
|
+
try {
|
|
149
|
+
const parsed = new XMLParser({
|
|
150
|
+
removeNSPrefix: true,
|
|
151
|
+
ignoreAttributes: false,
|
|
152
|
+
processEntities: false,
|
|
153
|
+
}).parse(xml) as {
|
|
154
|
+
multistatus?: { response?: unknown };
|
|
155
|
+
};
|
|
156
|
+
if (!parsed.multistatus || parsed.multistatus.response === undefined) {
|
|
157
|
+
throw new Error("Missing DAV multistatus response.");
|
|
158
|
+
}
|
|
159
|
+
const responses = asArray(parsed.multistatus.response);
|
|
160
|
+
return responses.map(parseEntry);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new Error("WebDAV directory response is malformed.", { cause: error });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
177
165
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
166
|
+
async delete(remotePath: string) {
|
|
167
|
+
const response = await this.request(remotePath, { method: "DELETE" });
|
|
168
|
+
if (response.status === 404) {
|
|
169
|
+
await response.body?.cancel();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (response.status === 207) {
|
|
173
|
+
await response.body?.cancel();
|
|
174
|
+
throw new WebDavHttpError("WebDAV probe cleanup returned a partial response.", 207);
|
|
175
|
+
}
|
|
176
|
+
await this.requireOk(response, "delete probe resource");
|
|
177
|
+
await response.body?.cancel();
|
|
178
|
+
}
|
|
191
179
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
180
|
+
private async request(remotePath: string, init: RequestInit) {
|
|
181
|
+
throwIfAborted(this.signal);
|
|
182
|
+
let url = appendRemotePath(this.baseUrl, remotePath);
|
|
183
|
+
for (let redirects = 0; ; redirects += 1) {
|
|
184
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
185
|
+
const signal = this.signal ? AbortSignal.any([this.signal, timeout]) : timeout;
|
|
186
|
+
let response: Response;
|
|
187
|
+
try {
|
|
188
|
+
response = await fetch(url, {
|
|
189
|
+
...init,
|
|
190
|
+
headers: {
|
|
191
|
+
accept: "*/*",
|
|
192
|
+
authorization: this.authorization,
|
|
193
|
+
...headersObject(init.headers),
|
|
194
|
+
},
|
|
195
|
+
redirect: "manual",
|
|
196
|
+
signal,
|
|
197
|
+
});
|
|
198
|
+
} catch (error) {
|
|
199
|
+
throwIfAborted(this.signal);
|
|
200
|
+
throw new Error(
|
|
201
|
+
`WebDAV ${String(init.method ?? "GET")} request failed for ${redactText(safeUrl(url), this.config)}.`,
|
|
202
|
+
{
|
|
203
|
+
cause: redactError(error, this.config),
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) return response;
|
|
208
|
+
await response.body?.cancel();
|
|
209
|
+
const method = String(init.method ?? "GET").toUpperCase();
|
|
210
|
+
if ([301, 302, 303].includes(response.status) && method !== "GET" && method !== "HEAD") {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`WebDAV refused an ambiguous HTTP ${response.status} redirect for ${method}; configure the canonical collection URL or require HTTP 307/308.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (redirects >= MAX_REDIRECTS) throw new Error("WebDAV redirect limit exceeded.");
|
|
216
|
+
const location = response.headers.get("location");
|
|
217
|
+
if (!location) throw new Error("WebDAV redirect is missing Location.");
|
|
218
|
+
const next = new URL(location, url);
|
|
219
|
+
if (next.origin !== this.baseUrl.origin) {
|
|
220
|
+
throw new Error("WebDAV refused a cross-origin authenticated redirect.");
|
|
221
|
+
}
|
|
222
|
+
url = next;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
237
225
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
response.status,
|
|
249
|
-
);
|
|
250
|
-
}
|
|
226
|
+
private async requireOk(response: Response, action: string) {
|
|
227
|
+
if (response.ok) return;
|
|
228
|
+
const body = (await readBounded(response, ERROR_LIMIT, "WebDAV error body is too large"))
|
|
229
|
+
.toString("utf8")
|
|
230
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Remote error text is untrusted terminal input.
|
|
231
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
|
232
|
+
.trim();
|
|
233
|
+
const detail = body ? `: ${redactText(body, this.config)}` : "";
|
|
234
|
+
throw new WebDavHttpError(`WebDAV ${action} failed with HTTP ${response.status}${detail}`, response.status);
|
|
235
|
+
}
|
|
251
236
|
}
|
|
252
237
|
|
|
253
238
|
function assertSafeAuthenticatedUrl(url: URL) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
"Invalid WebDAV URL: HTTPS is required and embedded credentials, query, or fragment are not allowed.",
|
|
268
|
-
);
|
|
269
|
-
}
|
|
239
|
+
const loopback =
|
|
240
|
+
url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]" || url.hostname === "::1";
|
|
241
|
+
if (
|
|
242
|
+
url.username ||
|
|
243
|
+
url.password ||
|
|
244
|
+
url.search ||
|
|
245
|
+
url.hash ||
|
|
246
|
+
(url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
|
|
247
|
+
) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
"Invalid WebDAV URL: HTTPS is required and embedded credentials, query, or fragment are not allowed.",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
270
252
|
}
|
|
271
253
|
|
|
272
254
|
function hasControlCharacter(value: string) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
255
|
+
return [...value].some((character) => {
|
|
256
|
+
const code = character.codePointAt(0) ?? 0;
|
|
257
|
+
return code < 0x20 || (code >= 0x7f && code <= 0x9f);
|
|
258
|
+
});
|
|
277
259
|
}
|
|
278
260
|
|
|
279
261
|
function appendRemotePath(base: URL, remotePath: string) {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
262
|
+
const url = new URL(base);
|
|
263
|
+
const suffix = safeSegments(remotePath).map(encodeURIComponent).join("/");
|
|
264
|
+
url.pathname = `${base.pathname.replace(/\/+$/u, "")}/${suffix}`;
|
|
265
|
+
return url;
|
|
284
266
|
}
|
|
285
267
|
|
|
286
268
|
function safeSegments(value: string) {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
269
|
+
const segments = value
|
|
270
|
+
.replace(/^\/+|\/+$/gu, "")
|
|
271
|
+
.split("/")
|
|
272
|
+
.filter(Boolean);
|
|
273
|
+
if (
|
|
274
|
+
segments.some(
|
|
275
|
+
(segment) =>
|
|
276
|
+
segment === "." ||
|
|
277
|
+
segment === ".." ||
|
|
278
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: Remote path segments cannot contain controls.
|
|
279
|
+
/[\u0000-\u001f]/u.test(segment),
|
|
280
|
+
)
|
|
281
|
+
) {
|
|
282
|
+
throw new Error("Invalid WebDAV remote path.");
|
|
283
|
+
}
|
|
284
|
+
return segments;
|
|
303
285
|
}
|
|
304
286
|
|
|
305
287
|
async function readBounded(response: Response, limit: number, message: string) {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
288
|
+
const length = Number(response.headers.get("content-length"));
|
|
289
|
+
if (Number.isFinite(length) && length > limit) {
|
|
290
|
+
await response.body?.cancel();
|
|
291
|
+
throw new Error(message);
|
|
292
|
+
}
|
|
293
|
+
if (!response.body) return Buffer.alloc(0);
|
|
294
|
+
const reader = response.body.getReader();
|
|
295
|
+
const chunks: Buffer[] = [];
|
|
296
|
+
let total = 0;
|
|
297
|
+
try {
|
|
298
|
+
while (true) {
|
|
299
|
+
const { done, value } = await reader.read();
|
|
300
|
+
if (done) break;
|
|
301
|
+
total += value.byteLength;
|
|
302
|
+
if (total > limit) throw new Error(message);
|
|
303
|
+
chunks.push(Buffer.from(value));
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
await reader.cancel().catch(() => undefined);
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
return Buffer.concat(chunks, total);
|
|
328
310
|
}
|
|
329
311
|
|
|
330
312
|
function parseEntry(value: unknown): WebDavEntry {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
Object.hasOwn(prop.resourcetype, "collection"),
|
|
347
|
-
};
|
|
313
|
+
if (!value || typeof value !== "object") throw new Error("Invalid WebDAV response entry.");
|
|
314
|
+
const record = value as Record<string, unknown>;
|
|
315
|
+
const href = typeof record.href === "string" ? record.href : undefined;
|
|
316
|
+
if (!href) throw new Error("Invalid WebDAV response href.");
|
|
317
|
+
const propstat = asArray(record.propstat).find((item) => {
|
|
318
|
+
if (!item || typeof item !== "object") return false;
|
|
319
|
+
return String((item as Record<string, unknown>).status ?? "").includes(" 200 ");
|
|
320
|
+
}) as Record<string, unknown> | undefined;
|
|
321
|
+
const prop = propstat?.prop as Record<string, unknown> | undefined;
|
|
322
|
+
return {
|
|
323
|
+
href,
|
|
324
|
+
etag: typeof prop?.getetag === "string" ? prop.getetag : undefined,
|
|
325
|
+
collection:
|
|
326
|
+
!!prop?.resourcetype && typeof prop.resourcetype === "object" && Object.hasOwn(prop.resourcetype, "collection"),
|
|
327
|
+
};
|
|
348
328
|
}
|
|
349
329
|
|
|
350
330
|
function asArray(value: unknown): unknown[] {
|
|
351
|
-
|
|
352
|
-
|
|
331
|
+
if (value === undefined) return [];
|
|
332
|
+
return Array.isArray(value) ? value : [value];
|
|
353
333
|
}
|
|
354
334
|
|
|
355
335
|
function headersObject(headers: HeadersInit | undefined) {
|
|
356
|
-
|
|
336
|
+
return Object.fromEntries(new Headers(headers).entries());
|
|
357
337
|
}
|
|
358
338
|
|
|
359
339
|
function safeUrl(url: URL) {
|
|
360
|
-
|
|
340
|
+
return `${url.protocol}//${url.host}${url.pathname}`;
|
|
361
341
|
}
|
|
362
342
|
|
|
363
343
|
function redactError(error: unknown, config: ResolvedWebDavBackend) {
|
|
364
|
-
|
|
365
|
-
|
|
344
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
345
|
+
return new Error(redactText(message, config));
|
|
366
346
|
}
|
|
367
347
|
|
|
368
348
|
function redactText(value: string, config: ResolvedWebDavBackend) {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
.replace(/basic\s+[a-z0-9+/=]+/giu, "Basic [redacted]")
|
|
378
|
-
.replace(/\?[^\s]*/gu, "?[redacted]");
|
|
349
|
+
let result = value;
|
|
350
|
+
for (const secret of [config.profile.username, config.profile.password, config.profile.url]) {
|
|
351
|
+
if (!secret) continue;
|
|
352
|
+
for (const variant of new Set([secret, encodeURIComponent(secret)])) {
|
|
353
|
+
result = result.replace(new RegExp(escapeRegExp(variant), "giu"), "[redacted]");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return result.replace(/basic\s+[a-z0-9+/=]+/giu, "Basic [redacted]").replace(/\?[^\s]*/gu, "?[redacted]");
|
|
379
357
|
}
|
|
380
358
|
|
|
381
359
|
function escapeRegExp(value: string) {
|
|
382
|
-
|
|
360
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
383
361
|
}
|
|
384
362
|
|
|
385
363
|
function throwIfAborted(signal?: AbortSignal) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
? signal.reason
|
|
389
|
-
: new DOMException("The operation was aborted", "AbortError");
|
|
364
|
+
if (!signal?.aborted) return;
|
|
365
|
+
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
390
366
|
}
|