@3sln/trove 0.0.7 → 0.0.9
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/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- 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 +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- 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 +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- 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 +141 -145
- 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 +63 -11
- 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 +77 -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 +169 -43
- 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-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
|
|
15
15
|
import { TroveError, ErrorCode } from './errors.js';
|
|
16
16
|
import { newId, isValidItemName } from './util.js';
|
|
17
|
+
import {
|
|
18
|
+
cipherSize, DEFAULT_CHUNK_SIZE, isEnvelope, decodeHeader, encodeHeader, encryptStream,
|
|
19
|
+
HEADER_BYTES as ENVELOPE_HEAD,
|
|
20
|
+
} from './encryption/envelope.js';
|
|
21
|
+
import { fromHex, toHex } from './encryption/keys.js';
|
|
22
|
+
import { shouldEncrypt } from './encryption/policy.js';
|
|
17
23
|
|
|
18
24
|
export const DEFAULT_PART_SIZE = 8 * 1024 * 1024; // 8 MiB
|
|
19
25
|
const MIN_MULTIPART_PART = 5 * 1024 * 1024; // S3 floor (except final part)
|
|
@@ -48,6 +54,92 @@ class MemorySessionStore {
|
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
|
|
57
|
+
/** The namespace upload sessions live under. */
|
|
58
|
+
const SESSION_NS = 'uploads';
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Upload sessions in the KeyValueStore, so they outlive the process that made one.
|
|
62
|
+
*
|
|
63
|
+
* An upload is three or more separate requests — create, the bytes, complete — and the
|
|
64
|
+
* session is the only thing joining them. Held in a `Map`, that works exactly as long as
|
|
65
|
+
* every request happens to reach the same process: an assumption a long-lived server gets
|
|
66
|
+
* away with and a serverless one does not. On Cloudflare Workers an isolate can be
|
|
67
|
+
* discarded the moment a response resolves, and a cold drive fans a burst of requests
|
|
68
|
+
* across several isolates at once, so `create` writes to one Map and `complete` reads an
|
|
69
|
+
* empty one. The user is told their upload session does not exist while its own 24h TTL
|
|
70
|
+
* is nowhere near up — because it never expired, it was simply somewhere else.
|
|
71
|
+
*
|
|
72
|
+
* Retrying does not rescue that and must not: a missing session is `notFound`, correctly
|
|
73
|
+
* classified non-retryable, and one that genuinely expired is never coming back. The fix
|
|
74
|
+
* is for the session to live somewhere every request can see.
|
|
75
|
+
*
|
|
76
|
+
* Values are plain JSON, which is all a session ever was.
|
|
77
|
+
*/
|
|
78
|
+
export class KvSessionStore {
|
|
79
|
+
/** @param {{kv: import('./kv.js').KeyValueStore, ns?: string}} deps */
|
|
80
|
+
constructor({ kv, ns = SESSION_NS } = {}) {
|
|
81
|
+
if (!kv) throw TroveError.invalid('KvSessionStore needs a KeyValueStore');
|
|
82
|
+
this.kv = kv;
|
|
83
|
+
this.ns = ns;
|
|
84
|
+
}
|
|
85
|
+
async get(id) {
|
|
86
|
+
return (await this.kv.get(this.ns, id)) || null;
|
|
87
|
+
}
|
|
88
|
+
async put(session) {
|
|
89
|
+
await this.kv.set(this.ns, session.id, session);
|
|
90
|
+
}
|
|
91
|
+
async delete(id) {
|
|
92
|
+
await this.kv.delete(this.ns, id);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Which sessions have expired — WITHOUT deleting them, for the reason given on
|
|
96
|
+
* MemorySessionStore.expired: the session holds the multipart `uploadId`, and dropping
|
|
97
|
+
* the record strands the uploaded parts in the bucket with nothing left to abort them.
|
|
98
|
+
*/
|
|
99
|
+
async expired(now) {
|
|
100
|
+
const rows = await this.kv.list(this.ns, '');
|
|
101
|
+
return rows
|
|
102
|
+
.map((r) => r.value)
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.filter((s) => now - s.createdAt > SESSION_TTL_MS)
|
|
105
|
+
.map((s) => s.id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Whatever a caller handed us, as a stream.
|
|
111
|
+
*
|
|
112
|
+
* A part arrives as a stream over HTTP and as bytes from a test or an in-process caller;
|
|
113
|
+
* `encryptStream` takes only the former, and the sealing path has to accept both.
|
|
114
|
+
*/
|
|
115
|
+
function asStream(body) {
|
|
116
|
+
if (body && typeof body.getReader === 'function') return body;
|
|
117
|
+
if (body && typeof body.stream === 'function') return body.stream(); // Blob
|
|
118
|
+
const bytes = typeof body === 'string'
|
|
119
|
+
? new TextEncoder().encode(body)
|
|
120
|
+
: (body instanceof Uint8Array ? body : new Uint8Array(body || 0));
|
|
121
|
+
return new ReadableStream({
|
|
122
|
+
start(c) {
|
|
123
|
+
if (bytes.length) c.enqueue(bytes);
|
|
124
|
+
c.close();
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A stream that emits `head`, then everything `rest` produces. */
|
|
130
|
+
function prependBytes(head, rest) {
|
|
131
|
+
const reader = rest.getReader();
|
|
132
|
+
return new ReadableStream({
|
|
133
|
+
start(c) { c.enqueue(head); },
|
|
134
|
+
async pull(c) {
|
|
135
|
+
const { value, done } = await reader.read();
|
|
136
|
+
if (done) c.close();
|
|
137
|
+
else c.enqueue(value);
|
|
138
|
+
},
|
|
139
|
+
cancel(reason) { return reader.cancel(reason); },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
51
143
|
export class UploadManager {
|
|
52
144
|
/**
|
|
53
145
|
* @param {object} deps
|
|
@@ -55,10 +147,14 @@ export class UploadManager {
|
|
|
55
147
|
* @param {object} [deps.sessions] session store (defaults in-memory)
|
|
56
148
|
* @param {number} [deps.partSize]
|
|
57
149
|
*/
|
|
58
|
-
constructor({ storage, storageFor, sessions, partSize = DEFAULT_PART_SIZE, maxBytes = null }) {
|
|
150
|
+
constructor({ storage, storageFor, sessions, encryptionFor, partSize = DEFAULT_PART_SIZE, maxBytes = null }) {
|
|
59
151
|
// Either a single backend, or a resolver keyed by collectionId (collections).
|
|
60
152
|
this.storageFor = storageFor ?? (async () => storage);
|
|
61
153
|
this.sessions = sessions ?? new MemorySessionStore();
|
|
154
|
+
// What a collection encrypts, and the key to do it with:
|
|
155
|
+
// `(collectionId) => { encryption, dataKey } | null`. Absent means nothing is
|
|
156
|
+
// encrypted, which is what every existing deployment is.
|
|
157
|
+
this.encryptionFor = encryptionFor ?? (async () => null);
|
|
62
158
|
this.partSize = partSize;
|
|
63
159
|
this.maxBytes = maxBytes || null; // per-file quota (null = unbounded)
|
|
64
160
|
}
|
|
@@ -85,19 +181,50 @@ export class UploadManager {
|
|
|
85
181
|
async create(req) {
|
|
86
182
|
if (!isValidItemName(req.name)) throw TroveError.invalid(`Invalid file name "${req.name}"`);
|
|
87
183
|
if (!(req.size >= 0)) throw TroveError.invalid('size must be a non-negative number');
|
|
88
|
-
|
|
184
|
+
const collectionId = req.collectionId || 'default';
|
|
185
|
+
const storage = await this.#storage(collectionId);
|
|
186
|
+
const caps = storage.capabilities;
|
|
187
|
+
const storageKey = newId('obj');
|
|
188
|
+
const contentType = req.contentType || 'application/octet-stream';
|
|
189
|
+
|
|
190
|
+
// Does this item get encrypted, and with what?
|
|
191
|
+
//
|
|
192
|
+
// Decided here, once, and recorded on the session — not re-derived at `complete`,
|
|
193
|
+
// because the collection's rules can change between the two and an object half-planned
|
|
194
|
+
// as one thing and finished as another is unreadable either way.
|
|
195
|
+
//
|
|
196
|
+
// Everything downstream negotiates against the STORED size, which is larger: a header
|
|
197
|
+
// plus an authentication tag per chunk. Planning multipart boundaries against the
|
|
198
|
+
// plaintext size is short by exactly that, which is the difference between a final part
|
|
199
|
+
// that exists and one that does not.
|
|
200
|
+
const policy = await this.encryptionFor(collectionId);
|
|
201
|
+
const encrypting = !!policy && shouldEncrypt(policy.encryption, { name: req.name, contentType });
|
|
202
|
+
const chunkSize = policy?.encryption?.chunkSize || DEFAULT_CHUNK_SIZE;
|
|
203
|
+
const storedSize = encrypting ? cipherSize(req.size, chunkSize) : req.size;
|
|
204
|
+
|
|
205
|
+
// The per-file limit is checked against what will be STORED, and checked here rather
|
|
206
|
+
// than against `req.size` at the top of this method.
|
|
207
|
+
//
|
|
208
|
+
// `complete` compares the size read back from the store, which for an encrypted upload
|
|
209
|
+
// is the envelope. Checking the plaintext size at negotiation and the envelope size at
|
|
210
|
+
// completion meant a file just under the limit was accepted, transferred in full, and
|
|
211
|
+
// then DELETED by the too-large branch at the end — the user paying for the whole
|
|
212
|
+
// upload and losing the file. Both ends now measure the same thing.
|
|
213
|
+
if (this.maxBytes && storedSize > this.maxBytes) {
|
|
89
214
|
// Deterministic per-file limit — retrying can't help, so it's non-retryable
|
|
90
215
|
// (capacity/rate quotas stay retryable via the default).
|
|
91
216
|
// TOO_LARGE (413), not QUOTA: the store has plenty of room, this file is simply
|
|
92
217
|
// bigger than this deployment permits. Reporting it as a capacity problem would
|
|
93
218
|
// send the user looking for space to free that would not help.
|
|
94
|
-
throw TroveError.tooLarge(
|
|
219
|
+
throw TroveError.tooLarge(
|
|
220
|
+
encrypting && req.size <= this.maxBytes
|
|
221
|
+
// Naming the reason, because "your 10MB file exceeds the 10MB limit" is a
|
|
222
|
+
// maddening thing to be told.
|
|
223
|
+
? `Encrypted, this file needs ${storedSize} bytes of storage, over the ${this.maxBytes}-byte limit`
|
|
224
|
+
: `File exceeds the maximum upload size of ${this.maxBytes} bytes`,
|
|
225
|
+
{ details: { maxBytes: this.maxBytes, size: req.size, storedSize } },
|
|
226
|
+
);
|
|
95
227
|
}
|
|
96
|
-
const collectionId = req.collectionId || 'default';
|
|
97
|
-
const storage = await this.#storage(collectionId);
|
|
98
|
-
const caps = storage.capabilities;
|
|
99
|
-
const storageKey = newId('obj');
|
|
100
|
-
const contentType = req.contentType || 'application/octet-stream';
|
|
101
228
|
|
|
102
229
|
const session = {
|
|
103
230
|
id: newId('up'),
|
|
@@ -108,7 +235,17 @@ export class UploadManager {
|
|
|
108
235
|
// the session because the decision is made when the upload STARTS but has to be
|
|
109
236
|
// honoured when it COMPLETES, possibly much later.
|
|
110
237
|
overwrite: !!req.overwrite,
|
|
238
|
+
// The size the USER sees, which is what the item is recorded as. `storedSize` is
|
|
239
|
+
// what actually occupies the bucket.
|
|
111
240
|
size: req.size,
|
|
241
|
+
storedSize,
|
|
242
|
+
encrypted: encrypting,
|
|
243
|
+
chunkSize: encrypting ? chunkSize : null,
|
|
244
|
+
// Fixed for the object, at negotiation, because parts are sealed INDEPENDENTLY and
|
|
245
|
+
// may arrive in any order — see `#sealPart`. Generating it per part would reuse
|
|
246
|
+
// nonces across the object, which AES-GCM does not survive.
|
|
247
|
+
noncePrefix: encrypting ? toHex(crypto.getRandomValues(new Uint8Array(8))) : null,
|
|
248
|
+
keyFingerprint: encrypting ? policy.encryption.fingerprint : null,
|
|
112
249
|
contentType,
|
|
113
250
|
createdAt: Date.now(),
|
|
114
251
|
strategy: null,
|
|
@@ -119,18 +256,35 @@ export class UploadManager {
|
|
|
119
256
|
|
|
120
257
|
const limits = this.#limits();
|
|
121
258
|
|
|
259
|
+
// Presigned strategies hand the client a URL to the bucket, which is only safe when
|
|
260
|
+
// the client has something the bucket may hold. It seals nothing now, so for an
|
|
261
|
+
// encrypted collection a presigned PUT would put PLAINTEXT in the store — exactly what
|
|
262
|
+
// the encryption exists to prevent. Those go through us, and we seal on the way past.
|
|
263
|
+
const canPresign = caps.presignUpload && !encrypting;
|
|
264
|
+
|
|
122
265
|
// Small file + presign → single PUT straight to storage (never through us).
|
|
123
|
-
if (
|
|
266
|
+
if (storedSize <= SINGLE_PUT_LIMIT && canPresign) {
|
|
124
267
|
session.strategy = 'single';
|
|
125
268
|
await this.sessions.put(session);
|
|
126
269
|
const url = await storage.presignPut(storageKey, { contentType });
|
|
127
|
-
return { ...planSummary(session), strategy: 'single', multipart: false, presigned: true, url, limits };
|
|
270
|
+
return { ...planSummary(session), strategy: 'single', multipart: false, presigned: true, url, limits, encryption: this.#planEncryption(session) };
|
|
128
271
|
}
|
|
129
272
|
|
|
130
273
|
// Multipart (presigned parts straight to storage, or streamed through us).
|
|
131
274
|
if (caps.multipart) {
|
|
132
|
-
session.strategy =
|
|
133
|
-
|
|
275
|
+
session.strategy = canPresign ? 'presign' : 'direct';
|
|
276
|
+
// Measured in PLAINTEXT for an encrypted upload: the client sends us the file as it
|
|
277
|
+
// is, and the envelope it becomes is bigger by a tag per chunk. Sizing parts against
|
|
278
|
+
// the stored size would ask the client for bytes it does not have.
|
|
279
|
+
const wireSize = encrypting ? req.size : storedSize;
|
|
280
|
+
const partCount = Math.max(1, Math.ceil(wireSize / this.partSize));
|
|
281
|
+
// A part must hold a whole number of chunks, or the chunk a part boundary lands
|
|
282
|
+
// inside would be sealed twice under two different indices.
|
|
283
|
+
if (encrypting && this.partSize % chunkSize !== 0) {
|
|
284
|
+
throw TroveError.internal(
|
|
285
|
+
`Part size ${this.partSize} is not a multiple of the ${chunkSize}-byte chunk size`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
134
288
|
// `#limits()` advertises maxParts in the very same response, and nothing enforced
|
|
135
289
|
// it: a 150 GiB file planned 19,200 parts against a ceiling of 10,000, which S3
|
|
136
290
|
// rejects at part 10,001 — after the client has transferred 80 GiB. On a presign
|
|
@@ -139,7 +293,7 @@ export class UploadManager {
|
|
|
139
293
|
if (partCount > MAX_PARTS) {
|
|
140
294
|
throw TroveError.tooLarge(
|
|
141
295
|
`This file needs ${partCount.toLocaleString()} parts of ${this.partSize} bytes, over the ${MAX_PARTS.toLocaleString()}-part limit`,
|
|
142
|
-
{ details: { maxParts: MAX_PARTS, partCount, partSize: this.partSize, size:
|
|
296
|
+
{ details: { maxParts: MAX_PARTS, partCount, partSize: this.partSize, size: storedSize } },
|
|
143
297
|
);
|
|
144
298
|
}
|
|
145
299
|
// Only now open the multipart. Refusing AFTER creating it left an upload open in
|
|
@@ -153,13 +307,67 @@ export class UploadManager {
|
|
|
153
307
|
parts.push({ partNumber: n, url: await storage.presignPart(storageKey, session.uploadId, n) });
|
|
154
308
|
}
|
|
155
309
|
}
|
|
156
|
-
return { ...planSummary(session), strategy: session.strategy, multipart: true, presigned: session.strategy === 'presign', partCount, parts, limits };
|
|
310
|
+
return { ...planSummary(session), strategy: session.strategy, multipart: true, presigned: session.strategy === 'presign', partCount, parts, limits, encryption: this.#planEncryption(session) };
|
|
157
311
|
}
|
|
158
312
|
|
|
159
313
|
// Fallback: whole-object PUT streamed through us (tiny/simple backends).
|
|
160
314
|
session.strategy = 'direct-single';
|
|
161
315
|
await this.sessions.put(session);
|
|
162
|
-
return { ...planSummary(session), strategy: 'direct-single', multipart: false, presigned: false, limits };
|
|
316
|
+
return { ...planSummary(session), strategy: 'direct-single', multipart: false, presigned: false, limits, encryption: this.#planEncryption(session) };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* What a client is told about encryption: that it is happening, and nothing else.
|
|
321
|
+
*
|
|
322
|
+
* This used to hand over the collection's data key so the browser could seal before the
|
|
323
|
+
* bytes left it — which is what made a presigned direct-to-bucket PUT possible while the
|
|
324
|
+
* bucket only ever held ciphertext. That trade is off. Encrypted collections now pass
|
|
325
|
+
* through the drive in BOTH directions, so the key never leaves the server and a client
|
|
326
|
+
* has nothing it could leak.
|
|
327
|
+
*
|
|
328
|
+
* The cost is deliberate and worth naming: bytes for an encrypted collection are paid
|
|
329
|
+
* for twice, once to us and once to the store, and a presigned upload is unavailable for
|
|
330
|
+
* them. Unencrypted collections are untouched and still go straight to the bucket.
|
|
331
|
+
*/
|
|
332
|
+
#planEncryption(session) {
|
|
333
|
+
if (!session.encrypted) return null;
|
|
334
|
+
return { algorithm: 'AES-256-GCM', sealedBy: 'server' };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Refuse an upload that was supposed to be encrypted and is not.
|
|
340
|
+
*
|
|
341
|
+
* Deleted rather than kept, like the over-size branch: an object that cannot be read is
|
|
342
|
+
* not worth the storage, and leaving it would also leave plaintext in a bucket the
|
|
343
|
+
* collection promises is ciphertext.
|
|
344
|
+
*/
|
|
345
|
+
async #assertSealed(storage, s) {
|
|
346
|
+
let head;
|
|
347
|
+
try {
|
|
348
|
+
const got = await storage.get(s.storageKey, { range: { start: 0, end: ENVELOPE_HEAD - 1 } });
|
|
349
|
+
head = new Uint8Array(await new Response(got.stream).arrayBuffer());
|
|
350
|
+
} catch {
|
|
351
|
+
return; // a backend that cannot serve a range gets the benefit of the doubt
|
|
352
|
+
}
|
|
353
|
+
const sealed = isEnvelope(head);
|
|
354
|
+
const matches = sealed && (() => {
|
|
355
|
+
try {
|
|
356
|
+
return toHex(decodeHeader(head).fingerprint) === s.keyFingerprint;
|
|
357
|
+
} catch {
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
})();
|
|
361
|
+
if (sealed && matches) return;
|
|
362
|
+
|
|
363
|
+
await storage.delete(s.storageKey).catch(() => {});
|
|
364
|
+
await this.sessions.delete(s.id);
|
|
365
|
+
throw TroveError.invalid(
|
|
366
|
+
sealed
|
|
367
|
+
? 'This upload was encrypted with the wrong key for this collection.'
|
|
368
|
+
: 'This collection encrypts its files, and this upload arrived unencrypted. The client '
|
|
369
|
+
+ 'must seal the bytes using the key in the upload plan before sending them.',
|
|
370
|
+
);
|
|
163
371
|
}
|
|
164
372
|
|
|
165
373
|
/** Re-issue a signed URL for one part (resume after expiry). */
|
|
@@ -194,7 +402,8 @@ export class UploadManager {
|
|
|
194
402
|
// deliberately doesn't apply — so this is the only place a client can be stopped
|
|
195
403
|
// from writing an unbounded object through us.
|
|
196
404
|
const capped = capStream(body, this.maxBytes);
|
|
197
|
-
const
|
|
405
|
+
const toStore = s.encrypted ? await this.#sealPart(s, 1, capped, true) : capped;
|
|
406
|
+
const info = await storage.put(s.storageKey, toStore, { contentType: s.contentType, ...opts });
|
|
198
407
|
s.parts[1] = { etag: info.etag || 'single' };
|
|
199
408
|
await this.sessions.put(s);
|
|
200
409
|
return { partNumber: 1, etag: s.parts[1].etag };
|
|
@@ -209,13 +418,62 @@ export class UploadManager {
|
|
|
209
418
|
if (!Number.isInteger(partNumber) || partNumber < 1 || (s.partCount && partNumber > s.partCount)) {
|
|
210
419
|
throw TroveError.invalid(`Part ${partNumber} is outside this upload's ${s.partCount} part(s)`);
|
|
211
420
|
}
|
|
212
|
-
// A part is exactly `partSize` bytes, except the last, which is smaller.
|
|
213
|
-
|
|
421
|
+
// A part is exactly `partSize` bytes, except the last, which is smaller. Capped on the
|
|
422
|
+
// way IN — that is the plaintext the client sends; what we write is bigger by a tag per
|
|
423
|
+
// chunk.
|
|
424
|
+
const capped = capStream(body, s.partSize);
|
|
425
|
+
const toStore = s.encrypted
|
|
426
|
+
? await this.#sealPart(s, partNumber, capped, partNumber === (s.partCount ?? 1))
|
|
427
|
+
: capped;
|
|
428
|
+
const res = await storage.putPart(s.storageKey, s.uploadId, partNumber, toStore, opts);
|
|
214
429
|
s.parts[partNumber] = { etag: res.etag };
|
|
215
430
|
await this.sessions.put(s);
|
|
216
431
|
return res;
|
|
217
432
|
}
|
|
218
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Seal one part on its way to the store.
|
|
436
|
+
*
|
|
437
|
+
* Parts are sealed INDEPENDENTLY, which is what lets a client keep sending them
|
|
438
|
+
* concurrently now that it no longer seals them itself. That is safe only because a
|
|
439
|
+
* part's position in the envelope is a function of its NUMBER: part n carries plaintext
|
|
440
|
+
* bytes [(n-1)·partSize, n·partSize), so it begins at chunk (n-1)·partSize/chunkSize, and
|
|
441
|
+
* the nonce prefix was fixed for the whole object at negotiation. Nothing depends on the
|
|
442
|
+
* order parts arrive in, and no two chunks can ever be sealed under the same nonce.
|
|
443
|
+
*
|
|
444
|
+
* The header goes on the front of part 1 and nowhere else — it is the first 44 bytes of
|
|
445
|
+
* the object. Every part but the last is `partial`, which is what makes `encryptStream`
|
|
446
|
+
* refuse to finish an envelope early and insist on stopping at a chunk boundary.
|
|
447
|
+
*/
|
|
448
|
+
async #sealPart(s, partNumber, body, isLast) {
|
|
449
|
+
const chunksPerPart = s.partSize / s.chunkSize;
|
|
450
|
+
const startChunk = (partNumber - 1) * chunksPerPart;
|
|
451
|
+
const key = await this.#dataKeyFor(s);
|
|
452
|
+
const sealed = await encryptStream(key, asStream(body), {
|
|
453
|
+
fingerprint: fromHex(s.keyFingerprint),
|
|
454
|
+
plaintextSize: s.size,
|
|
455
|
+
chunkSize: s.chunkSize,
|
|
456
|
+
resume: { noncePrefix: fromHex(s.noncePrefix), index: startChunk },
|
|
457
|
+
partial: !isLast,
|
|
458
|
+
});
|
|
459
|
+
if (partNumber !== 1) return sealed;
|
|
460
|
+
const header = encodeHeader({
|
|
461
|
+
chunkSize: s.chunkSize,
|
|
462
|
+
plaintextSize: s.size,
|
|
463
|
+
noncePrefix: fromHex(s.noncePrefix),
|
|
464
|
+
fingerprint: fromHex(s.keyFingerprint),
|
|
465
|
+
});
|
|
466
|
+
return prependBytes(header, sealed);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** The collection's key, for sealing. Never leaves this process. */
|
|
470
|
+
async #dataKeyFor(s) {
|
|
471
|
+
const policy = await this.encryptionFor(s.collectionId);
|
|
472
|
+
const key = policy?.dataKeyHex ? fromHex(policy.dataKeyHex) : null;
|
|
473
|
+
if (!key) throw TroveError.internal('This collection is encrypted but its key is unavailable');
|
|
474
|
+
return key;
|
|
475
|
+
}
|
|
476
|
+
|
|
219
477
|
/** Which parts are still outstanding (resume support). */
|
|
220
478
|
async status(uploadId) {
|
|
221
479
|
const s = await this.#session(uploadId);
|
|
@@ -292,15 +550,40 @@ export class UploadManager {
|
|
|
292
550
|
{ details: { maxBytes: this.maxBytes, size } },
|
|
293
551
|
);
|
|
294
552
|
}
|
|
553
|
+
// An upload planned as encrypted must have ARRIVED encrypted.
|
|
554
|
+
//
|
|
555
|
+
// `complete` otherwise records what the session INTENDED, so a client that ignores
|
|
556
|
+
// `plan.encryption` — which is every client that has not implemented it yet — uploads
|
|
557
|
+
// plaintext and the item is stamped with a key fingerprint anyway. The result is the
|
|
558
|
+
// worst of both: permanently unreadable, because the read path looks for an envelope
|
|
559
|
+
// that is not there, and not actually protected, because the bytes are sitting in the
|
|
560
|
+
// bucket in the clear on a collection labelled encrypted.
|
|
561
|
+
//
|
|
562
|
+
// The envelope header is readable without the key, which is exactly what makes this
|
|
563
|
+
// checkable here. One small ranged read, once, at the end of an upload.
|
|
564
|
+
if (s.encrypted) {
|
|
565
|
+
await this.#assertSealed(storage, s);
|
|
566
|
+
}
|
|
567
|
+
|
|
295
568
|
await this.sessions.delete(uploadId);
|
|
296
569
|
return {
|
|
297
570
|
storageKey: s.storageKey,
|
|
298
|
-
|
|
571
|
+
// For an encrypted object the store holds an envelope, which is larger than the
|
|
572
|
+
// file. The item records the file: that is the number a user recognises, the one
|
|
573
|
+
// search results and quotas are about, and the one a range request is against.
|
|
574
|
+
size: s.encrypted ? s.size : size,
|
|
575
|
+
storedSize: size,
|
|
299
576
|
contentType: s.contentType,
|
|
300
577
|
etag,
|
|
301
578
|
collectionId: s.collectionId,
|
|
302
579
|
name: s.name,
|
|
303
580
|
overwrite: !!s.overwrite,
|
|
581
|
+
// Which key opens this object. Recorded on the item so reading it does not have to
|
|
582
|
+
// fetch the envelope header first, and so a rotation can find what it has not yet
|
|
583
|
+
// converted without opening every object in the bucket.
|
|
584
|
+
encryption: s.encrypted
|
|
585
|
+
? { fingerprint: s.keyFingerprint, chunkSize: s.chunkSize }
|
|
586
|
+
: null,
|
|
304
587
|
};
|
|
305
588
|
}
|
|
306
589
|
|
package/packages/core/src/vfs.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
|
|
13
13
|
import { TroveError, isOutOfSpace } from './errors.js';
|
|
14
14
|
import { UploadManager } from './uploads.js';
|
|
15
|
+
import { toHex } from './encryption/keys.js';
|
|
16
|
+
import { decryptStream, decodeHeader, cipherRangeFor, cipherSize, HEADER_BYTES } from './encryption/envelope.js';
|
|
15
17
|
import { IndexerRegistry } from './indexers/registry.js';
|
|
16
18
|
import { ParsingSearchTransformer, matchTagFilters } from './search/transformer.js';
|
|
17
19
|
import { extname } from './util.js';
|
|
@@ -36,8 +38,46 @@ const CONTENT_TYPES = {
|
|
|
36
38
|
'.mp4': 'video/mp4', '.webm': 'video/webm', '.zip': 'application/zip',
|
|
37
39
|
};
|
|
38
40
|
|
|
41
|
+
|
|
42
|
+
/** Drain a stream into one buffer. Only ever used for the fixed-size envelope header. */
|
|
43
|
+
async function bytesOf(stream) {
|
|
44
|
+
const reader = stream.getReader();
|
|
45
|
+
const parts = [];
|
|
46
|
+
let total = 0;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const { value, done } = await reader.read();
|
|
49
|
+
if (done) break;
|
|
50
|
+
parts.push(value);
|
|
51
|
+
total += value.length;
|
|
52
|
+
}
|
|
53
|
+
const out = new Uint8Array(total);
|
|
54
|
+
let at = 0;
|
|
55
|
+
for (const p of parts) { out.set(p, at); at += p.length; }
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Emit only the bytes between `start` and `end` of a stream.
|
|
61
|
+
*
|
|
62
|
+
* A plaintext range rarely lines up with a chunk boundary, so decryption yields whole
|
|
63
|
+
* chunks and this drops the overhang at each end — without collecting the middle, which is
|
|
64
|
+
* the entire point of streaming in the first place.
|
|
65
|
+
*/
|
|
66
|
+
function trimStream(stream, start, end) {
|
|
67
|
+
let seen = 0;
|
|
68
|
+
return stream.pipeThrough(new TransformStream({
|
|
69
|
+
transform(chunk, controller) {
|
|
70
|
+
const from = Math.max(0, start - seen);
|
|
71
|
+
const to = Math.min(chunk.length, end - seen);
|
|
72
|
+
if (to > from) controller.enqueue(chunk.subarray(from, to));
|
|
73
|
+
seen += chunk.length;
|
|
74
|
+
if (seen >= end) controller.terminate();
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
39
79
|
export class Vfs {
|
|
40
|
-
constructor({ storage, metadata, search, indexers, sidecar, collections, searchTransformer, issues, signedUrls = null, publicUrl = '', maxIndexBytes = 2 * 1024 * 1024, maxUploadBytes = null, uploadPartSize = undefined }) {
|
|
80
|
+
constructor({ storage, metadata, search, indexers, sidecar, collections, searchTransformer, issues, signedUrls = null, publicUrl = '', maxIndexBytes = 2 * 1024 * 1024, maxUploadBytes = null, uploadPartSize = undefined, uploadSessions = undefined }) {
|
|
41
81
|
if (!storage && !collections) throw TroveError.invalid('Vfs requires a storage backend or a CollectionService');
|
|
42
82
|
if (!metadata) throw TroveError.invalid('Vfs requires a metadata store');
|
|
43
83
|
this.storage = storage; // primary backend (default collection + capability reporting)
|
|
@@ -49,7 +89,27 @@ export class Vfs {
|
|
|
49
89
|
this.collections = collections ?? null;
|
|
50
90
|
this.indexers = indexers ?? new IndexerRegistry();
|
|
51
91
|
// One UploadManager; it resolves the right backend per session's collection.
|
|
52
|
-
|
|
92
|
+
//
|
|
93
|
+
// `uploadSessions` is where the sessions live. It defaults to memory, which is right
|
|
94
|
+
// for a test and wrong for anything where two requests of one upload can be served by
|
|
95
|
+
// different processes — see KvSessionStore. Injected rather than built here because
|
|
96
|
+
// core does not otherwise need a KeyValueStore.
|
|
97
|
+
this.uploads = new UploadManager({
|
|
98
|
+
storageFor: (cid) => this.storageFor(cid),
|
|
99
|
+
sessions: uploadSessions,
|
|
100
|
+
// What a collection encrypts and the key for it. Only the CollectionService knows,
|
|
101
|
+
// and only it is allowed to hand the key out — see collections/index.js.
|
|
102
|
+
encryptionFor: async (cid) => {
|
|
103
|
+
if (!this.collections?.encryptionFor) return null;
|
|
104
|
+
const encryption = await this.collections.encryptionFor(cid);
|
|
105
|
+
if (!encryption?.enabled) return null;
|
|
106
|
+
const key = await this.collections.dataKeyFor(cid);
|
|
107
|
+
if (!key) return null;
|
|
108
|
+
return { encryption, dataKeyHex: toHex(key) };
|
|
109
|
+
},
|
|
110
|
+
maxBytes: maxUploadBytes,
|
|
111
|
+
partSize: uploadPartSize,
|
|
112
|
+
});
|
|
53
113
|
this.maxIndexBytes = maxIndexBytes;
|
|
54
114
|
// The indexing subsystem (run/backfill/purge/contributions) lives here.
|
|
55
115
|
// Where a failure to index becomes a standing, retryable problem rather than a
|
|
@@ -223,17 +283,20 @@ export class Vfs {
|
|
|
223
283
|
* — and an unconditional replace at completion silently destroys whichever landed
|
|
224
284
|
* first. So an upload re-resolves the collision at the moment it commits.
|
|
225
285
|
*/
|
|
226
|
-
async #upsertItem({ collectionId, name, storageKey, size, contentType, etag, overwrite = true }) {
|
|
286
|
+
async #upsertItem({ collectionId, name, storageKey, size, contentType, etag, overwrite = true, encryption = null }) {
|
|
227
287
|
let finalName = name;
|
|
228
288
|
const existing = await this.metadata.getByName(collectionId, name);
|
|
229
289
|
if (existing && overwrite) {
|
|
230
290
|
const oldKey = existing.storageKey;
|
|
231
|
-
const updated = await this.metadata.update(existing.id, { storageKey, size, contentType, etag });
|
|
291
|
+
const updated = await this.metadata.update(existing.id, { storageKey, size, contentType, etag, encryption });
|
|
232
292
|
if (oldKey && oldKey !== storageKey) (await this.storageFor(collectionId)).delete(oldKey).catch(() => {});
|
|
233
293
|
return updated;
|
|
234
294
|
}
|
|
235
295
|
if (existing) finalName = await this.#uniqueName(collectionId, name);
|
|
236
|
-
|
|
296
|
+
// `encryption` names which key opens this object. Carried on the item so a read does
|
|
297
|
+
// not have to fetch the envelope header to find out, and so a rotation can tell what it
|
|
298
|
+
// has not converted without opening every object in the bucket.
|
|
299
|
+
return this.metadata.create({ collectionId, name: finalName, storageKey, size, contentType, etag, encryption });
|
|
237
300
|
}
|
|
238
301
|
|
|
239
302
|
/**
|
|
@@ -399,15 +462,23 @@ export class Vfs {
|
|
|
399
462
|
|
|
400
463
|
// --- download --------------------------------------------------------------
|
|
401
464
|
|
|
402
|
-
async getDownload(id, { expiresIn, download } = {}) {
|
|
465
|
+
async getDownload(id, { expiresIn, download, ciphertext = false } = {}) {
|
|
403
466
|
const node = await this.resolve(id);
|
|
404
467
|
const storage = await this.storageFor(node.collectionId);
|
|
468
|
+
// An encrypted object is not redirected to by default. A redirect hands the caller raw
|
|
469
|
+
// ciphertext, and most callers of a download URL cannot do anything with it — an <img
|
|
470
|
+
// src>, a <video src>, a signed URL given to an external service. Proxying is the
|
|
471
|
+
// answer that is always correct, so it is the default; a client that holds the key and
|
|
472
|
+
// knows it can decrypt asks for `ciphertext` and gets the direct path back.
|
|
473
|
+
if (node.encryption && !ciphertext) return { mode: 'proxy', node };
|
|
405
474
|
if (storage.capabilities.presignDownload) {
|
|
406
475
|
const url = await storage.presignGet(node.storageKey, {
|
|
407
476
|
expiresIn, responseContentType: node.contentType,
|
|
408
477
|
downloadName: download ? node.name : undefined,
|
|
409
478
|
});
|
|
410
|
-
|
|
479
|
+
// Named on the way out so a client that asked for ciphertext knows which key opens
|
|
480
|
+
// what it is about to receive.
|
|
481
|
+
return { mode: 'redirect', url, node, encryption: node.encryption || null };
|
|
411
482
|
}
|
|
412
483
|
return { mode: 'proxy', node };
|
|
413
484
|
}
|
|
@@ -431,7 +502,19 @@ export class Vfs {
|
|
|
431
502
|
const node = await this.resolve(id);
|
|
432
503
|
if (!node.storageKey) throw TroveError.notFound('File content');
|
|
433
504
|
const storage = await this.storageFor(node.collectionId);
|
|
434
|
-
|
|
505
|
+
// An ENCRYPTED object is never presigned to the store — the same rule `getDownload`
|
|
506
|
+
// already follows, and the one place it was missing. A bucket URL serves CIPHERTEXT,
|
|
507
|
+
// and everything a minted URL exists for (an <img src>, a <video src>, cache.add(), a
|
|
508
|
+
// URL handed to an external service) fetches bytes with nowhere to run decryption. So
|
|
509
|
+
// every thumbnail and preview in an encrypted collection on a presigning store pointed
|
|
510
|
+
// at ciphertext and rendered nothing.
|
|
511
|
+
//
|
|
512
|
+
// Worse than proxying would have been: a bucket URL is a DIFFERENT ORIGIN, so the
|
|
513
|
+
// service worker never saw it either — the one place that could have decrypted was
|
|
514
|
+
// routed around. Sending these through our own address is what lets the worker read
|
|
515
|
+
// straight from the store and decrypt (platform/directRead.js), and lets the server
|
|
516
|
+
// decrypt for anything without a worker.
|
|
517
|
+
if (storage.capabilities.presignDownload && !node.encryption) {
|
|
435
518
|
const seconds = clampAge(op, expiresIn);
|
|
436
519
|
const url = await storage.presignGet(node.storageKey, {
|
|
437
520
|
expiresIn: seconds, responseContentType: node.contentType,
|
|
@@ -457,7 +540,75 @@ export class Vfs {
|
|
|
457
540
|
async readStream(id, { range, signal } = {}) {
|
|
458
541
|
const node = await this.resolve(id);
|
|
459
542
|
if (!node.storageKey) throw TroveError.notFound('File content');
|
|
460
|
-
|
|
543
|
+
const storage = await this.storageFor(node.collectionId);
|
|
544
|
+
if (!node.encryption) return storage.get(node.storageKey, { range, signal });
|
|
545
|
+
|
|
546
|
+
// An encrypted object is decrypted HERE, for everything that reads through the server.
|
|
547
|
+
//
|
|
548
|
+
// The browser can decrypt for itself when it fetches — and does, which is what keeps a
|
|
549
|
+
// presigned download direct. But an <img src>, a <video src>, a signed URL handed to an
|
|
550
|
+
// external service, and the service worker's own cache fills cannot: they are bare URLs
|
|
551
|
+
// that get bytes and have nowhere to run our code. Those all arrive here, so this is
|
|
552
|
+
// where correctness has to live.
|
|
553
|
+
//
|
|
554
|
+
// The plaintext range the caller asked for is mapped onto the chunks that hold it, so a
|
|
555
|
+
// seek into a video still fetches a chunk rather than the film.
|
|
556
|
+
// The ENVELOPE is read first, and it is authoritative.
|
|
557
|
+
//
|
|
558
|
+
// The item's own record says which key sealed it and at what chunk size, and that is
|
|
559
|
+
// what makes a listing cheap — but it is a copy, and a copy can be stale: an object
|
|
560
|
+
// restored from a backup, adopted by a scan, or written by another version. Deriving
|
|
561
|
+
// the byte ranges from the record and then decrypting with the object's real geometry
|
|
562
|
+
// is how you get chunk indices computed against one layout and nonces against another,
|
|
563
|
+
// which surfaces as "the data has been altered" on data nobody altered.
|
|
564
|
+
const head = await storage.get(node.storageKey, { range: { start: 0, end: HEADER_BYTES - 1 }, signal });
|
|
565
|
+
const header = decodeHeader(await bytesOf(head.stream));
|
|
566
|
+
// The plaintext size likewise: the envelope knows, the record remembers.
|
|
567
|
+
const plaintextSize = header.plaintextSize ?? node.size;
|
|
568
|
+
const key = await this.#keyForNode(node, header);
|
|
569
|
+
|
|
570
|
+
const want = range
|
|
571
|
+
? cipherRangeFor({ start: range.start, end: range.end }, { ...header, plaintextSize })
|
|
572
|
+
: {
|
|
573
|
+
cipherStart: 0,
|
|
574
|
+
cipherEnd: cipherSize(plaintextSize, header.chunkSize) - 1,
|
|
575
|
+
firstChunk: 0,
|
|
576
|
+
trimStart: 0,
|
|
577
|
+
trimEnd: plaintextSize,
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const body = await storage.get(node.storageKey, {
|
|
581
|
+
range: { start: Math.max(want.cipherStart, HEADER_BYTES), end: want.cipherEnd }, signal,
|
|
582
|
+
});
|
|
583
|
+
const plain = await decryptStream(key, header, body.stream, want.firstChunk);
|
|
584
|
+
return {
|
|
585
|
+
stream: trimStream(plain, want.trimStart, want.trimEnd),
|
|
586
|
+
size: want.trimEnd - want.trimStart,
|
|
587
|
+
contentType: node.contentType,
|
|
588
|
+
etag: head.etag,
|
|
589
|
+
range: range ? { start: range.start, end: range.start + (want.trimEnd - want.trimStart) - 1, total: plaintextSize } : null,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The key that opens this object, chosen by what the ENVELOPE names.
|
|
595
|
+
*
|
|
596
|
+
* The object is the authority on which key sealed it; the item's record is a copy kept
|
|
597
|
+
* for cheap listings. Trusting the record would mean a stale one sends us to the wrong
|
|
598
|
+
* key and the failure reads as corruption.
|
|
599
|
+
*/
|
|
600
|
+
async #keyForNode(node, header) {
|
|
601
|
+
const want = toHex(header.fingerprint);
|
|
602
|
+
const key = this.collections?.dataKeyFor
|
|
603
|
+
? await this.collections.dataKeyFor(node.collectionId, want)
|
|
604
|
+
: null;
|
|
605
|
+
if (!key) {
|
|
606
|
+
throw TroveError.invalid(
|
|
607
|
+
'This item is encrypted with a key this collection no longer holds. It was probably '
|
|
608
|
+
+ 'retired before a key rotation finished moving everything onto the new one.',
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
return key;
|
|
461
612
|
}
|
|
462
613
|
|
|
463
614
|
// --- uploads ---------------------------------------------------------------
|
|
@@ -511,7 +662,7 @@ export class Vfs {
|
|
|
511
662
|
const node = await this.#upsertItem({
|
|
512
663
|
collectionId: obj.collectionId, name: obj.name, storageKey: obj.storageKey,
|
|
513
664
|
size: obj.size, contentType: obj.contentType, etag: obj.etag,
|
|
514
|
-
overwrite: obj.overwrite,
|
|
665
|
+
overwrite: obj.overwrite, encryption: obj.encryption || null,
|
|
515
666
|
});
|
|
516
667
|
this.indexing.indexNode(node).catch((e) => console.error('index error', e));
|
|
517
668
|
return node;
|