@sparkelf/dsh-plugin-backup 0.1.0-rc.10
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +67 -0
- package/README.zh.md +67 -0
- package/lib/client.js +594 -0
- package/lib/index.js +701 -0
- package/lib/invariant.js +18 -0
- package/lib/types/archive.d.ts +63 -0
- package/lib/types/client/BackupSection.d.ts +42 -0
- package/lib/types/client/index.d.ts +17 -0
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/store.d.ts +20 -0
- package/lib/types/client/types.d.ts +29 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/invariant.d.ts +11 -0
- package/lib/types/protocol.d.ts +32 -0
- package/lib/types/routes.d.ts +20 -0
- package/package.json +83 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { once } from "node:events";
|
|
5
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
6
|
+
import { mkdir, mkdtemp, readdir, rm, stat } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { Transform } from "node:stream";
|
|
9
|
+
import { finished, pipeline } from "node:stream/promises";
|
|
10
|
+
import { Zip, ZipDeflate, ZipPassThrough, strToU8 } from "fflate";
|
|
11
|
+
import { open } from "yauzl";
|
|
12
|
+
//#region lib/types/archive.js
|
|
13
|
+
/**
|
|
14
|
+
* User data backup core for the web settings Backup section. Export scans one
|
|
15
|
+
* stable file plan, reads and compresses bounded chunks, and publishes source-
|
|
16
|
+
* byte progress. Import validates before mutation, then writes bounded chunks
|
|
17
|
+
* while publishing restored-byte progress.
|
|
18
|
+
*/
|
|
19
|
+
/** Manifest entry at the archive root; import validation uses it as the marker. */
|
|
20
|
+
const BACKUP_MANIFEST_ENTRY = "backup-manifest.json";
|
|
21
|
+
/** Runtime-generated harness-home directories that are not user configuration or data. */
|
|
22
|
+
const GENERATED_DIRECTORIES = new Set(["profiles", "supervisor"]);
|
|
23
|
+
/** File read and restore write size. */
|
|
24
|
+
const BACKUP_CHUNK_BYTES = 64 * 1024;
|
|
25
|
+
const EMPTY_BYTES = new Uint8Array(0);
|
|
26
|
+
/**
|
|
27
|
+
* Add one directory's portable entries to a stable export plan.
|
|
28
|
+
* @param rootPath - backup root directory.
|
|
29
|
+
* @param relativePath - current slash-separated path relative to the root.
|
|
30
|
+
* @param entries - plan entries under construction.
|
|
31
|
+
* @param signal - caller or response cancellation.
|
|
32
|
+
* @returns the summed regular-file bytes below this directory.
|
|
33
|
+
*/
|
|
34
|
+
async function planDirectory(rootPath, relativePath, entries, signal) {
|
|
35
|
+
signal.throwIfAborted();
|
|
36
|
+
const children = await readdir(join(rootPath, relativePath), { withFileTypes: true });
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
for (const child of children) {
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
const entryRelative = relativePath === "" ? child.name : relativePath + "/" + child.name;
|
|
41
|
+
if (relativePath === "" && (GENERATED_DIRECTORIES.has(child.name) || child.name === BACKUP_MANIFEST_ENTRY)) continue;
|
|
42
|
+
if (child.isDirectory()) {
|
|
43
|
+
entries.push({
|
|
44
|
+
kind: "directory",
|
|
45
|
+
relativePath: entryRelative
|
|
46
|
+
});
|
|
47
|
+
bytes += await planDirectory(rootPath, entryRelative, entries, signal);
|
|
48
|
+
} else if (child.isFile()) {
|
|
49
|
+
const { size } = await stat(join(rootPath, entryRelative));
|
|
50
|
+
entries.push({
|
|
51
|
+
kind: "file",
|
|
52
|
+
relativePath: entryRelative,
|
|
53
|
+
size
|
|
54
|
+
});
|
|
55
|
+
bytes += size;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return bytes;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the stable file list and source-byte total used by one export.
|
|
62
|
+
* @param dshHome - harness user data directory.
|
|
63
|
+
* @param signal - caller or response cancellation.
|
|
64
|
+
* @returns the planned entries, manifest bytes, and measurable byte total.
|
|
65
|
+
*/
|
|
66
|
+
async function planUserBackup(dshHome, signal) {
|
|
67
|
+
const manifest = strToU8(JSON.stringify({
|
|
68
|
+
app: "deepseek-harness",
|
|
69
|
+
kind: "user-data-backup",
|
|
70
|
+
version: 1,
|
|
71
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
72
|
+
}, null, 2) + String.fromCharCode(10));
|
|
73
|
+
const entries = [];
|
|
74
|
+
return {
|
|
75
|
+
entries,
|
|
76
|
+
manifest,
|
|
77
|
+
totalBytes: await planDirectory(dshHome, "", entries, signal) + manifest.length
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Add one planned file without retaining its complete contents.
|
|
82
|
+
* @param zip - streaming archive receiving the file.
|
|
83
|
+
* @param rootPath - backup root directory.
|
|
84
|
+
* @param entry - planned file and scan-time size.
|
|
85
|
+
* @param waitForOutput - waits for archive output backpressure or failure.
|
|
86
|
+
* @param signal - caller or response cancellation.
|
|
87
|
+
* @param onBytes - publishes each source chunk after archive output accepts it.
|
|
88
|
+
* @returns when the planned file bytes have reached the archive output.
|
|
89
|
+
*/
|
|
90
|
+
async function addFileToZip(zip, rootPath, entry, waitForOutput, signal, onBytes) {
|
|
91
|
+
const archiveFile = new ZipDeflate(entry.relativePath);
|
|
92
|
+
zip.add(archiveFile);
|
|
93
|
+
if (entry.size === 0) {
|
|
94
|
+
archiveFile.push(EMPTY_BYTES, true);
|
|
95
|
+
await waitForOutput();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const source = createReadStream(join(rootPath, entry.relativePath), {
|
|
99
|
+
end: entry.size - 1,
|
|
100
|
+
highWaterMark: BACKUP_CHUNK_BYTES,
|
|
101
|
+
signal
|
|
102
|
+
});
|
|
103
|
+
let readBytes = 0;
|
|
104
|
+
for await (const chunk of source) {
|
|
105
|
+
signal.throwIfAborted();
|
|
106
|
+
archiveFile.push(chunk);
|
|
107
|
+
await waitForOutput();
|
|
108
|
+
readBytes += chunk.length;
|
|
109
|
+
await onBytes(chunk.length);
|
|
110
|
+
}
|
|
111
|
+
if (readBytes !== entry.size) throw new Error("backup source changed while reading: " + entry.relativePath);
|
|
112
|
+
archiveFile.push(EMPTY_BYTES, true);
|
|
113
|
+
await waitForOutput();
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Pack the harness home's user configuration and data into a zip archive,
|
|
117
|
+
* publishing scan and source-byte compression progress. A failed export removes
|
|
118
|
+
* its partial archive.
|
|
119
|
+
* @param dshHome - harness user data directory (settings.yaml parent).
|
|
120
|
+
* @param targetPath - absolute file path the archive is written to.
|
|
121
|
+
* @param signal - caller or response cancellation.
|
|
122
|
+
* @param report - ordered progress writer.
|
|
123
|
+
* @returns the number of zip entries including the manifest marker.
|
|
124
|
+
*/
|
|
125
|
+
async function writeUserBackup(dshHome, targetPath, signal, report) {
|
|
126
|
+
await report({ phase: "scan" });
|
|
127
|
+
const plan = await planUserBackup(dshHome, signal);
|
|
128
|
+
let completedBytes = 0;
|
|
129
|
+
let lastPercent = -1;
|
|
130
|
+
const reportCompression = async () => {
|
|
131
|
+
const percent = Math.floor(completedBytes * 100 / plan.totalBytes);
|
|
132
|
+
if (percent === lastPercent) return;
|
|
133
|
+
lastPercent = percent;
|
|
134
|
+
await report({
|
|
135
|
+
phase: "compress",
|
|
136
|
+
completedBytes,
|
|
137
|
+
totalBytes: plan.totalBytes
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
await reportCompression();
|
|
141
|
+
let outputError;
|
|
142
|
+
const output = createWriteStream(targetPath, {
|
|
143
|
+
flags: "w",
|
|
144
|
+
signal
|
|
145
|
+
});
|
|
146
|
+
const outputSettled = finished(output).catch((error) => {
|
|
147
|
+
outputError = error instanceof Error ? error : new Error(String(error));
|
|
148
|
+
});
|
|
149
|
+
let pendingDrain;
|
|
150
|
+
output.on("error", (error) => {
|
|
151
|
+
outputError = error;
|
|
152
|
+
});
|
|
153
|
+
const zip = new Zip((error, data, final) => {
|
|
154
|
+
if (error !== null) {
|
|
155
|
+
outputError = error;
|
|
156
|
+
output.destroy(error);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (outputError !== void 0) return;
|
|
160
|
+
if (data.length > 0 && !output.write(data)) pendingDrain = once(output, "drain");
|
|
161
|
+
if (final) output.end();
|
|
162
|
+
});
|
|
163
|
+
const waitForOutput = async () => {
|
|
164
|
+
const drain = pendingDrain;
|
|
165
|
+
pendingDrain = void 0;
|
|
166
|
+
if (drain !== void 0) await drain;
|
|
167
|
+
if (outputError !== void 0) throw outputError;
|
|
168
|
+
};
|
|
169
|
+
let complete = false;
|
|
170
|
+
try {
|
|
171
|
+
for (const entry of plan.entries) {
|
|
172
|
+
signal.throwIfAborted();
|
|
173
|
+
if (entry.kind === "directory") {
|
|
174
|
+
const directory = new ZipPassThrough(entry.relativePath + "/");
|
|
175
|
+
zip.add(directory);
|
|
176
|
+
directory.push(EMPTY_BYTES, true);
|
|
177
|
+
await waitForOutput();
|
|
178
|
+
} else await addFileToZip(zip, dshHome, entry, waitForOutput, signal, async (bytes) => {
|
|
179
|
+
completedBytes += bytes;
|
|
180
|
+
await reportCompression();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const manifest = new ZipDeflate(BACKUP_MANIFEST_ENTRY);
|
|
184
|
+
zip.add(manifest);
|
|
185
|
+
manifest.push(plan.manifest, true);
|
|
186
|
+
await waitForOutput();
|
|
187
|
+
completedBytes += plan.manifest.length;
|
|
188
|
+
await reportCompression();
|
|
189
|
+
zip.end();
|
|
190
|
+
await outputSettled;
|
|
191
|
+
if (outputError !== void 0) throw outputError;
|
|
192
|
+
complete = true;
|
|
193
|
+
return { entries: plan.entries.length + 1 };
|
|
194
|
+
} finally {
|
|
195
|
+
if (!complete) {
|
|
196
|
+
zip.terminate();
|
|
197
|
+
output.destroy();
|
|
198
|
+
await outputSettled;
|
|
199
|
+
await rm(targetPath, { force: true });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function openArchive(path) {
|
|
204
|
+
return new Promise((resolveOpen, reject) => {
|
|
205
|
+
open(path, {
|
|
206
|
+
lazyEntries: true,
|
|
207
|
+
autoClose: false,
|
|
208
|
+
validateEntrySizes: true
|
|
209
|
+
}, (error, zip) => {
|
|
210
|
+
if (error !== null) reject(error);
|
|
211
|
+
else resolveOpen(zip);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
function nextArchiveEntry(zip) {
|
|
216
|
+
return new Promise((resolveEntry, reject) => {
|
|
217
|
+
const settle = () => {
|
|
218
|
+
zip.off("entry", onEntry);
|
|
219
|
+
zip.off("end", onEnd);
|
|
220
|
+
zip.off("error", onError);
|
|
221
|
+
};
|
|
222
|
+
const onEntry = (entry) => {
|
|
223
|
+
settle();
|
|
224
|
+
resolveEntry(entry);
|
|
225
|
+
};
|
|
226
|
+
const onEnd = () => {
|
|
227
|
+
settle();
|
|
228
|
+
resolveEntry(void 0);
|
|
229
|
+
};
|
|
230
|
+
const onError = (error) => {
|
|
231
|
+
settle();
|
|
232
|
+
reject(error);
|
|
233
|
+
};
|
|
234
|
+
zip.once("entry", onEntry);
|
|
235
|
+
zip.once("end", onEnd);
|
|
236
|
+
zip.once("error", onError);
|
|
237
|
+
zip.readEntry();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function openEntryStream(zip, entry) {
|
|
241
|
+
return new Promise((resolveStream, reject) => {
|
|
242
|
+
zip.openReadStream(entry, (error, stream) => {
|
|
243
|
+
if (error !== null) reject(error);
|
|
244
|
+
else resolveStream(stream);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
function requireSafeArchivePath(name) {
|
|
249
|
+
if (name.startsWith("/") || name.includes("\\") || /^[A-Za-z]:/u.test(name)) throw new Error("Backup archive contains an unsafe path: " + name);
|
|
250
|
+
const parts = name.split("/");
|
|
251
|
+
for (const [index, part] of parts.entries()) {
|
|
252
|
+
if (part === ".." || part === ".") throw new Error("Backup archive contains an unsafe path: " + name);
|
|
253
|
+
if (part === "" && index < parts.length - 1) throw new Error("Backup archive contains an unsafe path: " + name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Validate and extract one staged zip without retaining archive or entry bodies in memory.
|
|
258
|
+
* @param archivePath - disk-staged raw upload.
|
|
259
|
+
* @param stagingRoot - temporary extraction root outside the harness home.
|
|
260
|
+
* @param maxExpandedBytes - aggregate expanded-byte limit shared with upload admission.
|
|
261
|
+
* @param signal - response cancellation honored until DSH-home mutation begins.
|
|
262
|
+
* @returns the validated staged entry plan.
|
|
263
|
+
*/
|
|
264
|
+
async function validateUserBackup(archivePath, stagingRoot, maxExpandedBytes, signal) {
|
|
265
|
+
const zip = await openArchive(archivePath);
|
|
266
|
+
const entries = [];
|
|
267
|
+
const names = /* @__PURE__ */ new Set();
|
|
268
|
+
let manifestSeen = false;
|
|
269
|
+
let expandedBytes = 0;
|
|
270
|
+
let totalBytes = 0;
|
|
271
|
+
try {
|
|
272
|
+
for (;;) {
|
|
273
|
+
signal.throwIfAborted();
|
|
274
|
+
const entry = await nextArchiveEntry(zip);
|
|
275
|
+
if (entry === void 0) break;
|
|
276
|
+
const name = entry.fileName;
|
|
277
|
+
requireSafeArchivePath(name);
|
|
278
|
+
if (names.has(name)) throw new Error("Backup archive contains a duplicate path: " + name);
|
|
279
|
+
names.add(name);
|
|
280
|
+
const directory = name.endsWith("/");
|
|
281
|
+
if (!Number.isSafeInteger(entry.uncompressedSize) || entry.uncompressedSize < 0 || expandedBytes + entry.uncompressedSize > maxExpandedBytes) throw new Error("Backup archive expanded data exceeds the configured limit");
|
|
282
|
+
expandedBytes += entry.uncompressedSize;
|
|
283
|
+
if (directory) {
|
|
284
|
+
await mkdir(join(stagingRoot, name), { recursive: true });
|
|
285
|
+
entries.push({
|
|
286
|
+
kind: "directory",
|
|
287
|
+
relativePath: name
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const target = join(stagingRoot, name);
|
|
292
|
+
await mkdir(dirname(target), { recursive: true });
|
|
293
|
+
const source = await openEntryStream(zip, entry);
|
|
294
|
+
let written = 0;
|
|
295
|
+
await pipeline(source, new Transform({ transform(chunk, _encoding, callback) {
|
|
296
|
+
written += chunk.length;
|
|
297
|
+
callback(written > entry.uncompressedSize ? /* @__PURE__ */ new Error("Backup archive entry exceeded its declared size") : null, chunk);
|
|
298
|
+
} }), createWriteStream(target, { flags: "wx" }), { signal });
|
|
299
|
+
if (written !== entry.uncompressedSize) throw new Error("Backup archive entry size mismatch: " + name);
|
|
300
|
+
if (name === BACKUP_MANIFEST_ENTRY) manifestSeen = true;
|
|
301
|
+
else totalBytes += written;
|
|
302
|
+
entries.push({
|
|
303
|
+
kind: "file",
|
|
304
|
+
relativePath: name,
|
|
305
|
+
size: written
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
} finally {
|
|
309
|
+
zip.close();
|
|
310
|
+
}
|
|
311
|
+
if (!manifestSeen) throw new Error("Not a DeepSeek Harness user data backup: missing backup-manifest.json");
|
|
312
|
+
return {
|
|
313
|
+
stagingRoot,
|
|
314
|
+
entries,
|
|
315
|
+
count: entries.length,
|
|
316
|
+
totalBytes
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Write a validated staged archive over the harness home while publishing restored bytes.
|
|
321
|
+
* Once file mutation begins the operation completes rather than honoring a late response
|
|
322
|
+
* cancellation, so user data is not left half-written by closing a page.
|
|
323
|
+
* @param validated - validateUserBackup result.
|
|
324
|
+
* @param dshHome - harness user data directory.
|
|
325
|
+
* @param report - ordered progress writer; a disconnected response may ignore updates.
|
|
326
|
+
* @returns the restored entry count.
|
|
327
|
+
*/
|
|
328
|
+
async function restoreUserBackup(validated, dshHome, report) {
|
|
329
|
+
let completedBytes = 0;
|
|
330
|
+
let lastPercent = -1;
|
|
331
|
+
const reportRestore = async () => {
|
|
332
|
+
const percent = validated.totalBytes === 0 ? 100 : Math.floor(completedBytes * 100 / validated.totalBytes);
|
|
333
|
+
if (percent === lastPercent) return;
|
|
334
|
+
lastPercent = percent;
|
|
335
|
+
await report({
|
|
336
|
+
phase: "restore",
|
|
337
|
+
completedBytes,
|
|
338
|
+
totalBytes: validated.totalBytes
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
await reportRestore();
|
|
342
|
+
for (const entry of validated.entries) {
|
|
343
|
+
if (entry.relativePath === BACKUP_MANIFEST_ENTRY) continue;
|
|
344
|
+
const target = join(dshHome, entry.relativePath);
|
|
345
|
+
if (entry.kind === "directory") {
|
|
346
|
+
await mkdir(target, { recursive: true });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
await mkdir(dirname(target), { recursive: true });
|
|
350
|
+
const progress = new Transform({ transform(chunk, _encoding, callback) {
|
|
351
|
+
completedBytes += chunk.length;
|
|
352
|
+
reportRestore().then(() => {
|
|
353
|
+
callback(null, chunk);
|
|
354
|
+
}, (error) => {
|
|
355
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
356
|
+
});
|
|
357
|
+
} });
|
|
358
|
+
await pipeline(createReadStream(join(validated.stagingRoot, entry.relativePath), { highWaterMark: BACKUP_CHUNK_BYTES }), progress, createWriteStream(target, { flags: "w" }));
|
|
359
|
+
}
|
|
360
|
+
await reportRestore();
|
|
361
|
+
return { entries: validated.count };
|
|
362
|
+
}
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region lib/types/routes.js
|
|
365
|
+
/** Authenticated Host routes for streamed user-data Backup export and import. */
|
|
366
|
+
const BACKUP_TOKEN_TTL_MS = 10 * 6e4;
|
|
367
|
+
var BackupUploadTooLargeError = class extends Error {};
|
|
368
|
+
/** Own one-use Host temp-file tokens and delete every file when its ownership ends. */
|
|
369
|
+
var BackupTokenStore = class {
|
|
370
|
+
entries = /* @__PURE__ */ new Map();
|
|
371
|
+
mintDownload(path, size) {
|
|
372
|
+
this.sweep();
|
|
373
|
+
return this.mint({
|
|
374
|
+
kind: "download",
|
|
375
|
+
path,
|
|
376
|
+
size,
|
|
377
|
+
expiresAt: Date.now() + BACKUP_TOKEN_TTL_MS
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
mintUpload(path) {
|
|
381
|
+
this.sweep();
|
|
382
|
+
return this.mint({
|
|
383
|
+
kind: "upload",
|
|
384
|
+
path,
|
|
385
|
+
expiresAt: Date.now() + BACKUP_TOKEN_TTL_MS
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
peekDownload(token) {
|
|
389
|
+
const entry = this.peek(token);
|
|
390
|
+
return entry?.kind === "download" ? entry : void 0;
|
|
391
|
+
}
|
|
392
|
+
takeDownload(token) {
|
|
393
|
+
const entry = this.peekDownload(token);
|
|
394
|
+
if (entry !== void 0) this.take(token);
|
|
395
|
+
return entry;
|
|
396
|
+
}
|
|
397
|
+
takeUpload(token) {
|
|
398
|
+
const entry = this.peek(token);
|
|
399
|
+
if (entry?.kind !== "upload") return void 0;
|
|
400
|
+
this.take(token);
|
|
401
|
+
return entry;
|
|
402
|
+
}
|
|
403
|
+
async dispose() {
|
|
404
|
+
for (const entry of this.entries.values()) {
|
|
405
|
+
clearTimeout(entry.expiryTimer);
|
|
406
|
+
await removeTempDirectory(entry.path, "dispose");
|
|
407
|
+
}
|
|
408
|
+
this.entries.clear();
|
|
409
|
+
}
|
|
410
|
+
mint(entry) {
|
|
411
|
+
const token = randomUUID();
|
|
412
|
+
const owned = {
|
|
413
|
+
...entry,
|
|
414
|
+
expiryTimer: setTimeout(() => {
|
|
415
|
+
if (this.entries.get(token) !== owned) return;
|
|
416
|
+
this.entries.delete(token);
|
|
417
|
+
removeTempDirectory(owned.path, "expired");
|
|
418
|
+
}, BACKUP_TOKEN_TTL_MS)
|
|
419
|
+
};
|
|
420
|
+
this.entries.set(token, owned);
|
|
421
|
+
return token;
|
|
422
|
+
}
|
|
423
|
+
peek(token) {
|
|
424
|
+
const entry = this.entries.get(token);
|
|
425
|
+
if (entry === void 0) return void 0;
|
|
426
|
+
if (entry.expiresAt > Date.now()) return entry;
|
|
427
|
+
this.expire(token, entry);
|
|
428
|
+
}
|
|
429
|
+
take(token) {
|
|
430
|
+
const entry = this.entries.get(token);
|
|
431
|
+
if (entry === void 0) return void 0;
|
|
432
|
+
clearTimeout(entry.expiryTimer);
|
|
433
|
+
this.entries.delete(token);
|
|
434
|
+
return entry;
|
|
435
|
+
}
|
|
436
|
+
expire(token, entry) {
|
|
437
|
+
clearTimeout(entry.expiryTimer);
|
|
438
|
+
this.entries.delete(token);
|
|
439
|
+
removeTempDirectory(entry.path, "expired");
|
|
440
|
+
}
|
|
441
|
+
sweep() {
|
|
442
|
+
const now = Date.now();
|
|
443
|
+
for (const [token, entry] of this.entries) {
|
|
444
|
+
if (entry.expiresAt > now) continue;
|
|
445
|
+
this.expire(token, entry);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
/** Log temp cleanup failure with the original stack while withholding the path. */
|
|
450
|
+
async function removeTempDirectory(path, reason) {
|
|
451
|
+
try {
|
|
452
|
+
await rm(dirname(path), {
|
|
453
|
+
recursive: true,
|
|
454
|
+
force: true
|
|
455
|
+
});
|
|
456
|
+
} catch (error) {
|
|
457
|
+
console.error("[plus-backup] temp cleanup failed", { reason }, error);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** Reject an unauthenticated route before reading any request body or query data. */
|
|
461
|
+
function registerProtectedRoute(ctx, path, handler) {
|
|
462
|
+
const route = {
|
|
463
|
+
kind: "exact",
|
|
464
|
+
path,
|
|
465
|
+
handler: async (request, response) => {
|
|
466
|
+
const rejection = ctx.connection.requestRejection(request);
|
|
467
|
+
if (rejection !== void 0) {
|
|
468
|
+
response.writeHead(rejection);
|
|
469
|
+
response.end(rejection === 401 ? "unauthorized" : "forbidden");
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
await handler(request, response);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
ctx.effect(() => ctx.webServer.register(route), `plus-backup: ${path} route`);
|
|
476
|
+
}
|
|
477
|
+
function requireMethod(request, response, methods) {
|
|
478
|
+
if (request.method !== void 0 && methods.includes(request.method)) return true;
|
|
479
|
+
response.writeHead(405, { allow: methods.join(", ") });
|
|
480
|
+
response.end("method not allowed");
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
function tokenFromRequest(request) {
|
|
484
|
+
const token = new URL(request.url ?? "", "http://localhost").searchParams.get("token");
|
|
485
|
+
return token === null || token === "" ? void 0 : token;
|
|
486
|
+
}
|
|
487
|
+
async function writeProgressLine(response, line) {
|
|
488
|
+
if (response.destroyed) throw new Error("backup progress response closed");
|
|
489
|
+
if (!response.write(JSON.stringify(line) + String.fromCharCode(10))) await once(response, "drain");
|
|
490
|
+
}
|
|
491
|
+
function beginProgressResponse(response) {
|
|
492
|
+
const controller = new AbortController();
|
|
493
|
+
response.once("close", () => {
|
|
494
|
+
if (!response.writableEnded) controller.abort();
|
|
495
|
+
});
|
|
496
|
+
response.writeHead(200, {
|
|
497
|
+
"content-type": "application/x-ndjson; charset=utf-8",
|
|
498
|
+
"cache-control": "no-store",
|
|
499
|
+
"x-content-type-options": "nosniff"
|
|
500
|
+
});
|
|
501
|
+
response.flushHeaders();
|
|
502
|
+
return controller.signal;
|
|
503
|
+
}
|
|
504
|
+
function publicOperationError(error, operation) {
|
|
505
|
+
if (error instanceof Error && (error.message.includes("missing backup-manifest.json") || error.message.includes("unsafe path"))) return error.message;
|
|
506
|
+
return "backup " + operation + " failed";
|
|
507
|
+
}
|
|
508
|
+
/** Stream one upload to disk; both declared and observed bytes share this ingress limit owner. */
|
|
509
|
+
async function stageUpload(request, response, tokens, maxUploadBytes) {
|
|
510
|
+
if (!requireMethod(request, response, ["POST"])) return;
|
|
511
|
+
const declared = Number(request.headers["content-length"] ?? "0");
|
|
512
|
+
if (Number.isFinite(declared) && declared > maxUploadBytes) {
|
|
513
|
+
response.writeHead(413);
|
|
514
|
+
response.end("backup upload too large");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const path = join(await mkdtemp(join(tmpdir(), "dsh-backup-upload-")), "upload.zip");
|
|
518
|
+
let written = 0;
|
|
519
|
+
const limiter = new Transform({ transform(chunk, _encoding, callback) {
|
|
520
|
+
written += chunk.length;
|
|
521
|
+
callback(written > maxUploadBytes ? new BackupUploadTooLargeError() : null, chunk);
|
|
522
|
+
} });
|
|
523
|
+
try {
|
|
524
|
+
await pipeline(request, limiter, createWriteStream(path));
|
|
525
|
+
if (response.destroyed) {
|
|
526
|
+
await removeTempDirectory(path, "upload response closed");
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
530
|
+
response.end(JSON.stringify({ token: tokens.mintUpload(path) }));
|
|
531
|
+
} catch (error) {
|
|
532
|
+
await removeTempDirectory(path, "upload failed");
|
|
533
|
+
if (request.destroyed || response.destroyed) return;
|
|
534
|
+
console.error("[plus-backup] upload failed", error);
|
|
535
|
+
response.writeHead(error instanceof BackupUploadTooLargeError ? 413 : 500);
|
|
536
|
+
response.end(error instanceof BackupUploadTooLargeError ? "backup upload too large" : "backup upload failed");
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async function prepareExport(request, response, tokens, dshHome) {
|
|
540
|
+
if (!requireMethod(request, response, ["POST"])) return;
|
|
541
|
+
const signal = beginProgressResponse(response);
|
|
542
|
+
const path = join(await mkdtemp(join(tmpdir(), "dsh-backup-export-")), "backup.zip");
|
|
543
|
+
let tokenMinted = false;
|
|
544
|
+
try {
|
|
545
|
+
const { entries } = await writeUserBackup(dshHome, path, signal, (progress) => writeProgressLine(response, {
|
|
546
|
+
type: "progress",
|
|
547
|
+
progress
|
|
548
|
+
}));
|
|
549
|
+
const { size } = await stat(path);
|
|
550
|
+
const token = tokens.mintDownload(path, size);
|
|
551
|
+
tokenMinted = true;
|
|
552
|
+
await writeProgressLine(response, {
|
|
553
|
+
type: "export-ready",
|
|
554
|
+
downloadUrl: "/api/backup.export?token=" + token,
|
|
555
|
+
entries
|
|
556
|
+
});
|
|
557
|
+
response.end();
|
|
558
|
+
} catch (error) {
|
|
559
|
+
if (!tokenMinted) await removeTempDirectory(path, "export failed");
|
|
560
|
+
if (signal.aborted || response.destroyed) return;
|
|
561
|
+
console.error("[plus-backup] export failed", error);
|
|
562
|
+
await writeProgressLine(response, {
|
|
563
|
+
type: "error",
|
|
564
|
+
message: publicOperationError(error, "export")
|
|
565
|
+
});
|
|
566
|
+
response.end();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
async function downloadExport(request, response, tokens) {
|
|
570
|
+
if (!requireMethod(request, response, ["GET", "HEAD"])) return;
|
|
571
|
+
const token = tokenFromRequest(request);
|
|
572
|
+
if (token === void 0) {
|
|
573
|
+
response.writeHead(400);
|
|
574
|
+
response.end("missing backup token");
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
const entry = request.method === "HEAD" ? tokens.peekDownload(token) : tokens.takeDownload(token);
|
|
578
|
+
if (entry === void 0) {
|
|
579
|
+
response.writeHead(404);
|
|
580
|
+
response.end("unknown or expired backup token");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
response.writeHead(200, {
|
|
584
|
+
"content-type": "application/zip",
|
|
585
|
+
"content-disposition": "attachment; filename=\"deepseek-harness-backup.zip\"",
|
|
586
|
+
"content-length": String(entry.size)
|
|
587
|
+
});
|
|
588
|
+
if (request.method === "HEAD") {
|
|
589
|
+
response.end();
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
await pipeline(createReadStream(entry.path), response);
|
|
594
|
+
} catch (error) {
|
|
595
|
+
console.error("[plus-backup] download failed", error);
|
|
596
|
+
if (!response.destroyed) response.destroy(error instanceof Error ? error : new Error(String(error)));
|
|
597
|
+
} finally {
|
|
598
|
+
await removeTempDirectory(entry.path, "download settled");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** Validate before mutation, then complete restore even when the browser closes mid-write. */
|
|
602
|
+
async function importBackup(ctx, request, response, tokens, dshHome, maxExpandedBytes) {
|
|
603
|
+
if (!requireMethod(request, response, ["POST"])) return;
|
|
604
|
+
const token = tokenFromRequest(request);
|
|
605
|
+
if (token === void 0) {
|
|
606
|
+
response.writeHead(400);
|
|
607
|
+
response.end("missing backup upload token");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const signal = beginProgressResponse(response);
|
|
611
|
+
const staged = tokens.takeUpload(token);
|
|
612
|
+
if (staged === void 0) {
|
|
613
|
+
await writeProgressLine(response, {
|
|
614
|
+
type: "error",
|
|
615
|
+
message: "unknown or expired backup upload token"
|
|
616
|
+
});
|
|
617
|
+
response.end();
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const lifecycle = { restoreStarted: false };
|
|
621
|
+
const report = async (progress) => {
|
|
622
|
+
if (progress.phase === "restore") lifecycle.restoreStarted = true;
|
|
623
|
+
if (lifecycle.restoreStarted && response.destroyed) return;
|
|
624
|
+
try {
|
|
625
|
+
await writeProgressLine(response, {
|
|
626
|
+
type: "progress",
|
|
627
|
+
progress
|
|
628
|
+
});
|
|
629
|
+
} catch (error) {
|
|
630
|
+
if (!lifecycle.restoreStarted) throw error;
|
|
631
|
+
console.error("[plus-backup] restore progress response failed", error);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
try {
|
|
635
|
+
signal.throwIfAborted();
|
|
636
|
+
await report({ phase: "validate" });
|
|
637
|
+
const validated = await validateUserBackup(staged.path, join(dirname(staged.path), "validated"), maxExpandedBytes, signal);
|
|
638
|
+
signal.throwIfAborted();
|
|
639
|
+
const { entries } = await ctx.workspaceRegistry.withStorageRestore(async () => {
|
|
640
|
+
const restored = await restoreUserBackup(validated, dshHome, report);
|
|
641
|
+
await report({ phase: "reload" });
|
|
642
|
+
return restored;
|
|
643
|
+
});
|
|
644
|
+
if (response.destroyed) return;
|
|
645
|
+
await writeProgressLine(response, {
|
|
646
|
+
type: "import-complete",
|
|
647
|
+
entries
|
|
648
|
+
});
|
|
649
|
+
response.end();
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (!lifecycle.restoreStarted && (signal.aborted || response.destroyed)) return;
|
|
652
|
+
console.error("[plus-backup] import failed", error);
|
|
653
|
+
if (response.destroyed) return;
|
|
654
|
+
await writeProgressLine(response, {
|
|
655
|
+
type: "error",
|
|
656
|
+
message: publicOperationError(error, "import")
|
|
657
|
+
});
|
|
658
|
+
response.end();
|
|
659
|
+
} finally {
|
|
660
|
+
await removeTempDirectory(staged.path, "import settled");
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Register the complete authenticated Backup route set and its temp-file lifecycle.
|
|
665
|
+
* @param ctx - Host services and the patched Workspace restore operation.
|
|
666
|
+
* @param config - Upload resource policy.
|
|
667
|
+
* @param dshHome - File-backed DSH home whose user data is archived.
|
|
668
|
+
*/
|
|
669
|
+
function registerBackupRoutes(ctx, config, dshHome) {
|
|
670
|
+
const tokens = new BackupTokenStore();
|
|
671
|
+
ctx.effect(() => () => tokens.dispose(), "plus-backup: temp files");
|
|
672
|
+
registerProtectedRoute(ctx, "/api/backup.upload", (request, response) => stageUpload(request, response, tokens, config.maxUploadBytes));
|
|
673
|
+
registerProtectedRoute(ctx, "/api/backup.export.prepare", (request, response) => prepareExport(request, response, tokens, dshHome));
|
|
674
|
+
registerProtectedRoute(ctx, "/api/backup.export", (request, response) => downloadExport(request, response, tokens));
|
|
675
|
+
registerProtectedRoute(ctx, "/api/backup.import", (request, response) => importBackup(ctx, request, response, tokens, dshHome, config.maxUploadBytes));
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region lib/types/index.js
|
|
679
|
+
/** Full-stack Plus user-data Backup Host plugin. */
|
|
680
|
+
const name = "plus-backup";
|
|
681
|
+
const inject = [
|
|
682
|
+
"connection",
|
|
683
|
+
"webServer",
|
|
684
|
+
"settings",
|
|
685
|
+
"workspaceRegistry"
|
|
686
|
+
];
|
|
687
|
+
const DEFAULT_MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
|
|
688
|
+
/** Validate Backup Host resource policy. */
|
|
689
|
+
const Config = Schema.object({ maxUploadBytes: Schema.number().step(1).min(1).default(DEFAULT_MAX_UPLOAD_BYTES) });
|
|
690
|
+
/**
|
|
691
|
+
* Register authenticated archive routes against a file-backed DSH home.
|
|
692
|
+
* @param ctx - Host services plus the temporary patched Workspace restore operation.
|
|
693
|
+
* @param config - Upload resource policy resolved by Cordis.
|
|
694
|
+
*/
|
|
695
|
+
function apply(ctx, config = {}) {
|
|
696
|
+
const documentPath = ctx.settings.documentPath;
|
|
697
|
+
if (documentPath === void 0) throw new Error("plus-backup requires a file-backed settings provider");
|
|
698
|
+
registerBackupRoutes(ctx, { maxUploadBytes: config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES }, dirname(documentPath));
|
|
699
|
+
}
|
|
700
|
+
//#endregion
|
|
701
|
+
export { Config, apply, inject, name };
|