@pithy-sh/storage 0.1.0

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.
@@ -0,0 +1,684 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { StorageConfig } from "../config/config";
5
+ import { StorageShare } from "../data/share";
6
+ import { StorageObject } from "../data/storageObject";
7
+ import { STORAGE_OBJECTS_TABLE, STORAGE_SHARES_TABLE, type StorageDatabase } from "../data/tables";
8
+ import {
9
+ StorageForbiddenError,
10
+ StorageNotFoundError,
11
+ StorageShareExpiredError,
12
+ StorageShareRevokedError,
13
+ StorageUploadIncompleteError,
14
+ } from "../error/errors";
15
+ import { deriveObjectKey } from "../object/key";
16
+ import { collectParts, needsMultipart, planMultipart } from "../object/multipart";
17
+ import type { ObjectStore, UploadedPart } from "../object/store";
18
+ import { assertWithinQuota, insertReservingQuota, updateSettlingQuota } from "../quota/quota";
19
+ import type {
20
+ CompleteUploadInput,
21
+ CopyObjectInput,
22
+ CreateShareInput,
23
+ CreateUploadInput,
24
+ ListObjectsQuery,
25
+ UpdateObjectInput,
26
+ } from "./schemas";
27
+
28
+ /**
29
+ * The storage request handlers — pure functions over injected dependencies, so every branch is tested
30
+ * against real D1 and real R2 without standing up a Worker. `routes.ts` is a thin shell that resolves
31
+ * these dependencies from the request env and maps their results onto responses.
32
+ *
33
+ * **Request shape is not validated here.** Each route declares its own schemas with
34
+ * `zValidator(target, Schema, validationHook)` and hands the handler the already-parsed value, so a
35
+ * malformed request is refused before a dependency is resolved or a row is read. What survives in
36
+ * these handlers is everything a request schema cannot express — ownership, quota, upload state —
37
+ * which is the whole of what they were ever really about.
38
+ *
39
+ * **The object key never leaves this module.** Every client-facing shape goes through {@link view},
40
+ * which drops `key` and `uploadId`. That is not tidiness: the key is the only thing a presigned URL
41
+ * addresses, so a key in a response body is a capability leak waiting for the first adopter who logs
42
+ * their API responses.
43
+ *
44
+ * **A file you cannot see reads as missing.** `assertOwner` answers `storage/not_found` for a private
45
+ * object owned by someone else and `storage/forbidden` only when the object is already public —
46
+ * because a 403 on a private object confirms it exists, which is exactly the oracle an enumeration
47
+ * attack wants. Public objects have nothing left to hide, so there the honest answer is the useful one.
48
+ */
49
+
50
+ /** Everything a handler needs, all injectable. */
51
+ export interface HandlerDeps {
52
+ /** The storage database. */
53
+ db: StorageDatabase;
54
+ /** The object plane — presigning, the multipart lifecycle, and binding-backed reads. */
55
+ store: ObjectStore;
56
+ /** The resolved storage config: quota, multipart sizing, default visibility. */
57
+ config: StorageConfig;
58
+ /** The authenticated caller from the core `AuthContext` seam, or null for an unauthenticated read. */
59
+ ownerId: string | null;
60
+ /** Mints an object id. */
61
+ newId: () => string;
62
+ /** Mints a share token. */
63
+ newToken: () => string;
64
+ /** The current time. */
65
+ now: () => Date;
66
+ }
67
+
68
+ /** The client-facing shape of a stored file. Deliberately without `key` and `uploadId`. */
69
+ export interface StorageObjectView {
70
+ id: string;
71
+ path: string;
72
+ ownerId: string | null;
73
+ contentType: string;
74
+ size: number | null;
75
+ visibility: "private" | "public";
76
+ checksum: string | null;
77
+ status: "pending" | "stored" | "failed";
78
+ createdAt: Date;
79
+ updatedAt: Date;
80
+ }
81
+
82
+ /** One part of a multipart upload, with the URL the client PUTs those bytes to. */
83
+ export interface UploadPartTarget {
84
+ partNumber: number;
85
+ offset: number;
86
+ length: number;
87
+ url: string;
88
+ }
89
+
90
+ /** Where to send the bytes: one URL, or one per part. */
91
+ export type UploadTarget =
92
+ | { kind: "single"; url: string }
93
+ | { kind: "multipart"; uploadId: string; partSize: number; parts: UploadPartTarget[] };
94
+
95
+ /** What an upload-init answers with: the record's coordinates and where the bytes go. */
96
+ export interface UploadInitResult {
97
+ object: StorageObjectView;
98
+ upload: UploadTarget;
99
+ }
100
+
101
+ /** A minted share link. */
102
+ export interface ShareView {
103
+ token: string;
104
+ objectId: string;
105
+ expiresAt: Date | null;
106
+ createdAt: Date;
107
+ }
108
+
109
+ /** Drop the server-only fields. The single place a row becomes something a client may see. */
110
+ function view(object: StorageObject): StorageObjectView {
111
+ return {
112
+ id: object.id,
113
+ path: object.path,
114
+ ownerId: object.ownerId,
115
+ contentType: object.contentType,
116
+ size: object.size,
117
+ visibility: object.visibility,
118
+ checksum: object.checksum,
119
+ status: object.status,
120
+ createdAt: object.createdAt,
121
+ updatedAt: object.updatedAt,
122
+ };
123
+ }
124
+
125
+ /** Load one row, decoded through the schema. Null when absent. */
126
+ async function findObject(deps: HandlerDeps, id: string): Promise<StorageObject | null> {
127
+ const row = await deps.db.selectFrom(STORAGE_OBJECTS_TABLE).selectAll().where("id", "=", id).executeTakeFirst();
128
+ return row ? StorageObject.parse(row) : null;
129
+ }
130
+
131
+ /** Load one row or throw `storage/not_found`. */
132
+ async function loadObject(deps: HandlerDeps, id: string): Promise<StorageObject> {
133
+ const object = await findObject(deps, id);
134
+ if (!object) throw new StorageNotFoundError({ detail: `no storage object ${id}` });
135
+ return object;
136
+ }
137
+
138
+ /**
139
+ * Require the caller to own the object. A private object they do not own is reported as **missing**,
140
+ * so the route cannot be used to test whether an id exists. A public object is reported as
141
+ * **forbidden**, because its existence is already public knowledge and "missing" would just be a lie.
142
+ */
143
+ function assertOwner(object: StorageObject, ownerId: string | null): void {
144
+ if (ownerId !== null && object.ownerId === ownerId) return;
145
+ if (object.visibility === "public") {
146
+ throw new StorageForbiddenError({ detail: `object ${object.id} is owned by ${object.ownerId ?? "the system"}` });
147
+ }
148
+ throw new StorageNotFoundError({ detail: `object ${object.id} is not owned by ${ownerId ?? "an anonymous caller"}` });
149
+ }
150
+
151
+ /** Require the caller to be allowed to *read* the object: it is public, or it is theirs. */
152
+ function assertReadable(object: StorageObject, ownerId: string | null): void {
153
+ if (object.visibility === "public") return;
154
+ if (ownerId !== null && object.ownerId === ownerId) return;
155
+ throw new StorageNotFoundError({
156
+ detail: `object ${object.id} is not readable by ${ownerId ?? "an anonymous caller"}`,
157
+ });
158
+ }
159
+
160
+ /** Require the object's bytes to actually be there. A `pending` row has a record but no content. */
161
+ function assertStored(object: StorageObject): void {
162
+ if (object.status === "stored") return;
163
+ throw new StorageUploadIncompleteError({ detail: `object ${object.id} is ${object.status}, not stored` });
164
+ }
165
+
166
+ /** Write a whole row back through the codec — the round-trip rule, so no field bypasses its codec. */
167
+ async function saveObject(deps: HandlerDeps, object: StorageObject): Promise<StorageObject> {
168
+ const record = StorageObject.encode(object);
169
+ await deps.db.updateTable(STORAGE_OBJECTS_TABLE).set(record).where("id", "=", object.id).execute();
170
+ return object;
171
+ }
172
+
173
+ /**
174
+ * Start an upload: reserve the row and its quota, then mint the URL(s) the client PUTs to.
175
+ *
176
+ * **The reservation is the insert, and the insert is conditional.** A `pending` row holds its bytes
177
+ * against the owner's quota, so the row has to exist before an upload can proceed — but a check that
178
+ * ran separately from the write would let a burst of concurrent inits each read a used total none of
179
+ * them had yet contributed to. `insertReservingQuota` evaluates the sum inside the write instead, so
180
+ * the tenth concurrent init sees the other nine (`quota/quota.ts`).
181
+ *
182
+ * The pre-check above it is a courtesy: it refuses an obviously oversized upload before an R2
183
+ * multipart upload exists to clean up. When the conditional insert loses the race anyway, that
184
+ * multipart upload is aborted — a refused init leaves R2 holding nothing.
185
+ */
186
+ export async function initUpload(deps: HandlerDeps, input: CreateUploadInput): Promise<UploadInitResult> {
187
+ const now = deps.now();
188
+
189
+ await assertWithinQuota({
190
+ db: deps.db,
191
+ ownerId: deps.ownerId,
192
+ limitBytes: deps.config.quota.bytesPerOwner,
193
+ additionalBytes: input.size,
194
+ });
195
+
196
+ const key = deriveObjectKey();
197
+ const multipart = needsMultipart(input.size, deps.config.multipartThresholdBytes);
198
+
199
+ let upload: UploadTarget;
200
+ let uploadId: string | null = null;
201
+ if (multipart) {
202
+ const plan = planMultipart(input.size, deps.config.partSizeBytes);
203
+ uploadId = await deps.store.initMultipart(key, input.contentType);
204
+ const parts: UploadPartTarget[] = [];
205
+ for (const part of plan.parts) {
206
+ parts.push({
207
+ partNumber: part.partNumber,
208
+ offset: part.offset,
209
+ length: part.length,
210
+ url: await deps.store.presignPart(key, uploadId, part.partNumber),
211
+ });
212
+ }
213
+ upload = { kind: "multipart", uploadId, partSize: plan.partSize, parts };
214
+ } else {
215
+ upload = { kind: "single", url: await deps.store.presignPut(key, input.contentType, input.size) };
216
+ }
217
+
218
+ const object: StorageObject = {
219
+ id: deps.newId(),
220
+ key,
221
+ path: input.path,
222
+ ownerId: deps.ownerId,
223
+ contentType: input.contentType,
224
+ size: input.size,
225
+ visibility: input.visibility ?? deps.config.defaultVisibility,
226
+ checksum: null,
227
+ status: "pending",
228
+ uploadId,
229
+ createdAt: now,
230
+ updatedAt: now,
231
+ };
232
+ try {
233
+ await insertReservingQuota({
234
+ db: deps.db,
235
+ ownerId: deps.ownerId,
236
+ limitBytes: deps.config.quota.bytesPerOwner,
237
+ additionalBytes: input.size,
238
+ record: StorageObject.encode(object),
239
+ });
240
+ } catch (error) {
241
+ // The row never landed, so nothing will ever complete or abort this upload. Discard it here or R2
242
+ // holds orphaned parts until the sweep notices.
243
+ if (uploadId) await deps.store.abortMultipart(key, uploadId).catch(() => {});
244
+ throw error;
245
+ }
246
+
247
+ return { object: view(object), upload };
248
+ }
249
+
250
+ /**
251
+ * Finalize an upload: assemble the parts (for a multipart), then confirm against R2 that the bytes
252
+ * are really there before the row is allowed to claim they are.
253
+ *
254
+ * The `head` is not a formality. Without it a client could complete an upload it never performed and
255
+ * leave a `stored` row pointing at nothing — a 404 from R2 at read time, long after the request that
256
+ * caused it. The size *and the content type* R2 reports win over the declared ones, so the row records
257
+ * what was stored rather than what was promised.
258
+ *
259
+ * **It is also where the quota is settled.** Part URLs carry no signed `Content-Length` — the final
260
+ * part's differs from every other, so pinning one at mint time would mean knowing the total up front
261
+ * and would still leave the last part unconstrained. The declared size therefore buys a *reservation*,
262
+ * not a limit, and an owner who declares 100 MiB can PUT far more across those URLs. Completion is the
263
+ * first and last point where the real byte count is known and the object can still be thrown away, so
264
+ * anything past the reservation is re-asserted against the quota here.
265
+ *
266
+ * **That re-assertion is the write, for the same reason the reservation was.** A check that ran ahead
267
+ * of the `UPDATE` would let two completions racing on one owner both read a total neither had yet
268
+ * contributed to, both pass, and both store — the init race, reproduced at the other end of the
269
+ * lifecycle. `updateSettlingQuota` evaluates the sum inside the update, so the second completion sees
270
+ * the first (`quota/quota.ts`).
271
+ */
272
+ export async function completeUpload(
273
+ deps: HandlerDeps,
274
+ id: string,
275
+ input: CompleteUploadInput,
276
+ ): Promise<StorageObjectView> {
277
+ const object = await loadObject(deps, id);
278
+ assertOwner(object, deps.ownerId);
279
+ // Completing twice is a no-op rather than an error: a client that retried a dropped response gets
280
+ // the same answer, which is what makes the route safe to retry at all.
281
+ if (object.status === "stored") return view(object);
282
+ if (object.status !== "pending") {
283
+ throw new StorageUploadIncompleteError({ detail: `object ${id} is ${object.status} and cannot be completed` });
284
+ }
285
+
286
+ if (object.uploadId) {
287
+ const expected = planMultipart(object.size ?? 0, deps.config.partSizeBytes).partCount;
288
+ const parts = collectParts(input.parts, expected);
289
+ await deps.store.completeMultipart(object.key, object.uploadId, parts);
290
+ }
291
+
292
+ const metadata = await deps.store.head(object.key);
293
+ if (!metadata) {
294
+ throw new StorageUploadIncompleteError({
295
+ message: "No bytes were uploaded for that file.",
296
+ action: "Upload to the URL the init call returned, then complete again.",
297
+ detail: `R2 has no object at ${object.key}`,
298
+ });
299
+ }
300
+
301
+ const settled: StorageObject = {
302
+ ...object,
303
+ status: "stored",
304
+ size: metadata.size,
305
+ // The declared type is not enforceable: S3 presigning marks `content-type` unsignable, so a
306
+ // client may PUT any type it likes to the URL it was given. Reconcile rather than reject — the
307
+ // bytes are already stored and paid for, and a row that disagrees with the object is the actual
308
+ // defect. R2 reporting no type at all leaves the declaration standing; it is all we have.
309
+ contentType: metadata.contentType ?? object.contentType,
310
+ checksum: input.checksum ?? metadata.checksumSha256 ?? null,
311
+ uploadId: null,
312
+ updatedAt: deps.now(),
313
+ };
314
+
315
+ // The pending row already reserves its declared size, and the quota sum still counts it — so only
316
+ // the overshoot is new. Settling against `metadata.size` would bill the reservation twice.
317
+ const overshoot = metadata.size - (object.size ?? 0);
318
+ // An upload smaller than it declared claims nothing further, so it is written unconditionally. A
319
+ // quota an adopter lowered under an in-flight upload must not turn a shrinking completion into a
320
+ // deletion of bytes the owner was, at reservation time, granted.
321
+ if (overshoot <= 0) return view(await saveObject(deps, settled));
322
+
323
+ try {
324
+ await updateSettlingQuota({
325
+ db: deps.db,
326
+ ownerId: object.ownerId,
327
+ limitBytes: deps.config.quota.bytesPerOwner,
328
+ additionalBytes: overshoot,
329
+ record: StorageObject.encode(settled),
330
+ });
331
+ } catch (error) {
332
+ // Do not keep bytes the owner was never granted. The object goes, and the row goes `failed`,
333
+ // which returns the reservation — the same end state an abort reaches. The delete is best-effort,
334
+ // so a transient R2 failure can leave the object behind; the `failed` row no longer claims its
335
+ // key, so the orphan sweep collects it (`workflows/sweep.ts`).
336
+ await deps.store.delete(object.key).catch(() => {});
337
+ await saveObject(deps, { ...object, status: "failed", uploadId: null, updatedAt: deps.now() });
338
+ throw error;
339
+ }
340
+ return view(settled);
341
+ }
342
+
343
+ /**
344
+ * Abandon an in-flight upload: tell R2 to discard the stored parts, then mark the row `failed`.
345
+ *
346
+ * The row survives rather than being deleted. `failed` rows are excluded from the quota sum, so they
347
+ * hold nothing, and keeping them means an owner can see that an upload was attempted and abandoned
348
+ * instead of the record silently evaporating.
349
+ *
350
+ * **The bytes are R2's problem after this, not the row's.** A single-PUT abort cannot revoke the
351
+ * presigned URL it handed out — nothing can, which is the whole cost of presigning — so a client may
352
+ * still PUT to it for the rest of that URL's hour, after the delete below has run. The row that
353
+ * survives therefore stops *claiming* its key the moment it goes `failed`: the orphan sweep matches
354
+ * only rows that bill for a key, so anything that lands afterwards is collected as an orphan rather
355
+ * than sheltered forever by a row that counts toward no quota (`workflows/sweep.ts`).
356
+ */
357
+ export async function abortUpload(deps: HandlerDeps, id: string): Promise<StorageObjectView> {
358
+ const object = await loadObject(deps, id);
359
+ assertOwner(object, deps.ownerId);
360
+ if (object.status === "stored") {
361
+ throw new StorageUploadIncompleteError({
362
+ message: "That upload already finished.",
363
+ action: "Delete the file instead.",
364
+ detail: `object ${id} is stored`,
365
+ });
366
+ }
367
+ if (object.uploadId) await deps.store.abortMultipart(object.key, object.uploadId);
368
+ // A single-PUT upload may have landed whole even though it was never completed; drop those bytes
369
+ // too, so aborting never leaves an object the sweep has to find later.
370
+ await deps.store.delete(object.key);
371
+
372
+ return view(await saveObject(deps, { ...object, status: "failed", uploadId: null, updatedAt: deps.now() }));
373
+ }
374
+
375
+ /** Where a resuming client stands: what R2 already holds, and a live URL for everything it does not. */
376
+ export interface UploadPartsResult {
377
+ uploadId: string;
378
+ partSize: number;
379
+ partCount: number;
380
+ uploaded: UploadedPart[];
381
+ missing: UploadPartTarget[];
382
+ }
383
+
384
+ /**
385
+ * Resume a multipart upload: the parts R2 already holds, plus a **freshly presigned** URL for each
386
+ * one still missing.
387
+ *
388
+ * This is what makes multipart resumable at all. `initUpload` presigns every part once, and those
389
+ * URLs lapse an hour later — a 40 GiB upload is 640 of them, and a client that stalls or dies has no
390
+ * way back to the ones it never sent. Re-minting them here is the recovery path, and it is a route
391
+ * rather than a longer TTL because a presigned URL cannot be revoked: minting on demand keeps each
392
+ * one's life short and its issue authorized.
393
+ *
394
+ * Only while the row is `pending`. A `stored` row has no upload left to resume, and a `failed` one had
395
+ * its parts discarded — re-presigning against either would hand out URLs addressing nothing.
396
+ */
397
+ export async function listUploadParts(deps: HandlerDeps, id: string): Promise<UploadPartsResult> {
398
+ const object = await loadObject(deps, id);
399
+ assertOwner(object, deps.ownerId);
400
+ const uploadId = object.uploadId;
401
+ if (object.status !== "pending" || !uploadId) {
402
+ throw new StorageUploadIncompleteError({
403
+ message: "That upload has no parts to resume.",
404
+ action: "Only an in-flight multipart upload can be resumed. Start a new upload.",
405
+ detail: `object ${id} is ${object.status} with uploadId ${uploadId ?? "null"}`,
406
+ });
407
+ }
408
+
409
+ const plan = planMultipart(object.size ?? 0, deps.config.partSizeBytes);
410
+ const uploaded = await deps.store.listParts(object.key, uploadId);
411
+ const stored = new Set(uploaded.map((part) => part.partNumber));
412
+ const missing: UploadPartTarget[] = [];
413
+ for (const part of plan.parts) {
414
+ if (stored.has(part.partNumber)) continue;
415
+ missing.push({
416
+ partNumber: part.partNumber,
417
+ offset: part.offset,
418
+ length: part.length,
419
+ url: await deps.store.presignPart(object.key, uploadId, part.partNumber),
420
+ });
421
+ }
422
+
423
+ return { uploadId, partSize: plan.partSize, partCount: plan.partCount, uploaded, missing };
424
+ }
425
+
426
+ /** The keyset a list cursor encodes: the last row's path and id, which together are unique. */
427
+ interface ListCursor {
428
+ path: string;
429
+ id: string;
430
+ }
431
+
432
+ /**
433
+ * Encode a cursor. Keyset, not offset: an `OFFSET` page skips rows a concurrent upload shifted, so a
434
+ * client paging through their files while uploading would miss some. Base64url keeps it opaque, which
435
+ * is the point — the shape is ours to change.
436
+ */
437
+ function encodeCursor(cursor: ListCursor): string {
438
+ const json = JSON.stringify(cursor);
439
+ const bytes = new TextEncoder().encode(json);
440
+ const binary = String.fromCharCode(...bytes);
441
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
442
+ }
443
+
444
+ /** Decode a cursor, or `null` when it is not one we minted. A bad cursor starts from the beginning. */
445
+ function decodeCursor(value: string | undefined): ListCursor | null {
446
+ if (!value) return null;
447
+ try {
448
+ const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/"));
449
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
450
+ const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
451
+ if (typeof parsed !== "object" || parsed === null) return null;
452
+ const { path, id } = parsed as Partial<ListCursor>;
453
+ return typeof path === "string" && typeof id === "string" ? { path, id } : null;
454
+ } catch {
455
+ return null;
456
+ }
457
+ }
458
+
459
+ /** Default page size — generous enough for a file browser, small enough to stay one index read. */
460
+ const DEFAULT_LIST_LIMIT = 50;
461
+
462
+ /**
463
+ * One page of the caller's files, ordered by logical path. Scoped to the authenticated owner: there
464
+ * is no way to list another owner's files, and no parameter that could be made to.
465
+ */
466
+ export async function listObjects(
467
+ deps: HandlerDeps,
468
+ query: ListObjectsQuery,
469
+ ): Promise<{ objects: StorageObjectView[]; cursor?: string }> {
470
+ const limit = query.limit ?? DEFAULT_LIST_LIMIT;
471
+ const after = decodeCursor(query.cursor);
472
+
473
+ let statement = deps.db
474
+ .selectFrom(STORAGE_OBJECTS_TABLE)
475
+ .selectAll()
476
+ .where("ownerId", "=", deps.ownerId)
477
+ .orderBy("path", "asc")
478
+ .orderBy("id", "asc")
479
+ // One extra row is what distinguishes "this page is full" from "there is another page", without
480
+ // a second count query.
481
+ .limit(limit + 1);
482
+
483
+ if (query.prefix) {
484
+ // `LIKE` with an escaped prefix, so a `%` or `_` in an adopter's path is a literal character and
485
+ // not a wildcard that would widen the listing.
486
+ const escaped = query.prefix.replace(/[\\%_]/g, (character) => `\\${character}`);
487
+ statement = statement.where("path", "like", `${escaped}%`);
488
+ }
489
+ if (after) {
490
+ statement = statement.where((eb) =>
491
+ eb.or([eb("path", ">", after.path), eb.and([eb("path", "=", after.path), eb("id", ">", after.id)])]),
492
+ );
493
+ }
494
+
495
+ const rows = await statement.execute();
496
+ const page = rows.slice(0, limit).map((row) => StorageObject.parse(row));
497
+ const objects = page.map(view);
498
+ if (rows.length <= limit) return { objects };
499
+ const last = page[page.length - 1];
500
+ return last ? { objects, cursor: encodeCursor({ path: last.path, id: last.id }) } : { objects };
501
+ }
502
+
503
+ /** Load an object for reading, enforcing public-or-owner and that its bytes exist. */
504
+ export async function readableObject(deps: HandlerDeps, id: string): Promise<StorageObject> {
505
+ const object = await loadObject(deps, id);
506
+ assertReadable(object, deps.ownerId);
507
+ assertStored(object);
508
+ return object;
509
+ }
510
+
511
+ /** How long a direct-download URL stays valid. Short: it is bearer-equivalent and cannot be revoked. */
512
+ export const PRESIGNED_URL_TTL_SECONDS = 300;
513
+
514
+ /**
515
+ * Mint a presigned GET — the no-Worker-in-the-byte-path escape hatch.
516
+ *
517
+ * Authorization happens **here**, once, and then the URL is on its own: it cannot be revoked, and
518
+ * anyone holding it can read the bytes until it lapses. Five minutes is short enough that a URL
519
+ * pasted into a chat is dead before it is read, and long enough to start a large download.
520
+ */
521
+ export async function presignObject(deps: HandlerDeps, id: string): Promise<{ url: string; expiresInSeconds: number }> {
522
+ const object = await readableObject(deps, id);
523
+ const url = await deps.store.presignGet(object.key, { expiresIn: PRESIGNED_URL_TTL_SECONDS });
524
+ return { url, expiresInSeconds: PRESIGNED_URL_TTL_SECONDS };
525
+ }
526
+
527
+ /** Rename a file, change who may read it, or both. The key never moves — only the row changes. */
528
+ export async function updateObject(
529
+ deps: HandlerDeps,
530
+ id: string,
531
+ input: UpdateObjectInput,
532
+ ): Promise<StorageObjectView> {
533
+ const object = await loadObject(deps, id);
534
+ assertOwner(object, deps.ownerId);
535
+ return view(
536
+ await saveObject(deps, {
537
+ ...object,
538
+ path: input.path ?? object.path,
539
+ visibility: input.visibility ?? object.visibility,
540
+ updatedAt: deps.now(),
541
+ }),
542
+ );
543
+ }
544
+
545
+ /**
546
+ * Copy a file server-side. The bytes are copied by R2 and never enter the Worker; the copy is a new
547
+ * object with its own id, its own key, and its own share links — it is not an alias.
548
+ *
549
+ * Owner-scoped, like every other mutating route: you may copy your own files. Copying a *public* file
550
+ * you do not own is a plausible feature and deliberately not this one — it would let any reader
551
+ * duplicate bytes into their own quota on the owner's storage bill, which is a decision an adopter
552
+ * should make explicitly rather than inherit.
553
+ */
554
+ export async function copyObject(deps: HandlerDeps, id: string, input: CopyObjectInput): Promise<StorageObjectView> {
555
+ const source = await loadObject(deps, id);
556
+ assertOwner(source, deps.ownerId);
557
+ assertStored(source);
558
+
559
+ await assertWithinQuota({
560
+ db: deps.db,
561
+ ownerId: deps.ownerId,
562
+ limitBytes: deps.config.quota.bytesPerOwner,
563
+ additionalBytes: source.size ?? 0,
564
+ });
565
+
566
+ const key = deriveObjectKey();
567
+ await deps.store.copy(source.key, key);
568
+ const now = deps.now();
569
+ const copy: StorageObject = {
570
+ ...source,
571
+ id: deps.newId(),
572
+ key,
573
+ path: input.path,
574
+ ownerId: deps.ownerId,
575
+ // A copy starts private even when the source was public. Republishing is a decision, and a copy
576
+ // made to take a private working copy of your own public file should not re-publish it by default.
577
+ visibility: "private",
578
+ status: "stored",
579
+ uploadId: null,
580
+ createdAt: now,
581
+ updatedAt: now,
582
+ };
583
+ try {
584
+ // Same conditional insert as an upload init, for the same reason: a copy is bytes billed to an
585
+ // owner, and concurrent copies of one 1 GiB file against a 1 GiB quota must not all pass.
586
+ await insertReservingQuota({
587
+ db: deps.db,
588
+ ownerId: deps.ownerId,
589
+ limitBytes: deps.config.quota.bytesPerOwner,
590
+ additionalBytes: source.size ?? 0,
591
+ record: StorageObject.encode(copy),
592
+ });
593
+ } catch (error) {
594
+ // The row never landed, so nothing points at the copied bytes. Drop them rather than leave the
595
+ // sweep to find them.
596
+ await deps.store.delete(key).catch(() => {});
597
+ throw error;
598
+ }
599
+ return view(copy);
600
+ }
601
+
602
+ /**
603
+ * Delete a file: the object first, then the row. Every share link pointing at it goes with the row,
604
+ * through the foreign key's `ON DELETE CASCADE` — a share that outlived its object would be a live
605
+ * token for bytes that no longer exist.
606
+ *
607
+ * The object delete is best-effort. An R2 hiccup must not leave a row nobody can remove; the orphan
608
+ * sweep collects an object whose row went first.
609
+ */
610
+ export async function deleteObject(deps: HandlerDeps, id: string): Promise<{ id: string; deleted: true }> {
611
+ const object = await loadObject(deps, id);
612
+ assertOwner(object, deps.ownerId);
613
+ if (object.uploadId) await deps.store.abortMultipart(object.key, object.uploadId).catch(() => {});
614
+ await deps.store.delete(object.key).catch(() => {});
615
+ await deps.db.deleteFrom(STORAGE_OBJECTS_TABLE).where("id", "=", id).execute();
616
+ return { id, deleted: true };
617
+ }
618
+
619
+ /** Mint a revocable share link for one file. */
620
+ export async function createShare(deps: HandlerDeps, id: string, input: CreateShareInput): Promise<ShareView> {
621
+ const object = await loadObject(deps, id);
622
+ assertOwner(object, deps.ownerId);
623
+ assertStored(object);
624
+
625
+ const now = deps.now();
626
+ const share: StorageShare = {
627
+ token: deps.newToken(),
628
+ objectId: object.id,
629
+ expiresAt: input.expiresInSeconds ? new Date(now.getTime() + input.expiresInSeconds * 1000) : null,
630
+ revokedAt: null,
631
+ createdAt: now,
632
+ };
633
+ await deps.db.insertInto(STORAGE_SHARES_TABLE).values(StorageShare.encode(share)).execute();
634
+ return { token: share.token, objectId: share.objectId, expiresAt: share.expiresAt, createdAt: share.createdAt };
635
+ }
636
+
637
+ /**
638
+ * Withdraw a share link. A write, effective on the very next request — which is the whole reason a
639
+ * share is a row and not a presigned URL.
640
+ *
641
+ * Revoking twice is a no-op. The first revocation time is kept, because when a link stopped working
642
+ * is the fact anyone actually needs.
643
+ */
644
+ export async function revokeShare(deps: HandlerDeps, token: string): Promise<{ token: string; revokedAt: Date }> {
645
+ const row = await deps.db.selectFrom(STORAGE_SHARES_TABLE).selectAll().where("token", "=", token).executeTakeFirst();
646
+ if (!row) throw new StorageNotFoundError({ detail: `no share token ${token}` });
647
+ const share = StorageShare.parse(row);
648
+ const object = await loadObject(deps, share.objectId);
649
+ assertOwner(object, deps.ownerId);
650
+ if (share.revokedAt) return { token, revokedAt: share.revokedAt };
651
+
652
+ const revokedAt = deps.now();
653
+ await deps.db
654
+ .updateTable(STORAGE_SHARES_TABLE)
655
+ .set(StorageShare.encode({ ...share, revokedAt }))
656
+ .where("token", "=", token)
657
+ .execute();
658
+ return { token, revokedAt };
659
+ }
660
+
661
+ /**
662
+ * Resolve a share token to the object it grants read access to.
663
+ *
664
+ * Revoked and expired are **separate answers**, deliberately. Both are 410, but `storage/share_revoked`
665
+ * says the owner took the link back and `storage/share_expired` says it simply aged out — one is worth
666
+ * asking about, the other is worth re-requesting. Collapsing them into "gone" throws away the only
667
+ * information the holder can act on. Revocation is checked first: an explicit withdrawal is the more
668
+ * specific fact about a link that is both.
669
+ */
670
+ export async function resolveShare(deps: HandlerDeps, token: string): Promise<StorageObject> {
671
+ const row = await deps.db.selectFrom(STORAGE_SHARES_TABLE).selectAll().where("token", "=", token).executeTakeFirst();
672
+ if (!row) throw new StorageNotFoundError({ detail: `no share token ${token}` });
673
+ const share = StorageShare.parse(row);
674
+
675
+ if (share.revokedAt)
676
+ throw new StorageShareRevokedError({ detail: `share ${token} revoked at ${share.revokedAt.toISOString()}` });
677
+ if (share.expiresAt && share.expiresAt.getTime() <= deps.now().getTime()) {
678
+ throw new StorageShareExpiredError({ detail: `share ${token} expired at ${share.expiresAt.toISOString()}` });
679
+ }
680
+
681
+ const object = await loadObject(deps, share.objectId);
682
+ assertStored(object);
683
+ return object;
684
+ }