dsh-webdav-sync 1.0.0 → 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/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 { check, dismiss, engineFor, loadConfig, preview, restore, sameAccount, saveConfig, status, sync, turnOff, undo, upload, usable, } from './engine.js';
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 { defaultConfig, normaliseConfig, heldPassword, holdPassword, releasePassword } from './state.js';
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
- /** The section-shaped view of a config: absent keys stay absent. */
63
- export function sectionOf(config) {
64
- const profileDir = readVolatile(config.profileDir);
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
- if (next.rememberPassword) {
135
- await saveConfig(engine.paths, next);
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
- * "Remember the password" off means it must not be written to `sync.json`, and
152
- * it does not mean it should stop working: every route rebuilds its engine from
153
- * that file, so a password emptied there would be one no request could carry and
154
- * sync would be unable to sign in until the process restarted. It is held for as
155
- * long as the host runs, keyed by the state directory so two profiles do not
156
- * share one, and put back into the configuration each time it is read.
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 result = await status(await engine());
228
- send(res, 200, result);
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
- const current = await engine();
238
- if (!usable(current.config) && current.config.url.trim() === '') {
239
- throw new Error('请先填写同步地址');
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
- /** Write a state file that records the new configuration's key, if it can be written. */
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
+ }