dsh-webdav-sync 1.0.4 → 1.0.6

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/engine.d.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * with 409, a write that went out without the server checking its version, a body
14
14
  * a relay cut in half.
15
15
  */
16
- import { type Bundle, type CheckResult, type ConfigView, type Part, type PartHashes, type KeyProblem, type PreviewResult, type StatusResult, type SyncConfig, type SyncResult } from './wire.js';
16
+ import { type Bundle, type CheckResult, type ConfigView, type ExportBody, type ExportResult, type ImportBody, type ImportResult, type Part, type PartHashes, type KeyProblem, type PreviewResult, type StatusResult, type SyncConfig, type SyncResult } from './wire.js';
17
17
  import { type DirsRead } from './dirs.js';
18
18
  import { missingPlugins, type Collected, type ManifestFile, type PatchFile } from './store.js';
19
19
  import { loadConfig, sameAccount, saveConfig, usable } from './state.js';
@@ -77,6 +77,33 @@ export declare function restore(engine: Engine): Promise<SyncResult>;
77
77
  * server held is kept under `sync/` before anything is written.
78
78
  */
79
79
  export declare function upload(engine: Engine): Promise<SyncResult>;
80
+ /**
81
+ * Write this computer's setup into one passphrase-sealed file the person can carry.
82
+ *
83
+ * The file is the same sealed bundle the server holds, so a manual backup and a
84
+ * sync backup are one format and either can be opened by the other. What goes in
85
+ * is what the configuration syncs — the five parts behind the switches — because
86
+ * "打包上面这些" means the section above the button, and a part that is switched
87
+ * off is one this person already said they do not want travelling.
88
+ *
89
+ * The passphrase is asked for here rather than taken from the configuration: this
90
+ * file leaves the computer, and the sync passphrase is the one that also opens the
91
+ * copy on the server. A file sealed with its own passphrase opens nothing else.
92
+ */
93
+ export declare function exportBackup(engine: Engine, body?: ExportBody): Promise<ExportResult>;
94
+ /**
95
+ * Put what a picked backup file holds onto this computer.
96
+ *
97
+ * This is the one action that must work on a computer that has never synced: the
98
+ * whole point of the file is to arrive somewhere new. So nothing here asks the
99
+ * configuration to be usable, and nothing is compared with the server.
100
+ *
101
+ * What it does *not* do is touch the sync baseline. The parts change here, the
102
+ * stored hashes still describe the setup from before, so the next sync sees the
103
+ * difference and pushes it — which is what someone who imported a setup on a new
104
+ * computer wants to happen.
105
+ */
106
+ export declare function importBackup(engine: Engine, body?: ImportBody): Promise<ImportResult>;
80
107
  /** Put back the setup the last restore replaced. */
81
108
  export declare function undo(engine: Engine): Promise<SyncResult>;
82
109
  /**
package/lib/engine.js CHANGED
@@ -13,9 +13,10 @@
13
13
  * with 409, a write that went out without the server checking its version, a body
14
14
  * a relay cut in half.
15
15
  */
16
- import { readFile } from 'node:fs/promises';
17
- import { join } from 'node:path';
18
- import { BUNDLE_APP, BUNDLE_FORMAT, BUNDLE_VERSION, PARTS, REMOTE_FILE, } from './wire.js';
16
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
17
+ import { homedir } from 'node:os';
18
+ import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
19
+ import { BACKUP_EXT, BUNDLE_APP, BUNDLE_FORMAT, BUNDLE_VERSION, PARTS, REMOTE_FILE, } from './wire.js';
19
20
  import { CorruptError, NotBackupError, PassphraseError, hashJson, open, seal, sum } from './crypto.js';
20
21
  import { Dav, LoginError, RateLimitedError, parseHeaders } from './dav.js';
21
22
  import { dirsDiffer, dirsPartOf, readDirs, writeDirs } from './dirs.js';
@@ -164,6 +165,11 @@ async function syncOnce(engine, state, force) {
164
165
  state.blind = undefined;
165
166
  }
166
167
  const snapshot = await take(paths, config, engine.credentials);
168
+ if (snapshot.keysProblems.length === 0 && state.local?.keys !== undefined) {
169
+ // Older state files predate the explicit marker; an existing keys hash is
170
+ // already a trustworthy baseline, even before this run measures it again.
171
+ state.keysBaseline = true;
172
+ }
167
173
  let remote = await openRemote(engine, state, read);
168
174
  const first = state.local === undefined;
169
175
  // A directory could not be read this run: its part stands in for the server's
@@ -175,7 +181,11 @@ async function syncOnce(engine, state, force) {
175
181
  && sameHashes(hashesOf(partsOf(snapshot, remote.part('dirs'))), state.local)
176
182
  && !force) {
177
183
  // Neither side moved: the last sync is still the state of the world.
178
- return { here: [], there: [], pushed: false, reloadNeeded: false, ...(await pendings(engine, snapshot.parts.plugins)) };
184
+ return {
185
+ here: [], there: [], pushed: false, reloadNeeded: false,
186
+ ...keyProblems(snapshot.keysProblems),
187
+ ...(await pendings(engine, snapshot.parts.plugins)),
188
+ };
179
189
  }
180
190
  if (remote.notModified) {
181
191
  // The server said the file is the one last read, which was worth knowing for
@@ -185,6 +195,10 @@ async function syncOnce(engine, state, force) {
185
195
  }
186
196
  const carried = remote.part('dirs');
187
197
  const localParts = partsOf(snapshot, carried);
198
+ // A failed credential read is unknown, not a deletion or a partial replacement.
199
+ // Defer this part while the other parts sync, carrying the server's copy below.
200
+ if (snapshot.keysProblems.length > 0)
201
+ delete localParts.keys;
188
202
  const local = hashesOf(localParts);
189
203
  // The server holds no file of ours. This computer's setup becomes the first
190
204
  // version, which is what joining an empty folder means.
@@ -206,10 +220,10 @@ async function syncOnce(engine, state, force) {
206
220
  for (const part of PARTS) {
207
221
  const remoteHas = remote.hashes[part] !== undefined;
208
222
  const remotePart = () => remote.part(part);
209
- if (!syncsPart(config, part)) {
210
- // Not carried with this configuration. The server's copy is carried through
211
- // the upload untouched — turning a part off must never delete it for the
212
- // computers that still sync it — and nothing is written here from it.
223
+ if (!syncsPart(config, part) || (part === 'keys' && snapshot.keysProblems.length > 0)) {
224
+ // Disabled or unreadable: carry the server's copy untouched. In particular,
225
+ // a missing/locked credential service must not look like local key deletion
226
+ // and erase the keys already uploaded by a successful manual sync.
213
227
  if (remoteHas)
214
228
  merged[part] = remotePart();
215
229
  continue;
@@ -218,7 +232,8 @@ async function syncOnce(engine, state, force) {
218
232
  // the last sync — not merely when its presence changed. Comparing presence
219
233
  // would read an edit made only on the other computer as "unchanged" and
220
234
  // quietly keep this computer's older copy.
221
- const changedHere = !first && local[part] !== state.local?.[part];
235
+ const missingKeyBaseline = !first && state.keysBaseline !== true && part === 'keys';
236
+ const changedHere = !first && !missingKeyBaseline && local[part] !== state.local?.[part];
222
237
  const changedThere = first || remote.hashes[part] !== state.remote?.[part];
223
238
  // A part the other computer took away is taken away here too — but never the
224
239
  // plugin list, which is what loads the shell, and never on a first sync,
@@ -327,11 +342,12 @@ async function syncOnce(engine, state, force) {
327
342
  const pushNeeded = force || remote.rebuilt === true || !sameHashes(mergedHashes, remote.hashes);
328
343
  if (!pushNeeded) {
329
344
  const written = await applyMerged(engine, snapshot, applied);
330
- state.remote = remote.hashes;
331
345
  // Read back rather than assume: a directory whose file was just written — or
332
346
  // removed — has to be hashed as it now is, or the next sync would see an edit
333
- // that this one made itself and call it a conflict.
334
- state.local = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
347
+ // that this one made itself and call it a conflict. Keep the key baselines if
348
+ // this read could not measure them, so recovery still sees pending edits.
349
+ const after = await take(paths, config, engine.credentials);
350
+ recordHashes(state, hashesOf(partsOf(after, undefined)), remote.hashes, snapshot.keysProblems.length === 0 && after.keysProblems.length === 0);
335
351
  // A part can come in without the file needing to be written: when only the
336
352
  // server moved, the merge agrees with it. The person still has to be told
337
353
  // what changed here, so the notice is recorded on this path too — clearing
@@ -351,7 +367,7 @@ async function syncOnce(engine, state, force) {
351
367
  ...(saved === undefined ? {} : { saved }),
352
368
  pushed: false,
353
369
  reloadNeeded: written.reloadNeeded,
354
- ...keyProblems(written.keysProblems),
370
+ ...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
355
371
  ...(await pendings(engine, applied.plugins)),
356
372
  };
357
373
  }
@@ -556,11 +572,11 @@ async function push(engine, state, snapshot, merged) {
556
572
  // failed upload never leaves this computer holding another computer's setup
557
573
  // with nothing to say where its own went.
558
574
  const applied = await applyMerged(engine, snapshot, merged.applied);
559
- const now = hashesOf(partsOf(await take(paths, config, engine.credentials), undefined));
575
+ const after = await take(paths, config, engine.credentials);
576
+ const now = hashesOf(partsOf(after, undefined));
560
577
  state.server = written;
561
578
  state.sum = sum(sealed);
562
- state.remote = hashesOf(bundle);
563
- state.local = now;
579
+ recordHashes(state, now, hashesOf(bundle), snapshot.keysProblems.length === 0 && after.keysProblems.length === 0);
564
580
  state.blind = written.etag === undefined || written.etag === '' ? { ...before } : undefined;
565
581
  state.undo = undefined;
566
582
  const notice = merged.here.length > 0 || merged.there.length > 0
@@ -579,7 +595,7 @@ async function push(engine, state, snapshot, merged) {
579
595
  ...(merged.saved === undefined ? {} : { saved: merged.saved }),
580
596
  pushed: true,
581
597
  reloadNeeded: applied.reloadNeeded,
582
- ...keyProblems(applied.keysProblems),
598
+ ...keyProblems([...snapshot.keysProblems, ...applied.keysProblems]),
583
599
  ...(await pendings(engine, merged.merged.plugins)),
584
600
  };
585
601
  }
@@ -664,6 +680,29 @@ async function applyMerged(engine, snapshot, applied) {
664
680
  }
665
681
  return { reloadNeeded, keysProblems };
666
682
  }
683
+ /** Record measured parts without turning an unreadable key store into a baseline. */
684
+ function recordHashes(state, local, remote, keysReadable) {
685
+ if (keysReadable)
686
+ state.keysBaseline = true;
687
+ if (!keysReadable) {
688
+ // Keep both comparison points: when the service returns, local and remote
689
+ // key edits still need to be merged rather than accepted as already applied.
690
+ if (state.local?.keys === undefined)
691
+ delete local.keys;
692
+ else
693
+ local.keys = state.local.keys;
694
+ remote = { ...remote };
695
+ if (state.remote?.keys === undefined)
696
+ delete remote.keys;
697
+ else
698
+ remote.keys = state.remote.keys;
699
+ // The server may have changed keys during this attempt. A conditional 304
700
+ // would hide its body on recovery even though those keys are still pending.
701
+ state.server = undefined;
702
+ }
703
+ state.local = local;
704
+ state.remote = remote;
705
+ }
667
706
  /**
668
707
  * The `keysProblems` field of a result, present only when there is one.
669
708
  *
@@ -838,7 +877,7 @@ export async function restore(engine) {
838
877
  ...(kept === undefined ? {} : { saved: keptDir(paths) }),
839
878
  pushed: false,
840
879
  reloadNeeded: written.reloadNeeded,
841
- ...keyProblems(written.keysProblems),
880
+ ...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
842
881
  ...(await pendings(engine, applied.plugins)),
843
882
  };
844
883
  });
@@ -897,6 +936,179 @@ export async function upload(engine) {
897
936
  return result;
898
937
  });
899
938
  }
939
+ /**
940
+ * Write this computer's setup into one passphrase-sealed file the person can carry.
941
+ *
942
+ * The file is the same sealed bundle the server holds, so a manual backup and a
943
+ * sync backup are one format and either can be opened by the other. What goes in
944
+ * is what the configuration syncs — the five parts behind the switches — because
945
+ * "打包上面这些" means the section above the button, and a part that is switched
946
+ * off is one this person already said they do not want travelling.
947
+ *
948
+ * The passphrase is asked for here rather than taken from the configuration: this
949
+ * file leaves the computer, and the sync passphrase is the one that also opens the
950
+ * copy on the server. A file sealed with its own passphrase opens nothing else.
951
+ */
952
+ export async function exportBackup(engine, body = {}) {
953
+ return guarded(engine, async () => {
954
+ const { paths, config } = engine;
955
+ const passphrase = (body.passphrase ?? '').trim();
956
+ if (passphrase === '')
957
+ throw new Error('导出需要一个口令来加密这个文件');
958
+ await ensureStateDir(paths);
959
+ const snapshot = await take(paths, config, engine.credentials);
960
+ const whole = partsOf(snapshot, undefined);
961
+ const parts = {};
962
+ for (const part of PARTS) {
963
+ if (!syncsPart(config, part))
964
+ continue;
965
+ const value = whole[part];
966
+ if (value !== undefined)
967
+ parts[part] = value;
968
+ }
969
+ const carried = Object.keys(parts);
970
+ if (carried.length === 0)
971
+ throw new Error('这份配置没有可以导出的内容:请先在上面打开要同步的项');
972
+ const bundle = {
973
+ format: BUNDLE_FORMAT,
974
+ version: BUNDLE_VERSION,
975
+ created: new Date().toISOString(),
976
+ app: BUNDLE_APP,
977
+ ...parts,
978
+ };
979
+ const sealed = await seal(bundle, passphrase);
980
+ const file = exportTarget(body.dir);
981
+ await mkdir(dirname(file), { recursive: true });
982
+ await writeFile(file, sealed);
983
+ return { file, size: sealed.byteLength, parts: carried };
984
+ });
985
+ }
986
+ /** The file an export writes: the folder that was picked, or a full path. */
987
+ function exportTarget(dir) {
988
+ const asked = (dir ?? '').trim();
989
+ // `dsh-webdav-20261021-180935.prop`: the timestamp is the same minute-precision
990
+ // form the kept copies use, so two exports in one session never collide and the
991
+ // folder sorts in the order they were taken.
992
+ const name = `dsh-webdav-${new Date().toISOString().replace(/[-:]/g, '').replace(/\..+$/, '').replace('T', '-')}${BACKUP_EXT}`;
993
+ if (asked === '')
994
+ return join(homedir(), name);
995
+ if (!isAbsolute(asked)) {
996
+ // A relative path means something different to the person typing it than to
997
+ // this process, so guessing a base would put the file somewhere they will not
998
+ // look for it. The folder picker always answers an absolute path.
999
+ throw new Error(`导出位置必须是绝对路径,收到的是「${asked}」`);
1000
+ }
1001
+ // A path that already names a file is taken as one; a folder gets the generated
1002
+ // name inside it. The extension is what tells the two apart.
1003
+ return extname(asked) === '' ? join(resolve(asked), name) : resolve(asked);
1004
+ }
1005
+ /**
1006
+ * Put what a picked backup file holds onto this computer.
1007
+ *
1008
+ * This is the one action that must work on a computer that has never synced: the
1009
+ * whole point of the file is to arrive somewhere new. So nothing here asks the
1010
+ * configuration to be usable, and nothing is compared with the server.
1011
+ *
1012
+ * What it does *not* do is touch the sync baseline. The parts change here, the
1013
+ * stored hashes still describe the setup from before, so the next sync sees the
1014
+ * difference and pushes it — which is what someone who imported a setup on a new
1015
+ * computer wants to happen.
1016
+ */
1017
+ export async function importBackup(engine, body = {}) {
1018
+ return guarded(engine, async () => {
1019
+ const { paths, config } = engine;
1020
+ const passphrase = (body.passphrase ?? '').trim();
1021
+ if (passphrase === '')
1022
+ throw new Error('导入需要这个文件的口令');
1023
+ const text = body.text ?? '';
1024
+ if (text.trim() === '')
1025
+ throw new Error('没有读到文件内容:请重新选择那个备份文件');
1026
+ const bundle = await openBackupFile(text, passphrase);
1027
+ const unreadable = unreadablePart(bundle);
1028
+ if (unreadable !== undefined) {
1029
+ throw new Error(`这个文件里「${unreadable}」这一份的形状不是本插件写出来的:它可能来自另一个版本的插件`);
1030
+ }
1031
+ await ensureStateDir(paths);
1032
+ const snapshot = await take(paths, config, engine.credentials);
1033
+ const local = hashesOf(partsOf(snapshot, undefined));
1034
+ const remote = hashesOf(bundle);
1035
+ const merged = {};
1036
+ const applied = {};
1037
+ const here = [];
1038
+ const same = [];
1039
+ for (const part of PARTS) {
1040
+ const value = bundle[part];
1041
+ if (value === undefined)
1042
+ continue;
1043
+ // Applied whatever the switches say: a file that was picked and opened is an
1044
+ // explicit instruction about this one moment, not a standing arrangement.
1045
+ merged[part] = value;
1046
+ applied[part] = value;
1047
+ if (local[part] === remote[part])
1048
+ same.push(part);
1049
+ else
1050
+ here.push(part);
1051
+ }
1052
+ if (here.length === 0) {
1053
+ // Nothing differs, so nothing is written: a no-op import that still rewrote
1054
+ // every file would only make the reload prompt meaningless.
1055
+ return {
1056
+ here: [],
1057
+ same,
1058
+ reloadNeeded: false,
1059
+ pendingInstall: false,
1060
+ created: bundle.created,
1061
+ };
1062
+ }
1063
+ // A copy is kept only when there is a passphrase to seal it with. On a fresh
1064
+ // computer there is none, and `keepBefore` answers `undefined` — the setup
1065
+ // being replaced there is the empty one anyway.
1066
+ const kept = await keepBefore(engine, snapshot, true);
1067
+ const written = await applyMerged(engine, snapshot, applied);
1068
+ const state = await freshState(paths, config);
1069
+ const at = new Date().toISOString();
1070
+ if (kept !== undefined)
1071
+ state.undo = kept;
1072
+ state.last = at;
1073
+ state.error = '';
1074
+ state.moved = at;
1075
+ await saveState(paths, state);
1076
+ return {
1077
+ here,
1078
+ same,
1079
+ ...(kept === undefined ? {} : { saved: keptDir(paths) }),
1080
+ reloadNeeded: written.reloadNeeded,
1081
+ ...(await pendings(engine, applied.plugins)),
1082
+ ...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
1083
+ created: bundle.created,
1084
+ };
1085
+ });
1086
+ }
1087
+ /** Open a picked file's text as a bundle of ours, with messages a person can act on. */
1088
+ async function openBackupFile(text, passphrase) {
1089
+ const data = Buffer.from(text, 'utf8');
1090
+ let opened;
1091
+ try {
1092
+ opened = await open(data, passphrase);
1093
+ }
1094
+ catch (cause) {
1095
+ if (cause instanceof PassphraseError) {
1096
+ throw new Error('口令打不开这个文件:请用导出它时用的那个口令');
1097
+ }
1098
+ if (cause instanceof CorruptError) {
1099
+ throw new Error(`${cause.message}——这个文件可能没有拷全,请重新导出一次`);
1100
+ }
1101
+ if (cause instanceof NotBackupError) {
1102
+ throw new Error('这个文件不是 dsh-webdav-sync 导出的备份:请选另一个文件');
1103
+ }
1104
+ throw cause;
1105
+ }
1106
+ if (opened === null || typeof opened !== 'object' || opened.format !== BUNDLE_FORMAT) {
1107
+ throw new Error('这个文件不是 dsh-webdav-sync 导出的备份:请选另一个文件');
1108
+ }
1109
+ const bundle = opened;
1110
+ return { ...bundle, created: typeof bundle.created === 'string' ? bundle.created : new Date(0).toISOString() };
1111
+ }
900
1112
  /** Put back the setup the last restore replaced. */
901
1113
  export async function undo(engine) {
902
1114
  return guarded(engine, async () => {
@@ -939,7 +1151,7 @@ export async function undo(engine) {
939
1151
  there: [],
940
1152
  pushed: false,
941
1153
  reloadNeeded: written.reloadNeeded,
942
- ...keyProblems(written.keysProblems),
1154
+ ...keyProblems([...snapshot.keysProblems, ...written.keysProblems]),
943
1155
  ...(await pendings(engine, merged.plugins)),
944
1156
  };
945
1157
  });
package/lib/index.js CHANGED
@@ -15,10 +15,10 @@
15
15
  */
16
16
  import { isAbsolute } from 'node:path';
17
17
  import z from '@deepseek-ai/schemastery';
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
+ import { AUTO_CHOICES, DIRS_MAX, ROUTE_CHECK, ROUTE_CONFIG, ROUTE_DISMISS, ROUTE_EXPORT, ROUTE_IMPORT, ROUTE_INSTALL, ROUTE_OFF, ROUTE_PREVIEW, ROUTE_RESTORE, ROUTE_STATUS, ROUTE_SYNC, ROUTE_UNDO, ROUTE_UPLOAD, } from './wire.js';
19
19
  import { validateProfileDir, resolveFromEnv } from './paths.js';
20
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';
21
+ import { check, dismiss, engineFor, exportBackup, importBackup, loadConfig, preview, restore, sameAccount, status, sync, turnOff, undo, upload, usable, } from './engine.js';
22
22
  import { installPlugins } from './install.js';
23
23
  import { keysPort } from './keys.js';
24
24
  import { sameSecret } from './crypto.js';
@@ -29,6 +29,15 @@ export const name = 'dsh-webdav-sync';
29
29
  export const inject = [];
30
30
  /** Largest accepted request body (bytes) — these payloads are a few strings. */
31
31
  const BODY_LIMIT = 64 * 1024;
32
+ /**
33
+ * Largest body the import route accepts: a whole sealed backup file, as text.
34
+ *
35
+ * The other routes carry a handful of form fields; this one carries a file, and a
36
+ * backup that includes directories carries their files as base64 inside it. The
37
+ * ceiling is the same 32 MiB the kept copies are bounded by, so a file this plugin
38
+ * would have written is a file it will read back.
39
+ */
40
+ const IMPORT_LIMIT = 32 * 1024 * 1024;
32
41
  /** How long the auto-sync timer waits before its first attempt. */
33
42
  const FIRST_DELAY_MS = 20_000;
34
43
  function readVolatile(value) {
@@ -244,12 +253,12 @@ export function apply(ctx, config = {}) {
244
253
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
245
254
  res.end(JSON.stringify(body));
246
255
  };
247
- const readJson = (req) => new Promise((resolvePromise, rejectPromise) => {
256
+ const readJson = (req, limit = BODY_LIMIT) => new Promise((resolvePromise, rejectPromise) => {
248
257
  const chunks = [];
249
258
  let size = 0;
250
259
  req.on('data', (chunk) => {
251
260
  size += chunk.length;
252
- if (size > BODY_LIMIT) {
261
+ if (size > limit) {
253
262
  req.destroy();
254
263
  rejectPromise(new Error('请求内容过大'));
255
264
  return;
@@ -276,13 +285,13 @@ export function apply(ctx, config = {}) {
276
285
  * a person ("服务器上没有目录 … 请先在那边建好"), not to be mapped
277
286
  * onto status codes.
278
287
  */
279
- const route = (path, label, handle) => {
288
+ const route = (path, label, handle, limit = BODY_LIMIT) => {
280
289
  webCtx.effect(() => webCtx.webServer.register({
281
290
  kind: 'exact',
282
291
  path,
283
292
  handler: async (req, res) => {
284
293
  try {
285
- await handle(req, res, req.method === 'GET' ? {} : await readJson(req));
294
+ await handle(req, res, req.method === 'GET' ? {} : await readJson(req, limit));
286
295
  }
287
296
  catch (error) {
288
297
  const text = error instanceof Error ? error.message : String(error);
@@ -330,6 +339,17 @@ export function apply(ctx, config = {}) {
330
339
  route(ROUTE_UNDO, 'undo', async (_req, res) => {
331
340
  send(res, 200, await undo(await engine()));
332
341
  });
342
+ // The two manual-backup routes are the one pair that works with the filesystem
343
+ // on this side of the wire. The card cannot save a file — the desktop shell
344
+ // cancels browser downloads and exposes no save dialog — and cannot read one
345
+ // either, so the export is written here and the import arrives as the file's
346
+ // own text. See `docs/decisions/0060-manual-backup.md`.
347
+ route(ROUTE_EXPORT, 'export', async (_req, res, body) => {
348
+ send(res, 200, await exportBackup(await engine(), (body ?? {})));
349
+ });
350
+ route(ROUTE_IMPORT, 'import', async (_req, res, body) => {
351
+ send(res, 200, await importBackup(await engine(), (body ?? {})));
352
+ }, IMPORT_LIMIT);
333
353
  route(ROUTE_UPLOAD, 'upload', async (_req, res) => {
334
354
  send(res, 200, await upload(await engine()));
335
355
  });
@@ -348,7 +368,7 @@ export function apply(ctx, config = {}) {
348
368
  send(res, 200, await installPlugins(current.paths));
349
369
  });
350
370
  });
351
- startTimer(ctx, currentConfig);
371
+ startTimer(ctx, engine);
352
372
  }
353
373
  /** Make sure the state directory exists, so the first sync has somewhere to write. */
354
374
  async function saveStateIfUsable(engine) {
@@ -368,7 +388,7 @@ async function saveStateIfUsable(engine) {
368
388
  * A failure is recorded in the state by `sync` itself and changes nothing here:
369
389
  * the next tick tries again, which is the whole point of a timer.
370
390
  */
371
- function startTimer(ctx, currentConfig) {
391
+ function startTimer(ctx, currentEngine) {
372
392
  let timer;
373
393
  let stopped = false;
374
394
  const tick = async () => {
@@ -376,11 +396,12 @@ function startTimer(ctx, currentConfig) {
376
396
  return;
377
397
  let wait = 0;
378
398
  try {
379
- const paths = resolveFromEnv(readVolatile(currentConfig().profileDir));
380
- const { config: stored } = await loadConfig(paths);
381
- const minutes = stored.autoEvery;
382
- if (usable(stored) && minutes > 0) {
383
- await sync(engineFor(paths, stored)).catch(() => undefined);
399
+ // Use the same fresh engine as the routes: reading only sync.json would
400
+ // drop both the host credential service and a session-only WebDAV password.
401
+ const current = await currentEngine();
402
+ const minutes = current.config.autoEvery;
403
+ if (usable(current.config) && minutes > 0) {
404
+ await sync(current).catch(() => undefined);
384
405
  wait = minutes * 60_000;
385
406
  }
386
407
  }
package/lib/wire.d.ts CHANGED
@@ -31,6 +31,10 @@ export declare const ROUTE_INSTALL = "/plugin/webdav-sync/install";
31
31
  export declare const ROUTE_PREVIEW = "/plugin/webdav-sync/preview";
32
32
  /** POST: put back the copies a restore replaced. */
33
33
  export declare const ROUTE_UNDO = "/plugin/webdav-sync/undo";
34
+ /** POST: write one passphrase-sealed backup file into a folder the person picked. */
35
+ export declare const ROUTE_EXPORT = "/plugin/webdav-sync/export";
36
+ /** POST: open a picked backup file and put what it holds on this computer. */
37
+ export declare const ROUTE_IMPORT = "/plugin/webdav-sync/import";
34
38
  /** Error envelope shared by every route. */
35
39
  export interface RouteError {
36
40
  error: string;
@@ -292,6 +296,8 @@ export interface StoredState extends SyncRecord {
292
296
  key?: string | undefined;
293
297
  /** Last sync attempt, ISO-8601. */
294
298
  last?: string | undefined;
299
+ /** True once keys have a trustworthy local comparison baseline. */
300
+ keysBaseline?: boolean | undefined;
295
301
  /**
296
302
  * Last attempt that actually moved something, ISO-8601: a push, a part taken
297
303
  * from the server, or a restore. Absent until one has. This is the difference
@@ -567,6 +573,67 @@ export interface InstallResult {
567
573
  export interface MessageResult {
568
574
  message: string;
569
575
  }
576
+ /** POST {@link ROUTE_EXPORT} body. */
577
+ export interface ExportBody {
578
+ /**
579
+ * The passphrase this file is sealed with.
580
+ *
581
+ * It is asked for on every export rather than taken from the configuration:
582
+ * the file leaves this computer, and the sync passphrase is the one thing that
583
+ * also opens the copy on the server. Typing a second one for the file means a
584
+ * file that leaks opens nothing else.
585
+ */
586
+ passphrase?: string;
587
+ /**
588
+ * Where to write it: a folder, or a full path ending in `.prop`.
589
+ *
590
+ * Blank means the home folder. A folder is asked for rather than fixed because
591
+ * the point of the file is to be carried somewhere — a removable disk, a shared
592
+ * folder — and only the person knows which one.
593
+ */
594
+ dir?: string;
595
+ }
596
+ /** POST {@link ROUTE_EXPORT} result. */
597
+ export interface ExportResult {
598
+ /** Absolute path of the file that was written. */
599
+ file: string;
600
+ /** How many bytes it is. */
601
+ size: number;
602
+ /** Which parts of the setup the file carries. */
603
+ parts: Part[];
604
+ }
605
+ /** POST {@link ROUTE_IMPORT} body. */
606
+ export interface ImportBody {
607
+ /** The passphrase the file was sealed with. */
608
+ passphrase?: string;
609
+ /**
610
+ * The file's own text, as the browser read it.
611
+ *
612
+ * It travels as text rather than as a path because the browser half cannot see
613
+ * the filesystem: a picked file is a `File` whose bytes only that half holds.
614
+ * The sealed backup is indented JSON, so its text is its bytes.
615
+ */
616
+ text?: string;
617
+ }
618
+ /** POST {@link ROUTE_IMPORT} result. */
619
+ export interface ImportResult {
620
+ /** Parts the file put on this computer. */
621
+ here: Part[];
622
+ /** Parts the file carried that this computer already held unchanged. */
623
+ same: Part[];
624
+ /** Folder the replaced copies were kept in, when any were. */
625
+ saved?: string;
626
+ /** The profile has to be reloaded for the applied configuration to take effect. */
627
+ reloadNeeded: boolean;
628
+ /** The profile now declares plugins that are not installed; run the install action. */
629
+ pendingInstall: boolean;
630
+ /** Which plugins those are, present exactly when {@link pendingInstall} is true. */
631
+ pendingNames?: string[];
632
+ /** Keys the file brought that this computer's credential store would not take. */
633
+ keysProblems?: KeyProblem[];
634
+ /** When the exporting computer wrote the file, ISO-8601. */
635
+ created: string;
636
+ }
570
637
  /** Which of a part's two sides a preview call would take. */
571
638
  export interface PreviewChange {
572
639
  part: Part;
package/lib/wire.js CHANGED
@@ -31,6 +31,10 @@ export const ROUTE_INSTALL = `${ROUTE_PREFIX}/install`;
31
31
  export const ROUTE_PREVIEW = `${ROUTE_PREFIX}/preview`;
32
32
  /** POST: put back the copies a restore replaced. */
33
33
  export const ROUTE_UNDO = `${ROUTE_PREFIX}/undo`;
34
+ /** POST: write one passphrase-sealed backup file into a folder the person picked. */
35
+ export const ROUTE_EXPORT = `${ROUTE_PREFIX}/export`;
36
+ /** POST: open a picked backup file and put what it holds on this computer. */
37
+ export const ROUTE_IMPORT = `${ROUTE_PREFIX}/import`;
34
38
  // ---------------------------------------------------------------------------
35
39
  // Remote folder layout
36
40
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-webdav-sync",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "用 WebDAV 在多台电脑之间同步 DSH 的配置:模型供应商与模型、已安装插件、其余 profile 配置、附加目录与 API 密钥。整个备份在离开本机前用口令加密(AES-256-GCM + PBKDF2),以分片三方合并的方式在服务端保留一个加密文件。",
5
5
  "keywords": [
6
6
  "dsh",