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/README.md +8 -3
- package/lib/client.js +618 -56
- package/lib/engine.d.ts +28 -1
- package/lib/engine.js +231 -19
- package/lib/index.js +34 -13
- package/lib/wire.d.ts +67 -0
- package/lib/wire.js +4 -0
- package/package.json +1 -1
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 {
|
|
18
|
-
import {
|
|
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 {
|
|
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
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 >
|
|
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,
|
|
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,
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
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