dsh-webdav-sync 1.0.5 → 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';
@@ -935,6 +936,179 @@ export async function upload(engine) {
935
936
  return result;
936
937
  });
937
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
+ }
938
1112
  /** Put back the setup the last restore replaced. */
939
1113
  export async function undo(engine) {
940
1114
  return guarded(engine, async () => {
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
  });
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;
@@ -569,6 +573,67 @@ export interface InstallResult {
569
573
  export interface MessageResult {
570
574
  message: string;
571
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
+ }
572
637
  /** Which of a part's two sides a preview call would take. */
573
638
  export interface PreviewChange {
574
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.5",
3
+ "version": "1.0.6",
4
4
  "description": "用 WebDAV 在多台电脑之间同步 DSH 的配置:模型供应商与模型、已安装插件、其余 profile 配置、附加目录与 API 密钥。整个备份在离开本机前用口令加密(AES-256-GCM + PBKDF2),以分片三方合并的方式在服务端保留一个加密文件。",
5
5
  "keywords": [
6
6
  "dsh",