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/dav.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.dshsync`,
2
+ * The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.prop`,
3
3
  * and the whole protocol between this computer and that file is:
4
4
  *
5
5
  * - read it, with the version last seen so an unchanged file costs one request;
@@ -18,10 +18,6 @@ import { type ServerVersion } from './wire.js';
18
18
  export declare class ChangedError extends Error {
19
19
  constructor();
20
20
  }
21
- /** The file is still the version last read; the server did not send it again. */
22
- export declare class NotModifiedError extends Error {
23
- constructor();
24
- }
25
21
  /** The server refused the account. */
26
22
  export declare class LoginError extends Error {
27
23
  constructor();
@@ -42,6 +38,14 @@ export interface DavOptions {
42
38
  url: string;
43
39
  user: string;
44
40
  password: string;
41
+ /**
42
+ * Extra headers sent with every request, one `Name: value` pair each. They go
43
+ * on before this client's own headers, so a header the protocol depends on —
44
+ * `If-Match`, `If-None-Match`, `Depth`, `Accept-Encoding` — cannot be broken
45
+ * by one configured here; and they go on before the Basic credential too, so
46
+ * a configured `Authorization` replaces it.
47
+ */
48
+ headers?: Record<string, string>;
45
49
  }
46
50
  /**
47
51
  * What one read found. `data` is absent when the server holds no file yet — which
@@ -68,6 +72,7 @@ export declare class Dav {
68
72
  private readonly basePath;
69
73
  private readonly user;
70
74
  private readonly password;
75
+ private readonly extraHeaders;
71
76
  constructor(options: DavOptions);
72
77
  /** The address, without any credentials that may have ridden in on it. */
73
78
  get address(): string;
@@ -79,10 +84,18 @@ export declare class Dav {
79
84
  * Read the backup.
80
85
  *
81
86
  * Returns `undefined` when the server holds none yet — which several servers
82
- * say in several ways. Throws {@link NotModifiedError} when `have` is still
83
- * current, {@link LoginError}, {@link RateLimitedError} or {@link ForbiddenError}.
87
+ * say in several ways. Returns `{ notModified: true }` when `have` is still
88
+ * current. Throws {@link LoginError}, {@link RateLimitedError} or
89
+ * {@link ForbiddenError}.
84
90
  */
85
91
  get(have?: ServerVersion): Promise<DavRead>;
92
+ /**
93
+ * One read of one address: the parsed answer, or `undefined` when nothing is
94
+ * there — 404, 410, 409 (how 坚果云 answers a read of a folder that is not there
95
+ * yet), and a Synology's 403 for a file in a folder that is not there, which is
96
+ * the state before the first sync.
97
+ */
98
+ private readAt;
86
99
  /**
87
100
  * Write the backup, only over the version that was read when there was one.
88
101
  *
@@ -135,3 +148,10 @@ export declare class Dav {
135
148
  /** The rate-limit error for an answer, carrying the server's own wait when it named one. */
136
149
  private limited;
137
150
  }
151
+ /**
152
+ * Parse the configured extra headers: one `Name: value` per line, blank lines
153
+ * and lines starting with `#` ignored. A line that cannot be a header is
154
+ * refused with the line number rather than dropped, so a typo shows up while it
155
+ * is being saved instead of surfacing as a puzzling failure on the next sync.
156
+ */
157
+ export declare function parseHeaders(text: string): Record<string, string>;
package/lib/dav.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.dshsync`,
2
+ * The WebDAV client. One file lives at `<address>/dsh-webdav/dsh-profile.prop`,
3
3
  * and the whole protocol between this computer and that file is:
4
4
  *
5
5
  * - read it, with the version last seen so an unchanged file costs one request;
@@ -13,7 +13,7 @@
13
13
  * it hands back, which then never matches in `If-Match`. Every one of those is
14
14
  * handled where it happens, with the reason it exists written beside it.
15
15
  */
16
- import { REMOTE_FILE, REMOTE_FOLDER } from './wire.js';
16
+ import { LEGACY_REMOTE_FILE, REMOTE_FILE, REMOTE_FOLDER } from './wire.js';
17
17
  /** The file was not written because it changed on the server since it was read. */
18
18
  export class ChangedError extends Error {
19
19
  constructor() {
@@ -21,13 +21,6 @@ export class ChangedError extends Error {
21
21
  this.name = 'ChangedError';
22
22
  }
23
23
  }
24
- /** The file is still the version last read; the server did not send it again. */
25
- export class NotModifiedError extends Error {
26
- constructor() {
27
- super('服务器上的文件没有变化');
28
- this.name = 'NotModifiedError';
29
- }
30
- }
31
24
  /** The server refused the account. */
32
25
  export class LoginError extends Error {
33
26
  constructor() {
@@ -73,6 +66,7 @@ export class Dav {
73
66
  basePath;
74
67
  user;
75
68
  password;
69
+ extraHeaders;
76
70
  constructor(options) {
77
71
  const trimmed = (options.url ?? '').trim();
78
72
  let parsed;
@@ -93,6 +87,7 @@ export class Dav {
93
87
  this.basePath = path;
94
88
  this.user = options.user ?? '';
95
89
  this.password = options.password ?? '';
90
+ this.extraHeaders = options.headers ?? {};
96
91
  }
97
92
  /** The address, without any credentials that may have ridden in on it. */
98
93
  get address() {
@@ -115,8 +110,9 @@ export class Dav {
115
110
  * Read the backup.
116
111
  *
117
112
  * Returns `undefined` when the server holds none yet — which several servers
118
- * say in several ways. Throws {@link NotModifiedError} when `have` is still
119
- * current, {@link LoginError}, {@link RateLimitedError} or {@link ForbiddenError}.
113
+ * say in several ways. Returns `{ notModified: true }` when `have` is still
114
+ * current. Throws {@link LoginError}, {@link RateLimitedError} or
115
+ * {@link ForbiddenError}.
120
116
  */
121
117
  async get(have) {
122
118
  const headers = { 'accept-encoding': 'identity' };
@@ -127,30 +123,46 @@ export class Dav {
127
123
  headers['if-none-match'] = have.etag;
128
124
  else if (have?.modified !== undefined && have.modified !== '')
129
125
  headers['if-modified-since'] = have.modified;
126
+ const read = await this.readAt(this.fileUrl, headers, have);
127
+ if (read !== undefined)
128
+ return read;
129
+ // A folder this plugin filled under its earlier name still holds the same
130
+ // backup under that name. Reading it makes this run an ordinary sync; calling
131
+ // the server empty instead would have this computer push its own setup up
132
+ // over whatever the other computer left there, silently.
133
+ //
134
+ // Its version is dropped rather than carried: the write that follows names
135
+ // the new file, which the server has no ETag for, and an `If-Match` against a
136
+ // file that does not exist yet would be refused. The merge still runs against
137
+ // the bytes just read, so nothing is written blind that was not merged first.
138
+ const legacy = await this.readAt(this.url(REMOTE_FOLDER, LEGACY_REMOTE_FILE), headers, have);
139
+ return legacy === undefined ? { version: {} } : { ...legacy, version: {} };
140
+ }
141
+ /**
142
+ * One read of one address: the parsed answer, or `undefined` when nothing is
143
+ * there — 404, 410, 409 (how 坚果云 answers a read of a folder that is not there
144
+ * yet), and a Synology's 403 for a file in a folder that is not there, which is
145
+ * the state before the first sync.
146
+ */
147
+ async readAt(url, headers, have) {
130
148
  const conditional = headers['if-none-match'] !== undefined || headers['if-modified-since'] !== undefined;
131
- const response = await this.send('GET', this.fileUrl, undefined, headers);
149
+ const response = await this.send('GET', url, undefined, headers);
132
150
  if (response.status === 304 && conditional) {
133
151
  return { version: have ?? {}, notModified: true };
134
152
  }
135
153
  if (response.status === 403) {
136
- // A Synology answers 403, not 404, for a file in a folder that is not
137
- // there — which is the state before the first sync.
138
154
  if (!(await this.there(`${this.url(REMOTE_FOLDER)}/`)))
139
- return { version: {} };
155
+ return undefined;
140
156
  throw await this.forbiddenMessage();
141
157
  }
142
158
  if (response.status === 404 || response.status === 410 || response.status === 409) {
143
- // 409 is how 坚果云 answers a read of a folder that is not there yet.
144
- return { version: {} };
159
+ return undefined;
145
160
  }
146
161
  if (response.status !== 200) {
147
162
  await drain(response);
148
163
  throw new Error(`从 WebDAV 服务器读取 ${REMOTE_FILE} 失败:HTTP ${response.status}`);
149
164
  }
150
- const buffer = new Uint8Array(await response.arrayBuffer());
151
- if (buffer.byteLength > MAX_BYTES) {
152
- throw new Error(`WebDAV 服务器上的文件超过 ${MAX_BYTES} 字节,没有读取`);
153
- }
165
+ const buffer = await readLimited(response, MAX_BYTES);
154
166
  const data = await unpacked(buffer, response.headers.get('content-encoding'));
155
167
  // A redirect that ended somewhere other than the file — a web app's sign-in
156
168
  // or files page — is carried out with where it went, so the caller can say
@@ -273,7 +285,12 @@ export class Dav {
273
285
  // 405: the server does not do HEAD, so the size is asked of PROPFIND instead.
274
286
  // 403/404/409/410: no file yet, which is not an error here.
275
287
  const found = await this.length(this.fileUrl);
276
- return found === undefined ? { exists: false } : { exists: true, size: found };
288
+ if (found !== undefined)
289
+ return { exists: true, size: found };
290
+ // A backup still under the earlier name is one this plugin wrote, and the
291
+ // question the card asks — "is there one on the server" — means that one too.
292
+ const legacy = await this.length(this.url(REMOTE_FOLDER, LEGACY_REMOTE_FILE));
293
+ return legacy === undefined ? { exists: false } : { exists: true, size: legacy };
277
294
  }
278
295
  /**
279
296
  * Make the folder, tolerating the several ways servers say it is already
@@ -430,9 +447,18 @@ export class Dav {
430
447
  /** One request, with auth, redirect and rate-limit handling applied. */
431
448
  async send(method, url, body, headers = {}) {
432
449
  const requestHeaders = new Headers();
450
+ // Configured headers go on first, so a header this client computes per
451
+ // request wins a name collision: a configured `If-Match` would turn the
452
+ // conditional write into a blind one, and a configured `Depth` would make
453
+ // the folder probe answer for the wrong level.
454
+ for (const [key, value] of Object.entries(this.extraHeaders))
455
+ requestHeaders.set(key, value);
433
456
  for (const [key, value] of Object.entries(headers))
434
457
  requestHeaders.set(key, value);
435
- if (this.user !== '' || this.password !== '') {
458
+ // The credential is the fallback, not the winner: a configured
459
+ // `Authorization` (a bearer token for a proxy in front of the server, say)
460
+ // is left in place.
461
+ if ((this.user !== '' || this.password !== '') && !requestHeaders.has('authorization')) {
436
462
  requestHeaders.set('authorization', `Basic ${Buffer.from(`${this.user}:${this.password}`, 'utf8').toString('base64')}`);
437
463
  }
438
464
  let response;
@@ -465,6 +491,28 @@ export class Dav {
465
491
  return new RateLimitedError(response.status, retryAfter(response.headers.get('retry-after')));
466
492
  }
467
493
  }
494
+ /**
495
+ * Parse the configured extra headers: one `Name: value` per line, blank lines
496
+ * and lines starting with `#` ignored. A line that cannot be a header is
497
+ * refused with the line number rather than dropped, so a typo shows up while it
498
+ * is being saved instead of surfacing as a puzzling failure on the next sync.
499
+ */
500
+ export function parseHeaders(text) {
501
+ const out = {};
502
+ const lines = (text ?? '').split(/\r?\n/);
503
+ lines.forEach((raw, index) => {
504
+ const line = raw.trim();
505
+ if (line === '' || line.startsWith('#'))
506
+ return;
507
+ const at = line.indexOf(':');
508
+ const name = at < 0 ? '' : line.slice(0, at).trim();
509
+ if (at < 1 || !/^[\w!#$%&'*+\-.^`|~]+$/i.test(name)) {
510
+ throw new Error(`第 ${index + 1} 行「${line}」不是请求头:请写成「名字: 值」,一行一个`);
511
+ }
512
+ out[name] = line.slice(at + 1).trim();
513
+ });
514
+ return out;
515
+ }
468
516
  /** The version an answer's headers name; empty when nothing can be asked about again. */
469
517
  function versionOf(headers) {
470
518
  const etag = headers.get('etag');
@@ -510,17 +558,75 @@ async function unpacked(data, encoding) {
510
558
  }
511
559
  const { gunzipSync, inflateSync } = await import('node:zlib');
512
560
  const buffer = Buffer.from(data);
513
- const plain = word === 'deflate' ? inflateSync(buffer) : gunzipSync(buffer);
514
- return new Uint8Array(plain);
561
+ // The cap is on what the read accepts, so it applies to the bytes that come out
562
+ // of here too: a small compressed body that unpacks to gigabytes would otherwise
563
+ // be the one way left to make this process hold more than it agreed to.
564
+ try {
565
+ const plain = (word === 'deflate' ? inflateSync : gunzipSync)(buffer, { maxOutputLength: MAX_BYTES });
566
+ return new Uint8Array(plain);
567
+ }
568
+ catch (cause) {
569
+ if (cause.code === 'ERR_BUFFER_TOO_LARGE') {
570
+ throw new Error(`WebDAV 服务器上的文件解压后超过 ${MAX_BYTES} 字节,没有读取`);
571
+ }
572
+ throw cause;
573
+ }
515
574
  }
516
- /** Read and discard a body, so the connection can be reused. */
575
+ /**
576
+ * Read and discard a body, so the connection can be reused.
577
+ *
578
+ * Only the first slice of it, and only up to a bound: an error page this plugin
579
+ * is about to throw away is not worth holding a whole answer for, and a server
580
+ * that answers with something enormous must not be able to make it.
581
+ */
517
582
  async function drain(response) {
518
583
  try {
519
- await response.arrayBuffer();
584
+ await readLimited(response, MAX_BYTES);
520
585
  }
521
586
  catch {
522
- // A body that cannot be read is of no use to anybody.
587
+ // A body that cannot be read is of no use to anybody, and neither is its
588
+ // size: the caller is already on the way to reporting the status.
589
+ }
590
+ }
591
+ /**
592
+ * Read a body, refusing it the moment it grows past `limit`.
593
+ *
594
+ * The count is taken as the bytes arrive rather than after the fact: a server
595
+ * that sends no length, or sends a length that is not true, would otherwise have
596
+ * this process hold the whole answer in memory before the check that rejects it
597
+ * gets to run.
598
+ */
599
+ async function readLimited(response, limit) {
600
+ const body = response.body;
601
+ if (body === null) {
602
+ const whole = new Uint8Array(await response.arrayBuffer());
603
+ if (whole.byteLength > limit)
604
+ throw new Error(`WebDAV 服务器上的文件超过 ${limit} 字节,没有读取`);
605
+ return whole;
606
+ }
607
+ const reader = body.getReader();
608
+ const chunks = [];
609
+ let total = 0;
610
+ for (;;) {
611
+ const { done, value } = await reader.read();
612
+ if (done)
613
+ break;
614
+ if (value === undefined)
615
+ continue;
616
+ total += value.byteLength;
617
+ if (total > limit) {
618
+ await reader.cancel().catch(() => undefined);
619
+ throw new Error(`WebDAV 服务器上的文件超过 ${limit} 字节,没有读取`);
620
+ }
621
+ chunks.push(value);
622
+ }
623
+ const out = new Uint8Array(total);
624
+ let at = 0;
625
+ for (const chunk of chunks) {
626
+ out.set(chunk, at);
627
+ at += chunk.byteLength;
523
628
  }
629
+ return out;
524
630
  }
525
631
  /** The path an address names, for messages. */
526
632
  function displayPath(url) {
package/lib/dirs.d.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The extra directories: reading them into the `dirs` part, and writing that
3
+ * part back out.
4
+ *
5
+ * The four profile parts are read from two files whose shape this plugin already
6
+ * knows. This one is different in three ways, and each of them shapes the code
7
+ * below:
8
+ *
9
+ * - **It is a whole tree, not a file.** Reading means walking, so it is capped:
10
+ * a directory over the cap is refused as a whole rather than half-carried,
11
+ * because half a directory that then gets pushed would delete the rest for
12
+ * everybody else.
13
+ * - **Its path is part of the data.** The user names a directory; the path
14
+ * travels so a restore writes back into the same directory instead of inventing
15
+ * one. A path this computer cannot make sense of is therefore not an error to
16
+ * shout about — it is a directory to leave exactly as the server has it.
17
+ * - **Writing is additive.** A sync writes and overwrites files here; it never
18
+ * removes one. Mirrored deletion inside a person's own directory is the one
19
+ * accident this feature could cause that no notice could undo, and a union is
20
+ * the safe half of the trade: a file deleted on one computer comes back from the
21
+ * others, and deleting it everywhere is done by hand — the same bargain the
22
+ * plugin already makes for the plugin list (see
23
+ * `docs/decisions/0005-four-parts-and-plugin-exception.md`).
24
+ */
25
+ import type { DirProblem, DirSpec, DirsPart } from './wire.js';
26
+ /**
27
+ * Names never descended into. `node_modules` because a plugin's own dependencies
28
+ * are installed on each computer rather than carried, `.git` because a repository
29
+ * there has an owner of its own. Both are skipped in silence: a skipped name is
30
+ * not a directory that failed to read.
31
+ */
32
+ export declare const DIRS_SKIP: ReadonlySet<string>;
33
+ /** Most files one configured directory may carry. */
34
+ export declare const DIRS_MAX_FILES = 2000;
35
+ /** Most bytes one configured directory may carry, before base64 and sealing. */
36
+ export declare const DIRS_MAX_BYTES: number;
37
+ /** What reading the configured directories produced. */
38
+ export interface DirsRead {
39
+ /** The directories that could be read, in the configured order. */
40
+ entries: DirSpec[];
41
+ /** Newest modification time among every file read; 0 when nothing was read. */
42
+ mtimeMs: number;
43
+ /** Directories that could not be read, and why. */
44
+ problems: DirProblem[];
45
+ }
46
+ /** Writing reported back to the caller, for the run's own bookkeeping. */
47
+ export interface DirsWrite {
48
+ /** True when at least one file's bytes were written. */
49
+ changed: boolean;
50
+ /** The absolute paths the part carried, in order, for the configuration to record. */
51
+ paths: string[];
52
+ }
53
+ /**
54
+ * The stored list of directories: strings only, trimmed, no blanks, no repeats,
55
+ * in a fixed order.
56
+ *
57
+ * The order is by path rather than by when each was added, so the same set of
58
+ * directories hashes the same on every computer — the part is hashed whole, and
59
+ * two computers that name the same directories in a different order would
60
+ * otherwise see a change that is not one.
61
+ */
62
+ export declare function normaliseDirs(value: unknown): string[];
63
+ /** Path order, used wherever the set of directories is put in canonical order. */
64
+ export declare function comparePath(a: string, b: string): number;
65
+ /**
66
+ * Read every configured directory.
67
+ *
68
+ * Nothing throws: this runs on every status call, and a card that cannot be
69
+ * opened because a directory on a disconnected drive is not there would be worse
70
+ * than a card that says so. What cannot be read is reported in `problems`, and
71
+ * {@link dirsPartOf} decides what to do about it.
72
+ */
73
+ export declare function readDirs(dirs: readonly string[]): Promise<DirsRead>;
74
+ /**
75
+ * The `dirs` part this computer holds, given what the server holds.
76
+ *
77
+ * Without a problem this is simply what is on disk — and `undefined` when there is
78
+ * nothing on disk, which keeps a computer that syncs no directories from putting an
79
+ * empty `dirs` part on the server and quietly emptying it for the computers that do.
80
+ *
81
+ * With a problem it is still what is on disk, except for the directories that could
82
+ * not be read: those take the server's copy verbatim. That is the whole reason this
83
+ * function exists. Reading a directory that is not there as "a directory with no
84
+ * files in it" would make the next push announce that every file in it was deleted,
85
+ * and a disconnected drive would quietly empty everybody else's copy of it. Carrying
86
+ * the server's copy instead means such a directory is neither read nor written this
87
+ * run, and the card says which one it was.
88
+ */
89
+ export declare function dirsPartOf(read: DirsRead, carried: DirsPart | undefined): DirsPart | undefined;
90
+ /**
91
+ * Put one `dirs` part on disk.
92
+ *
93
+ * Only directories that are absolute *on this computer* are touched: the list
94
+ * travels, so a path another computer named may not be a path here, and writing
95
+ * into a made-up relative one would scatter files around the working directory.
96
+ * Those paths stay in the list — the part is carried whole, and dropping one here
97
+ * would delete it for the computers that can use it — and are simply not read or
98
+ * written on this machine.
99
+ *
100
+ * `skip` holds the directories this run could not read. They are left alone for
101
+ * the same reason {@link dirsPartOf} carries them: whatever is at that path now is
102
+ * not this sync's business to replace, and it may well be nothing at all.
103
+ */
104
+ export declare function writeDirs(part: DirsPart, skip: ReadonlySet<string>): Promise<DirsWrite>;
105
+ /**
106
+ * Whether writing the part would change anything on disk.
107
+ *
108
+ * Asked before the copy kept for Undo is taken. That copy carries the directories
109
+ * when they are about to be written to, and a tree can run to megabytes — sealing one
110
+ * on every sync whose server copy of this part merely *existed* would cost a
111
+ * profile-size file per sync for a write that changes no file, which is exactly what
112
+ * happens on a computer that syncs no directories of its own and is sent somebody's.
113
+ */
114
+ export declare function dirsDiffer(part: DirsPart, skip: ReadonlySet<string>): Promise<boolean>;