@3sln/trove 0.0.8 → 0.0.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/package.json +1 -1
- package/packages/core/src/encryption/envelope.js +46 -7
- package/packages/core/src/encryption/rotation.js +187 -68
- package/packages/core/src/uploads.js +130 -29
- package/packages/core/src/vfs.js +91 -5
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +12 -0
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +59 -26
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1454 -137
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +77 -141
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +31 -102
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +74 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +158 -45
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-y778bpte.js +0 -356
- package/packages/web/dist/assets/main-y778bpte.js.map +0 -106
- package/packages/web/dist/assets/styles-nfy8t3n1.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@3sln/trove",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.10",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
|
|
6
6
|
"repository": {
|
|
@@ -375,22 +375,48 @@ export async function decryptStream(rawKey, header, cipherStream, firstChunk = 0
|
|
|
375
375
|
* @param {ReadableStream<Uint8Array>} plaintext
|
|
376
376
|
* @param {{fingerprint: Uint8Array, plaintextSize: number, chunkSize?: number}} opts
|
|
377
377
|
*/
|
|
378
|
-
export async function encryptStream(rawKey, plaintext, { fingerprint, plaintextSize, chunkSize = DEFAULT_CHUNK_SIZE } = {}) {
|
|
378
|
+
export async function encryptStream(rawKey, plaintext, { fingerprint, plaintextSize, chunkSize = DEFAULT_CHUNK_SIZE, resume = null, partial = false } = {}) {
|
|
379
379
|
assertChunkSize(chunkSize);
|
|
380
380
|
if (!fingerprint || fingerprint.length !== FINGERPRINT_BYTES) {
|
|
381
381
|
throw TroveError.invalid(`A fingerprint must be ${FINGERPRINT_BYTES} bytes`);
|
|
382
382
|
}
|
|
383
383
|
const key = await importKey(rawKey);
|
|
384
|
-
|
|
384
|
+
// Resuming continues ONE envelope across calls: same nonce prefix, next chunk index, no
|
|
385
|
+
// second header. It exists so a rotation can be interrupted part-way through a large
|
|
386
|
+
// object and carry on rather than start again — see encryption/rotation.js.
|
|
387
|
+
//
|
|
388
|
+
// The nonce is prefix + index, so continuing the sequence is what keeps every nonce used
|
|
389
|
+
// exactly once under this key. Restarting the index (or minting a fresh prefix and
|
|
390
|
+
// stitching the output together) would reuse nonces, and AES-GCM does not survive that:
|
|
391
|
+
// two chunks encrypted with the same key and nonce leak their XOR and forge each other's
|
|
392
|
+
// tags. That is the whole reason this takes the prefix rather than generating one.
|
|
393
|
+
if (resume && (!(resume.noncePrefix instanceof Uint8Array) || resume.noncePrefix.length !== NONCE_PREFIX_BYTES)) {
|
|
394
|
+
throw TroveError.invalid(`Resuming needs the original ${NONCE_PREFIX_BYTES}-byte nonce prefix`);
|
|
395
|
+
}
|
|
396
|
+
if (resume && !Number.isInteger(resume.index)) {
|
|
397
|
+
throw TroveError.invalid('Resuming needs the chunk index to continue from');
|
|
398
|
+
}
|
|
399
|
+
const noncePrefix = resume ? resume.noncePrefix : crypto.getRandomValues(new Uint8Array(NONCE_PREFIX_BYTES));
|
|
385
400
|
const header = encodeHeader({
|
|
386
401
|
version: VERSION, algorithm: ALG_AES_256_GCM, chunkSize, plaintextSize, noncePrefix, fingerprint,
|
|
387
402
|
});
|
|
388
403
|
|
|
389
404
|
const reader = plaintext.getReader();
|
|
390
405
|
let held = new Uint8Array(0);
|
|
391
|
-
let index = 0;
|
|
392
|
-
|
|
406
|
+
let index = resume ? resume.index : 0;
|
|
407
|
+
// A resumed stream is the MIDDLE of an object; the header went out with the first slice.
|
|
408
|
+
let wroteHeader = !!resume;
|
|
393
409
|
let seen = 0;
|
|
410
|
+
// What THIS call is expected to consume. The header still names the whole object — it
|
|
411
|
+
// describes the object, not the call — so the guard below has to compare against the
|
|
412
|
+
// remainder or a legitimate resume looks like a truncated encrypt.
|
|
413
|
+
const expected = resume ? plaintextSize - resume.index * chunkSize : plaintextSize;
|
|
414
|
+
if (expected < 0) throw TroveError.invalid('Resuming past the end of the object');
|
|
415
|
+
// `partial` says this call does NOT finish the object — another will continue it. Such a
|
|
416
|
+
// call must be fed a whole number of chunks, because a short chunk is how the envelope
|
|
417
|
+
// marks the end: seal a partial one mid-object and every later chunk index is wrong and
|
|
418
|
+
// the plaintext length is a lie. The completeness check is skipped for the same reason —
|
|
419
|
+
// stopping early is the point, not a truncated encrypt.
|
|
394
420
|
|
|
395
421
|
const seal = async (piece) => new Uint8Array(await crypto.subtle.encrypt(
|
|
396
422
|
{ name: 'AES-GCM', iv: nonceFor(noncePrefix, index++) }, key, piece,
|
|
@@ -415,17 +441,30 @@ export async function encryptStream(rawKey, plaintext, { fingerprint, plaintextS
|
|
|
415
441
|
if (done) {
|
|
416
442
|
// The trailing partial chunk — and, for an empty file, the one empty chunk that
|
|
417
443
|
// makes "is this encrypted" answerable the same way at any size.
|
|
418
|
-
|
|
444
|
+
// The trailing partial chunk — and, for an empty file, the one empty chunk that
|
|
445
|
+
// makes "is this encrypted" answerable the same way at any size. A RESUMED stream
|
|
446
|
+
// never wants that empty chunk: the object already has its chunks, and adding one
|
|
447
|
+
// would append a stray sealed block past the end.
|
|
448
|
+
if (partial) {
|
|
449
|
+
if (held.length) {
|
|
450
|
+
throw TroveError.invalid(
|
|
451
|
+
`A partial encrypt must stop on a chunk boundary; ${held.length} bytes left over`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
controller.close();
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (held.length || (seen === 0 && !resume)) {
|
|
419
458
|
seen += held.length;
|
|
420
459
|
controller.enqueue(await seal(held));
|
|
421
460
|
held = new Uint8Array(0);
|
|
422
461
|
}
|
|
423
|
-
if (seen !==
|
|
462
|
+
if (seen !== expected) {
|
|
424
463
|
// Refused rather than written: an envelope whose header disagrees with its body
|
|
425
464
|
// decrypts to the wrong length forever, and the header cannot be fixed later
|
|
426
465
|
// without re-encrypting.
|
|
427
466
|
throw TroveError.invalid(
|
|
428
|
-
`Expected ${
|
|
467
|
+
`Expected ${expected} bytes to encrypt and received ${seen}`,
|
|
429
468
|
);
|
|
430
469
|
}
|
|
431
470
|
controller.close();
|
|
@@ -53,6 +53,20 @@ const PART_TARGET_BYTES = 8 * 1024 * 1024;
|
|
|
53
53
|
* @property {'running'|'done'} status
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The storage key a rotation writes to.
|
|
58
|
+
*
|
|
59
|
+
* Based on the key with any previous rotation suffix STRIPPED. Appending to
|
|
60
|
+
* `node.storageKey` instead compounds: a collection rotated twice held
|
|
61
|
+
* `obj_x.rotms9gq8j2.rotms9grnhf`, and a key grew by ~11 characters on every rotation for
|
|
62
|
+
* the life of the drive. S3 stops accepting a key at 1024 bytes, so a drive rotated on a
|
|
63
|
+
* schedule would eventually fail to rotate at all — years out, silently, and only for the
|
|
64
|
+
* objects with the longest names.
|
|
65
|
+
*/
|
|
66
|
+
export function rotatedKey(storageKey) {
|
|
67
|
+
return `${storageKey.replace(/(\.rot[0-9a-z]+)+$/, '')}.rot${Date.now().toString(36)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
export class RotationService {
|
|
57
71
|
/**
|
|
58
72
|
* @param {object} deps
|
|
@@ -84,6 +98,9 @@ export class RotationService {
|
|
|
84
98
|
if (running && running.status === 'running') {
|
|
85
99
|
throw TroveError.invalid('A key rotation is already running on this collection');
|
|
86
100
|
}
|
|
101
|
+
// A finished-but-abandoned rotation can still be holding an open multipart. Starting a
|
|
102
|
+
// new one is the last moment anything knows it exists.
|
|
103
|
+
if (running?.inflight) await this.#discard(collectionId, running.inflight);
|
|
87
104
|
const { fingerprint: to, previous } = await this.collections.beginRotation(collectionId, principal);
|
|
88
105
|
const state = {
|
|
89
106
|
collectionId,
|
|
@@ -95,6 +112,9 @@ export class RotationService {
|
|
|
95
112
|
.map((k) => k.fingerprint)
|
|
96
113
|
.filter((fp) => fp !== to),
|
|
97
114
|
cursor: null,
|
|
115
|
+
// The object part-way through, when a slice ran out of budget inside one. See
|
|
116
|
+
// #moveSlice — this is what lets an object larger than a slice be rotated at all.
|
|
117
|
+
inflight: null,
|
|
98
118
|
moved: 0,
|
|
99
119
|
failed: 0,
|
|
100
120
|
startedAt: Date.now(),
|
|
@@ -145,6 +165,36 @@ export class RotationService {
|
|
|
145
165
|
let moved = state.moved;
|
|
146
166
|
let failed = state.failed;
|
|
147
167
|
let sawStragglers = false;
|
|
168
|
+
let inflight = state.inflight || null;
|
|
169
|
+
|
|
170
|
+
// Finish what the last slice started before looking for more. An object part-way
|
|
171
|
+
// through is the one thing that MUST be dealt with first: its multipart is open and
|
|
172
|
+
// billed, and the walk would otherwise pick it up again from the beginning.
|
|
173
|
+
if (inflight) {
|
|
174
|
+
const node = await this.vfs.metadata.getById(inflight.nodeId).catch(() => null);
|
|
175
|
+
// Deleted, or already moved by something else, while we were away. Spend the upload
|
|
176
|
+
// rather than leaving it open forever.
|
|
177
|
+
if (!node || !node.encryption || node.encryption.fingerprint === state.to) {
|
|
178
|
+
await this.#discard(collectionId, inflight);
|
|
179
|
+
inflight = null;
|
|
180
|
+
} else {
|
|
181
|
+
sawStragglers = true;
|
|
182
|
+
try {
|
|
183
|
+
const r = await this.#moveSlice(node, key, fp, inflight, { deadline, now });
|
|
184
|
+
inflight = r.inflight;
|
|
185
|
+
if (r.done) moved++;
|
|
186
|
+
} catch (err) {
|
|
187
|
+
failed++;
|
|
188
|
+
await this.#discard(collectionId, inflight);
|
|
189
|
+
inflight = null;
|
|
190
|
+
console.error(`[trove] resuming ${node.name} failed:`, err?.message || err);
|
|
191
|
+
}
|
|
192
|
+
// Still not finished, or out of time: persist and let the next slice continue.
|
|
193
|
+
if (inflight || now() >= deadline) {
|
|
194
|
+
return this.#save(collectionId, { ...state, cursor, moved, failed, inflight });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
148
198
|
|
|
149
199
|
for (;;) {
|
|
150
200
|
const page = await this.vfs.metadata.listItems(collectionId, { cursor, limit: 50 });
|
|
@@ -155,8 +205,15 @@ export class RotationService {
|
|
|
155
205
|
if (!node.encryption || node.encryption.fingerprint === state.to) continue;
|
|
156
206
|
sawStragglers = true;
|
|
157
207
|
try {
|
|
158
|
-
await this.#
|
|
159
|
-
moved++;
|
|
208
|
+
const r = await this.#moveSlice(node, key, fp, null, { deadline, now });
|
|
209
|
+
if (r.done) moved++;
|
|
210
|
+
else {
|
|
211
|
+
// Bigger than the remaining budget. Its parts and its place in the envelope are
|
|
212
|
+
// recorded, and the next slice picks it up where this one stopped instead of
|
|
213
|
+
// starting the object again — which is what used to make a large object
|
|
214
|
+
// unrotatable at all rather than merely slow.
|
|
215
|
+
return this.#save(collectionId, { ...state, cursor, moved, failed, inflight: r.inflight });
|
|
216
|
+
}
|
|
160
217
|
} catch (err) {
|
|
161
218
|
// One unreadable object must not stop the rotation: the rest of the collection
|
|
162
219
|
// still needs to move, and the old key cannot be retired while anything is left
|
|
@@ -173,6 +230,7 @@ export class RotationService {
|
|
|
173
230
|
const finished = !cursor && !sawStragglers;
|
|
174
231
|
const next = {
|
|
175
232
|
...state,
|
|
233
|
+
inflight: null,
|
|
176
234
|
// A finished pass starts the next one from the beginning, because items added during
|
|
177
235
|
// it may still be behind. Two clean passes in a row is what actually ends the job.
|
|
178
236
|
cursor: finished ? null : cursor,
|
|
@@ -199,45 +257,103 @@ export class RotationService {
|
|
|
199
257
|
}
|
|
200
258
|
|
|
201
259
|
/**
|
|
202
|
-
*
|
|
260
|
+
* Move one object onto the new key, in as many slices as it takes.
|
|
261
|
+
*
|
|
262
|
+
* A rotation slice is bounded by wall-clock time, and until now an object could only be
|
|
263
|
+
* moved whole: one larger than the budget failed its slice, restarted from the beginning
|
|
264
|
+
* on the next, and failed again — forever, with the rotation never completing and the old
|
|
265
|
+
* key never retiring. A collection containing one big video could not be rotated at all.
|
|
203
266
|
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
267
|
+
* The checkpoint is the multipart part list, which the store is already keeping for us.
|
|
268
|
+
* What has to travel with it is the ENVELOPE's position: a nonce is derived from the
|
|
269
|
+
* chunk index, so a resumed encryption must continue the same sequence under the same
|
|
270
|
+
* prefix. Starting a fresh envelope halfway would reuse nonce/key pairs from the first
|
|
271
|
+
* half — the one thing AES-GCM cannot survive — so the prefix and the next chunk index
|
|
272
|
+
* are persisted alongside the parts.
|
|
273
|
+
*
|
|
274
|
+
* @returns {Promise<{done: boolean, inflight: object|null}>}
|
|
208
275
|
*/
|
|
209
|
-
async #
|
|
276
|
+
async #moveSlice(node, newKey, newFingerprint, inflight, { deadline, now }) {
|
|
210
277
|
const storage = await this.vfs.storageFor(node.collectionId);
|
|
211
278
|
const chunkSize = node.encryption.chunkSize;
|
|
279
|
+
const plaintextSize = node.size;
|
|
280
|
+
|
|
281
|
+
// A store without multipart is a local one — filesystem, memory — where the whole
|
|
282
|
+
// object is already within reach. There is nothing to check point against, and nothing
|
|
283
|
+
// that needs it: the budget that makes this necessary is a network one.
|
|
284
|
+
if (!storage.capabilities?.multipart) {
|
|
285
|
+
await this.#moveWhole(node, newKey, newFingerprint, storage, chunkSize);
|
|
286
|
+
return { done: true, inflight: null };
|
|
287
|
+
}
|
|
288
|
+
|
|
212
289
|
// Written to a NEW storage key rather than over the old one. AES-GCM cannot survive a
|
|
213
290
|
// nonce being reused with a key, and rewriting in place invites exactly that on a
|
|
214
291
|
// retry; a new object also means a failure halfway leaves the original intact and
|
|
215
292
|
// readable rather than a half-written file that is neither.
|
|
216
|
-
const nextKey =
|
|
293
|
+
const nextKey = inflight?.storageKey ?? rotatedKey(node.storageKey);
|
|
294
|
+
const uploadId = inflight?.uploadId ?? await storage.createMultipart(nextKey, { contentType: node.contentType });
|
|
295
|
+
const parts = inflight?.parts ? [...inflight.parts] : [];
|
|
296
|
+
let chunkIndex = inflight?.chunkIndex ?? 0;
|
|
297
|
+
let noncePrefix = inflight?.noncePrefix ? fromHex(inflight.noncePrefix) : null;
|
|
217
298
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
chunkSize,
|
|
223
|
-
});
|
|
299
|
+
// One part per pass, so the checkpoint lands on a part boundary — which is also a chunk
|
|
300
|
+
// boundary, since a part is a whole number of sealed chunks. Sized to clear S3's 5 MiB
|
|
301
|
+
// floor for every part but the last.
|
|
302
|
+
const chunksPerPart = Math.max(1, Math.ceil(PART_TARGET_BYTES / chunkSize));
|
|
224
303
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
304
|
+
try {
|
|
305
|
+
while (chunkIndex * chunkSize < plaintextSize) {
|
|
306
|
+
const start = chunkIndex * chunkSize;
|
|
307
|
+
const end = Math.min(start + chunksPerPart * chunkSize, plaintextSize) - 1;
|
|
308
|
+
const last = end + 1 >= plaintextSize;
|
|
309
|
+
|
|
310
|
+
const read = await this.vfs.readStream(node.id, { range: { start, end } });
|
|
311
|
+
const sealed = await encryptStream(newKey, read.stream, {
|
|
312
|
+
fingerprint: newFingerprint,
|
|
313
|
+
plaintextSize,
|
|
314
|
+
chunkSize,
|
|
315
|
+
// The first pass mints the prefix and writes the header; every later one continues
|
|
316
|
+
// the same envelope, which is what `resume` means.
|
|
317
|
+
resume: noncePrefix ? { noncePrefix, index: chunkIndex } : null,
|
|
318
|
+
// `partial` says another call will continue this object. Without it a mid-object
|
|
319
|
+
// pass would be sealed as a complete envelope and every later chunk index would be
|
|
320
|
+
// wrong.
|
|
321
|
+
partial: !last,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const body = new Uint8Array(await new Response(sealed).arrayBuffer());
|
|
325
|
+
// The prefix the first pass generated, read back out of the header it wrote. It is
|
|
326
|
+
// the only place it exists, and every later pass needs it.
|
|
327
|
+
if (!noncePrefix) noncePrefix = decodeHeader(body).noncePrefix;
|
|
328
|
+
|
|
329
|
+
const partNumber = parts.length + 1;
|
|
330
|
+
const etag = await storage.putPart(nextKey, uploadId, partNumber, body);
|
|
331
|
+
parts.push({ partNumber, etag: etag?.etag ?? etag });
|
|
332
|
+
chunkIndex += Math.ceil((end + 1 - start) / chunkSize);
|
|
333
|
+
|
|
334
|
+
// Out of time, but on a boundary: hand back what has been done so the next slice
|
|
335
|
+
// continues rather than starting over.
|
|
336
|
+
if (!last && now() >= deadline) {
|
|
337
|
+
return {
|
|
338
|
+
done: false,
|
|
339
|
+
inflight: {
|
|
340
|
+
nodeId: node.id,
|
|
341
|
+
storageKey: nextKey,
|
|
342
|
+
uploadId,
|
|
343
|
+
parts,
|
|
344
|
+
chunkIndex,
|
|
345
|
+
noncePrefix: toHex(noncePrefix),
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
await storage.completeMultipart(nextKey, uploadId, parts);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
// The parts already sent stay in the bucket, billed, with nothing left able to
|
|
354
|
+
// reclaim them — so a failure aborts rather than leaving them.
|
|
355
|
+
await storage.abortMultipart(nextKey, uploadId).catch(() => {});
|
|
356
|
+
throw err;
|
|
241
357
|
}
|
|
242
358
|
|
|
243
359
|
// The item points at the new object before the old one is removed. In the window
|
|
@@ -249,57 +365,60 @@ export class RotationService {
|
|
|
249
365
|
encryption: { fingerprint: toHex(newFingerprint), chunkSize },
|
|
250
366
|
});
|
|
251
367
|
await storage.delete(oldKey).catch(() => {});
|
|
368
|
+
return { done: true, inflight: null };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The whole object in one put, for a store that cannot do multipart. */
|
|
372
|
+
async #moveWhole(node, newKey, newFingerprint, storage, chunkSize) {
|
|
373
|
+
const nextKey = rotatedKey(node.storageKey);
|
|
374
|
+
const read = await this.vfs.readStream(node.id);
|
|
375
|
+
const sealed = await encryptStream(newKey, read.stream, {
|
|
376
|
+
fingerprint: newFingerprint,
|
|
377
|
+
plaintextSize: read.size ?? node.size,
|
|
378
|
+
chunkSize,
|
|
379
|
+
});
|
|
380
|
+
await storage.put(nextKey, new Uint8Array(await new Response(sealed).arrayBuffer()), {
|
|
381
|
+
contentType: node.contentType,
|
|
382
|
+
});
|
|
383
|
+
const oldKey = node.storageKey;
|
|
384
|
+
await this.vfs.metadata.update(node.id, {
|
|
385
|
+
storageKey: nextKey,
|
|
386
|
+
encryption: { fingerprint: toHex(newFingerprint), chunkSize },
|
|
387
|
+
});
|
|
388
|
+
await storage.delete(oldKey).catch(() => {});
|
|
252
389
|
}
|
|
253
390
|
|
|
254
391
|
/**
|
|
255
|
-
*
|
|
392
|
+
* Abandon a half-written object.
|
|
256
393
|
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* A failure aborts the multipart. Without that the parts already sent stay in the bucket,
|
|
262
|
-
* billed, with nothing left able to reclaim them.
|
|
394
|
+
* A multipart left open is billed until it is completed or aborted, and nothing else can
|
|
395
|
+
* find it: the storage contract has no way to list them, deliberately, so the only record
|
|
396
|
+
* that it exists is the one we wrote. Cancelling or restarting a rotation has to spend it.
|
|
263
397
|
*/
|
|
264
|
-
async #
|
|
265
|
-
|
|
398
|
+
async #discard(collectionId, inflight) {
|
|
399
|
+
if (!inflight?.uploadId) return;
|
|
266
400
|
try {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
let held = [];
|
|
270
|
-
let size = 0;
|
|
271
|
-
let n = 1;
|
|
272
|
-
const flush = async () => {
|
|
273
|
-
const part = new Uint8Array(size);
|
|
274
|
-
let at = 0;
|
|
275
|
-
for (const p of held) { part.set(p, at); at += p.length; }
|
|
276
|
-
held = [];
|
|
277
|
-
size = 0;
|
|
278
|
-
const etag = await storage.putPart(key, uploadId, n, part);
|
|
279
|
-
parts.push({ partNumber: n, etag: etag?.etag ?? etag });
|
|
280
|
-
n++;
|
|
281
|
-
};
|
|
282
|
-
for (;;) {
|
|
283
|
-
const { value, done } = await reader.read();
|
|
284
|
-
if (done) break;
|
|
285
|
-
held.push(value);
|
|
286
|
-
size += value.length;
|
|
287
|
-
if (size >= PART_TARGET_BYTES) await flush();
|
|
288
|
-
}
|
|
289
|
-
// The final part may be under the floor, and only the final part may be.
|
|
290
|
-
if (size || parts.length === 0) await flush();
|
|
291
|
-
await storage.completeMultipart(key, uploadId, parts);
|
|
401
|
+
const storage = await this.vfs.storageFor(collectionId);
|
|
402
|
+
await storage.abortMultipart(inflight.storageKey, inflight.uploadId);
|
|
292
403
|
} catch (err) {
|
|
293
|
-
|
|
294
|
-
throw err;
|
|
404
|
+
console.error(`[trove] abandoning a rotation upload failed:`, err?.message || err);
|
|
295
405
|
}
|
|
296
406
|
}
|
|
297
407
|
|
|
408
|
+
/** Persist progress mid-pass, so the next slice continues from here. */
|
|
409
|
+
async #save(collectionId, next) {
|
|
410
|
+
await this.kv.set(NS, collectionId, next);
|
|
411
|
+
return next;
|
|
412
|
+
}
|
|
413
|
+
|
|
298
414
|
/** Abandon a rotation. The new key stays current; what has moved stays moved. */
|
|
299
415
|
async cancel(collectionId) {
|
|
300
416
|
const state = await this.state(collectionId);
|
|
301
417
|
if (!state) return null;
|
|
302
|
-
|
|
418
|
+
// The half-written object goes with it. Nothing else can find that multipart — the
|
|
419
|
+
// storage contract cannot list them — so if this does not spend it, nothing will.
|
|
420
|
+
await this.#discard(collectionId, state.inflight);
|
|
421
|
+
const next = { ...state, status: 'done', cancelled: true, inflight: null };
|
|
303
422
|
await this.kv.set(NS, collectionId, next);
|
|
304
423
|
return next;
|
|
305
424
|
}
|