dsh-webdav-sync 1.0.1 → 1.0.2
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 +119 -25
- package/lib/client.js +543 -18
- package/lib/dav.d.ts +27 -7
- package/lib/dav.js +134 -28
- package/lib/dirs.d.ts +114 -0
- package/lib/dirs.js +259 -0
- package/lib/engine.d.ts +5 -1
- package/lib/engine.js +207 -44
- package/lib/index.d.ts +7 -18
- package/lib/index.js +98 -40
- package/lib/relay.d.ts +119 -0
- package/lib/relay.js +683 -0
- package/lib/state.d.ts +14 -2
- package/lib/state.js +47 -2
- package/lib/store.d.ts +1 -1
- package/lib/store.js +8 -10
- package/lib/wire.d.ts +228 -17
- package/lib/wire.js +65 -10
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -13,19 +13,21 @@
|
|
|
13
13
|
* be uploaded by the next sync. It lives in the profile's
|
|
14
14
|
* `.dsh-webdav-sync/sync.json`, mode 0600, beside the state and the cache.
|
|
15
15
|
*/
|
|
16
|
+
import { isAbsolute } from 'node:path';
|
|
16
17
|
import z from '@deepseek-ai/schemastery';
|
|
17
|
-
import { AUTO_CHOICES, ROUTE_CHECK, ROUTE_CONFIG, ROUTE_DISMISS, ROUTE_INSTALL, ROUTE_OFF, ROUTE_PREVIEW, ROUTE_RESTORE, ROUTE_STATUS, ROUTE_SYNC, ROUTE_UNDO, ROUTE_UPLOAD, } from './wire.js';
|
|
18
|
+
import { AUTO_CHOICES, DIRS_MAX, ROUTE_CHECK, ROUTE_CONFIG, ROUTE_DISMISS, ROUTE_INSTALL, ROUTE_OFF, ROUTE_PREVIEW, ROUTE_RESTORE, ROUTE_STATUS, ROUTE_SYNC, ROUTE_UNDO, ROUTE_UPLOAD, } from './wire.js';
|
|
18
19
|
import { validateProfileDir, resolveFromEnv } from './paths.js';
|
|
19
|
-
import {
|
|
20
|
+
import { RelayServer, makeRelayPassword, makeRelayUser } from './relay.js';
|
|
21
|
+
import { check, dismiss, engineFor, loadConfig, preview, restore, sameAccount, status, sync, turnOff, undo, upload, usable, } from './engine.js';
|
|
20
22
|
import { installPlugins } from './install.js';
|
|
21
23
|
import { sameSecret } from './crypto.js';
|
|
22
|
-
import {
|
|
24
|
+
import { normaliseDirs } from './dirs.js';
|
|
25
|
+
import { parseHeaders } from './dav.js';
|
|
26
|
+
import { defaultConfig, ensureStateDir, heldPassword, normaliseConfig, persistConfig } from './state.js';
|
|
23
27
|
export const name = 'dsh-webdav-sync';
|
|
24
28
|
export const inject = [];
|
|
25
29
|
/** Largest accepted request body (bytes) — these payloads are a few strings. */
|
|
26
30
|
const BODY_LIMIT = 64 * 1024;
|
|
27
|
-
/** The settings namespace this plugin serves; its browser card spells the same string. */
|
|
28
|
-
export const WEBDAV_SYNC_NS = 'dsh-webdav-sync';
|
|
29
31
|
/** How long the auto-sync timer waits before its first attempt. */
|
|
30
32
|
const FIRST_DELAY_MS = 20_000;
|
|
31
33
|
function readVolatile(value) {
|
|
@@ -59,14 +61,9 @@ export function validateConfig(config) {
|
|
|
59
61
|
throw new Error(`WebdavSyncConfig:「autoEvery」只能是 ${AUTO_CHOICES.join('、')} 之一`);
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const autoEvery = readVolatile(config.autoEvery);
|
|
66
|
-
return {
|
|
67
|
-
...(profileDir === undefined || profileDir === '' ? {} : { profileDir }),
|
|
68
|
-
autoEvery: autoEvery ?? -1,
|
|
69
|
-
};
|
|
64
|
+
/** An example of the shape the directory field wants, in this platform's spelling. */
|
|
65
|
+
function exampleDir() {
|
|
66
|
+
return process.platform === 'win32' ? 'D:\\dsh-extra' : '/home/you/dsh-extra';
|
|
70
67
|
}
|
|
71
68
|
/**
|
|
72
69
|
* Apply one configuration change from the card.
|
|
@@ -101,9 +98,53 @@ export async function configure(engine, body) {
|
|
|
101
98
|
next.patch = body.patch;
|
|
102
99
|
if (typeof body.rememberPassword === 'boolean')
|
|
103
100
|
next.rememberPassword = body.rememberPassword;
|
|
101
|
+
if (typeof body.headers === 'string') {
|
|
102
|
+
// Refused here rather than at the first request: the card can then show the
|
|
103
|
+
// line it is unhappy about while the user is still looking at it.
|
|
104
|
+
parseHeaders(body.headers);
|
|
105
|
+
next.headers = body.headers;
|
|
106
|
+
}
|
|
104
107
|
if (typeof body.autoEvery === 'number' && AUTO_CHOICES.includes(body.autoEvery)) {
|
|
105
108
|
next.autoEvery = body.autoEvery;
|
|
106
109
|
}
|
|
110
|
+
if (typeof body.dirsEnabled === 'boolean')
|
|
111
|
+
next.dirsEnabled = body.dirsEnabled;
|
|
112
|
+
if (typeof body.relayEnabled === 'boolean')
|
|
113
|
+
next.relayEnabled = body.relayEnabled;
|
|
114
|
+
if (typeof body.relayPort === 'number') {
|
|
115
|
+
if (!Number.isInteger(body.relayPort) || body.relayPort < 1 || body.relayPort > 65_535) {
|
|
116
|
+
throw new Error(`转发端口要填 1 到 65535 之间的整数:「${body.relayPort}」不是`);
|
|
117
|
+
}
|
|
118
|
+
next.relayPort = body.relayPort;
|
|
119
|
+
}
|
|
120
|
+
// The account other programs sign in with, minted the first time the switch is
|
|
121
|
+
// turned on. It is minted once and then left alone: it exists to be written
|
|
122
|
+
// into those programs, and one that changed under them would be one they could
|
|
123
|
+
// no longer sign in with — so turning the switch off and on again keeps the
|
|
124
|
+
// same pair, as does any later save. A half-emptied pair (only reachable by
|
|
125
|
+
// editing `sync.json` by hand) is completed rather than left unusable.
|
|
126
|
+
if (next.relayEnabled && (next.relayUser === '' || next.relayPassword === '')) {
|
|
127
|
+
next.relayUser = makeRelayUser();
|
|
128
|
+
next.relayPassword = makeRelayPassword();
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(body.dirs)) {
|
|
131
|
+
// What the card sends is what was typed, one line per directory. What is
|
|
132
|
+
// stored is one absolute path each in a fixed order — the part is hashed whole,
|
|
133
|
+
// so two computers that name the same set in a different order must still
|
|
134
|
+
// agree — and a relative path is refused here, while the person is still
|
|
135
|
+
// looking at the field, rather than left to fail as an unreadable directory
|
|
136
|
+
// on every later sync.
|
|
137
|
+
next.dirs = normaliseDirs(body.dirs);
|
|
138
|
+
const relative = next.dirs.find((path) => !isAbsolute(path));
|
|
139
|
+
if (relative !== undefined) {
|
|
140
|
+
throw new Error(`目录要填绝对路径,像 ${exampleDir()} 这样:「${relative}」不是`);
|
|
141
|
+
}
|
|
142
|
+
// Five at most: each one is walked on every card open and carried whole on
|
|
143
|
+
// every push, and a sixth would be a person pointing at a whole disk.
|
|
144
|
+
if (next.dirs.length > DIRS_MAX) {
|
|
145
|
+
throw new Error(`最多同步 ${DIRS_MAX} 个目录,现在填了 ${next.dirs.length} 个`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
107
148
|
// The password this computer would actually send: one typed into this submit, or
|
|
108
149
|
// the one already in hand for this account. What is written down is only part of
|
|
109
150
|
// that — a password the user chose not to remember is held in memory for the run
|
|
@@ -130,30 +171,22 @@ export async function configure(engine, body) {
|
|
|
130
171
|
// be one no request could ever carry, leaving sync unable to sign in until the
|
|
131
172
|
// process restarts and the user retypes it. It stays in the engine — which is
|
|
132
173
|
// what the running half talks to the server with — and is left out of what is
|
|
133
|
-
// written down.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
releasePassword(engine.paths);
|
|
137
|
-
}
|
|
138
|
-
else {
|
|
139
|
-
await saveConfig(engine.paths, { ...next, password: '' });
|
|
140
|
-
holdPassword(engine.paths, next.password);
|
|
141
|
-
}
|
|
174
|
+
// written down. That split lives in one place, {@link persistConfig}, because
|
|
175
|
+
// this is no longer the only writer of the configuration.
|
|
176
|
+
await persistConfig(engine.paths, next);
|
|
142
177
|
engine.config = next;
|
|
143
178
|
}
|
|
144
179
|
/**
|
|
145
180
|
* Plugin apply: mount the settings presentation policy, the HTTP routes and the
|
|
146
181
|
* auto-sync timer on any webServer that comes and goes.
|
|
147
|
-
*/
|
|
148
|
-
/**
|
|
149
|
-
* Passwords that are in use but deliberately not on disk.
|
|
150
182
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
183
|
+
* Passwords that are in use but deliberately not on disk are read back here:
|
|
184
|
+
* "remember the password" off means it must not be written to `sync.json`, and it
|
|
185
|
+
* does not mean it should stop working. Every route rebuilds its engine from that
|
|
186
|
+
* file, so a password emptied there would be one no request could carry and sync
|
|
187
|
+
* would be unable to sign in until the process restarted. It is held for as long
|
|
188
|
+
* as the host runs, keyed by the state directory so two profiles do not share
|
|
189
|
+
* one, and put back into the configuration each time it is read.
|
|
157
190
|
*/
|
|
158
191
|
export function apply(ctx, config = {}) {
|
|
159
192
|
validateConfig(config);
|
|
@@ -171,6 +204,22 @@ export function apply(ctx, config = {}) {
|
|
|
171
204
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
172
205
|
settingsCtx.effect(() => settingsCtx.settings.configure({ auto: false }, ctx.fiber), 'webdav-sync: settings presentation policy');
|
|
173
206
|
});
|
|
207
|
+
// The forwarder for other programs on this computer. It is a switch on the
|
|
208
|
+
// configuration above rather than a second one, so it is handed that
|
|
209
|
+
// configuration and nothing else: the address it forwards to, the account it
|
|
210
|
+
// signs in with and the extra headers it adds are the ones the sync is already
|
|
211
|
+
// set up with. What it adds is a door — an address on loopback, and an account
|
|
212
|
+
// of its own, because the programs that come in by it hold that account and the
|
|
213
|
+
// WebDAV password itself is never handed out to them.
|
|
214
|
+
const relay = new RelayServer();
|
|
215
|
+
let relayStopped = false;
|
|
216
|
+
void engine()
|
|
217
|
+
.then((current) => relayStopped ? undefined : relay.apply(current.config, current.paths.stateDir))
|
|
218
|
+
.catch(() => undefined);
|
|
219
|
+
ctx.effect(() => () => {
|
|
220
|
+
relayStopped = true;
|
|
221
|
+
void relay.stop();
|
|
222
|
+
}, 'webdav-sync: relay forwarder');
|
|
174
223
|
ctx.inject(['webServer'], (webCtx) => {
|
|
175
224
|
const send = (res, status, body) => {
|
|
176
225
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
@@ -224,21 +273,29 @@ export function apply(ctx, config = {}) {
|
|
|
224
273
|
}), `webdav-sync: ${label} route`);
|
|
225
274
|
};
|
|
226
275
|
route(ROUTE_STATUS, 'status', async (_req, res) => {
|
|
227
|
-
const
|
|
228
|
-
|
|
276
|
+
const current = await engine();
|
|
277
|
+
// The forwarder is a switch on this configuration, so this is where it is
|
|
278
|
+
// brought in line with it: the card asks for this when it opens and every
|
|
279
|
+
// fifteen seconds after, which is what makes a switch turned on in another
|
|
280
|
+
// window, or a `sync.json` edited by hand, take effect.
|
|
281
|
+
await relay.apply(current.config, current.paths.stateDir);
|
|
282
|
+
send(res, 200, { ...(await status(current)), relay: relay.view() });
|
|
229
283
|
});
|
|
230
284
|
route(ROUTE_CONFIG, 'config', async (_req, res, body) => {
|
|
231
285
|
const current = await engine();
|
|
232
286
|
await configure(current, (body ?? {}));
|
|
233
287
|
await saveStateIfUsable(current);
|
|
288
|
+
// Saved and applied in the same breath: the card reloads the status right
|
|
289
|
+
// after this, and that read must not be the thing that makes the switch
|
|
290
|
+
// real — a port the user cannot see the result of until later reads as one
|
|
291
|
+
// that did not take.
|
|
292
|
+
await relay.apply(current.config, current.paths.stateDir);
|
|
234
293
|
send(res, 200, { message: '已保存' });
|
|
235
294
|
});
|
|
236
295
|
route(ROUTE_CHECK, 'check', async (_req, res) => {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
send(res, 200, await check(current));
|
|
296
|
+
// An address that is not filled in is refused by `check` itself, in the one
|
|
297
|
+
// sentence the card shows for it.
|
|
298
|
+
send(res, 200, await check(await engine()));
|
|
242
299
|
});
|
|
243
300
|
route(ROUTE_SYNC, 'sync', async (req, res, body) => {
|
|
244
301
|
const current = await engine();
|
|
@@ -263,6 +320,8 @@ export function apply(ctx, config = {}) {
|
|
|
263
320
|
});
|
|
264
321
|
route(ROUTE_OFF, 'off', async (_req, res) => {
|
|
265
322
|
await turnOff(await engine());
|
|
323
|
+
const updated = await engine();
|
|
324
|
+
await relay.apply(updated.config, updated.paths.stateDir);
|
|
266
325
|
send(res, 200, { message: '已关闭 WebDAV 同步;服务器上的文件保持原样' });
|
|
267
326
|
});
|
|
268
327
|
route(ROUTE_INSTALL, 'install', async (_req, res) => {
|
|
@@ -272,10 +331,9 @@ export function apply(ctx, config = {}) {
|
|
|
272
331
|
});
|
|
273
332
|
startTimer(ctx, currentConfig);
|
|
274
333
|
}
|
|
275
|
-
/**
|
|
334
|
+
/** Make sure the state directory exists, so the first sync has somewhere to write. */
|
|
276
335
|
async function saveStateIfUsable(engine) {
|
|
277
336
|
try {
|
|
278
|
-
const { ensureStateDir } = await import('./state.js');
|
|
279
337
|
await ensureStateDir(engine.paths);
|
|
280
338
|
}
|
|
281
339
|
catch {
|
package/lib/relay.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The forwarder for other WebDAV clients on this computer.
|
|
3
|
+
*
|
|
4
|
+
* It is a switch on the sync's own configuration, not a second one. There is
|
|
5
|
+
* nothing here to fill in: the address it forwards to, the account it signs in
|
|
6
|
+
* with and the extra headers it adds are the ones the sync is already configured
|
|
7
|
+
* with, and it reads them per request from the configuration the host is holding.
|
|
8
|
+
* What it adds is a door a local client can come in by — an address on loopback,
|
|
9
|
+
* and an account of its own to knock with, because those clients hold that
|
|
10
|
+
* account and must not be handed the real WebDAV password as well.
|
|
11
|
+
*
|
|
12
|
+
* What it does is a byte pipe for request bodies. It preserves client paths except
|
|
13
|
+
* for the `.prop` compatibility suffix, and reverses only suffixes the relay added
|
|
14
|
+
* when returning PROPFIND hrefs. The `location` header of a redirect is pointed
|
|
15
|
+
* back here too.
|
|
16
|
+
*/
|
|
17
|
+
import { type RelayView, type SyncConfig } from './wire.js';
|
|
18
|
+
/** The name half of a fresh account: short, hex, and easy to type again. */
|
|
19
|
+
export declare function makeRelayUser(): string;
|
|
20
|
+
/** The password half of a fresh account. Minted once and then kept. */
|
|
21
|
+
export declare function makeRelayPassword(): string;
|
|
22
|
+
/**
|
|
23
|
+
* The forwarder itself.
|
|
24
|
+
*
|
|
25
|
+
* One instance lives for as long as the host runs. The host hands it the
|
|
26
|
+
* configuration it is holding — at startup, after every save, and on every card
|
|
27
|
+
* read — and it brings its listening socket in line with it. The configuration
|
|
28
|
+
* is kept rather than looked up per request, so a request never waits on a file
|
|
29
|
+
* read, and the account, the target and the headers it uses are all from one
|
|
30
|
+
* single version of the configuration.
|
|
31
|
+
*/
|
|
32
|
+
export declare class RelayServer {
|
|
33
|
+
private config;
|
|
34
|
+
private server;
|
|
35
|
+
private failure;
|
|
36
|
+
private aliasFile;
|
|
37
|
+
private aliases;
|
|
38
|
+
private aliasWrites;
|
|
39
|
+
/** The port the socket in hand is bound to, so a change of port rebuilds it. */
|
|
40
|
+
private bound;
|
|
41
|
+
/** Socket changes cannot overlap a startup read, a save, or a status poll. */
|
|
42
|
+
private pending;
|
|
43
|
+
private enqueue;
|
|
44
|
+
/**
|
|
45
|
+
* Bring the socket in line with a configuration.
|
|
46
|
+
*
|
|
47
|
+
* Off, or on with something missing, closes it rather than leaving a listener
|
|
48
|
+
* that answers every request with a complaint: a client that reaches a door
|
|
49
|
+
* gets a straight answer from the server behind it, or no door at all. A port
|
|
50
|
+
* that is merely taken is not a refusal — the configuration is good, the socket
|
|
51
|
+
* is not, and {@link view} says which.
|
|
52
|
+
*/
|
|
53
|
+
apply(config: SyncConfig, stateDir?: string): Promise<void>;
|
|
54
|
+
private applyNow;
|
|
55
|
+
private loadAliases;
|
|
56
|
+
private saveAliases;
|
|
57
|
+
private changeAlias;
|
|
58
|
+
/** What the card shows. Never throws: a failure is a field, not an exception. */
|
|
59
|
+
view(): RelayView;
|
|
60
|
+
/** Stop listening. Called when the plugin is disposed. */
|
|
61
|
+
stop(): Promise<void>;
|
|
62
|
+
/** The target address, parsed, or the complaint the card shows. */
|
|
63
|
+
private targetOf;
|
|
64
|
+
/** The account a client has to sign in with, or the complaint the card shows. */
|
|
65
|
+
private accountOf;
|
|
66
|
+
/** Rebuild the socket for one port. Failures are recorded, never thrown. */
|
|
67
|
+
private listen;
|
|
68
|
+
/** Close the socket and anything still talking on it. */
|
|
69
|
+
private close;
|
|
70
|
+
/**
|
|
71
|
+
* One forwarded request.
|
|
72
|
+
*
|
|
73
|
+
* The caller signs in with the relay's account, and only then is anything sent
|
|
74
|
+
* on: an address on loopback is not a boundary — any program on this computer
|
|
75
|
+
* can reach it — so the account is the whole of what decides who may spend the
|
|
76
|
+
* sync's credentials, and a caller that does not have it learns nothing.
|
|
77
|
+
*/
|
|
78
|
+
private handle;
|
|
79
|
+
/** Whether a caller signed in with the relay's own account. */
|
|
80
|
+
private authorised;
|
|
81
|
+
/**
|
|
82
|
+
* Where a request the client made goes on the real server.
|
|
83
|
+
*
|
|
84
|
+
* The address the client was given carries the target's own base path, and
|
|
85
|
+
* clients differ in what they do with it: one joins its paths onto the host
|
|
86
|
+
* alone, another keeps the base path it was handed and appends to it. Both are
|
|
87
|
+
* made to work — a path that already begins with the base path is taken as it
|
|
88
|
+
* stands, and one that does not is given it — so the same address is correct
|
|
89
|
+
* for either habit rather than only for the one the author happened to test.
|
|
90
|
+
*/
|
|
91
|
+
private upstreamPath;
|
|
92
|
+
/** Map a same-relay Destination to the configured upstream and return its alias. */
|
|
93
|
+
private rewriteDestination;
|
|
94
|
+
/**
|
|
95
|
+
* The headers the forwarded request carries.
|
|
96
|
+
*
|
|
97
|
+
* The configured ones are the reason the forwarder exists, and the client's own
|
|
98
|
+
* are the protocol headers its request is made of, so a collision goes to the
|
|
99
|
+
* client. `authorization` is the one header that never comes from the client:
|
|
100
|
+
* it was addressed to this forwarder, not to the server, and the server is given
|
|
101
|
+
* the sync's own credential — or, when the configuration names an
|
|
102
|
+
* `Authorization` line of its own, exactly that line, since a hand-written one
|
|
103
|
+
* is the more specific instruction.
|
|
104
|
+
*/
|
|
105
|
+
/** Configuration headers obey the same connection boundary as client headers. */
|
|
106
|
+
private configuredHeaders;
|
|
107
|
+
private forwardHeaders;
|
|
108
|
+
/** The headers a response carries back: connection-level ones out, location mapped. */
|
|
109
|
+
private responseHeaders;
|
|
110
|
+
/**
|
|
111
|
+
* A redirect that points at the real server is pointed back at this forwarder,
|
|
112
|
+
* path and query untouched. Left alone, the client would follow it straight to
|
|
113
|
+
* the server and from then on talk around the forwarder — losing the account
|
|
114
|
+
* and the headers this exists for.
|
|
115
|
+
*/
|
|
116
|
+
private rewriteLocation;
|
|
117
|
+
/** Answer with a short plain-text refusal, unless the answer already started. */
|
|
118
|
+
private refuse;
|
|
119
|
+
}
|