deepspace 0.19.4 → 0.20.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.
package/dist/worker.js CHANGED
@@ -6093,9 +6093,6 @@ var DANGEROUS_MIME_TYPES = /* @__PURE__ */ new Set([
6093
6093
  "application/ecmascript",
6094
6094
  "text/ecmascript"
6095
6095
  ]);
6096
- function activeContentMessage(mimeType) {
6097
- return `${mimeType} is not accepted: the files API serves your app's own origin, so stored HTML/SVG/JavaScript would execute there with your users' sessions. Ship it as a build asset under \`public/\` instead, where it is served as part of your app rather than as user-uploaded content.`;
6098
- }
6099
6096
  function formatBytes(bytes) {
6100
6097
  if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
6101
6098
  if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
@@ -6115,21 +6112,479 @@ function oversizePartMessage(bytes) {
6115
6112
  return `This part carries ${formatBytes(bytes)}; a part may carry ${formatBytes(UPLOAD_PART_BYTES)} \u2014 the size the upload advertised.`;
6116
6113
  }
6117
6114
 
6118
- // src/server/utils/scoped-r2-files.ts
6115
+ // src/server/utils/r2-storage-quota.ts
6119
6116
  var CORS_HEADERS3 = { "Access-Control-Allow-Origin": "*" };
6117
+ var QUOTA_SUMMARY_VERSION = 1;
6118
+ var MAX_QUOTA_SCAN_OBJECTS = 5e5;
6119
+ var MAX_QUOTA_SCAN_LISTS = 900;
6120
+ var QUOTA_REPAIR_COOLDOWN_MS = 6e4;
6121
+ var PENDING_MARKER_MAX_AGE_MS = 60 * 6e4;
6122
+ var EMPTY_MULTIPART_MARKER_MAX_AGE_MS = 60 * 6e4;
6123
+ var ACTIVE_MULTIPART_MARKER_MAX_AGE_MS = 8 * 24 * 60 * 6e4;
6120
6124
  function fail(status, error, code, extra) {
6121
6125
  return Response.json(
6122
6126
  { error, ...code ? { code } : {}, ...extra },
6123
6127
  { status, headers: CORS_HEADERS3 }
6124
6128
  );
6125
6129
  }
6126
- function contentDisposition(originalName) {
6130
+ function quotaUnavailable(message) {
6131
+ return fail(503, message, "storage_limit_unavailable");
6132
+ }
6133
+ function quotaKeySegment(value) {
6134
+ return encodeURIComponent(value);
6135
+ }
6136
+ function markerPrefix(storage, kind) {
6137
+ return `__deepspace/quota/${kind}/${quotaKeySegment(storage.quotaKey)}/`;
6138
+ }
6139
+ function pendingMarkerPrefix(storage) {
6140
+ return markerPrefix(storage, "pending");
6141
+ }
6142
+ function multipartMarkerPrefix(storage) {
6143
+ return markerPrefix(storage, "multipart");
6144
+ }
6145
+ function multipartMarkerKey(storage, reservationId) {
6146
+ return `${multipartMarkerPrefix(storage)}${quotaKeySegment(reservationId)}`;
6147
+ }
6148
+ function accountPrefixesEqual(left, right) {
6149
+ return left.length === right.length && left.every((prefix, index) => prefix === right[index]);
6150
+ }
6151
+ async function allocationPrefixes(storage) {
6152
+ const siblings = storage.siblingPrefixes ? await storage.siblingPrefixes() : [];
6153
+ if (siblings === null) return null;
6154
+ return [.../* @__PURE__ */ new Set([storage.prefix, ...siblings])].sort();
6155
+ }
6156
+ function countScan(budget, count) {
6157
+ budget.lists++;
6158
+ budget.objects += count;
6159
+ if (budget.lists > MAX_QUOTA_SCAN_LISTS || budget.objects > MAX_QUOTA_SCAN_OBJECTS) {
6160
+ throw new Error("quota scan work limit exceeded");
6161
+ }
6162
+ }
6163
+ async function prefixUsage(bucket, prefix, budget) {
6164
+ let total = 0;
6165
+ let cursor;
6166
+ do {
6167
+ const page = await bucket.list({ prefix, cursor, limit: 1e3 });
6168
+ countScan(budget, page.objects.length);
6169
+ for (const object of page.objects) total += object.size;
6170
+ cursor = page.truncated ? page.cursor : void 0;
6171
+ } while (cursor !== void 0);
6172
+ return total;
6173
+ }
6174
+ function markerBytes(object) {
6175
+ const raw = object.customMetadata?.reservedBytes;
6176
+ const bytes = raw === void 0 ? NaN : Number(raw);
6177
+ if (!Number.isSafeInteger(bytes) || bytes < 0) throw new Error("invalid quota marker");
6178
+ return bytes;
6179
+ }
6180
+ function markerMaxAge(object, fallback) {
6181
+ if (object.customMetadata?.quotaKind !== "multipart") return fallback;
6182
+ return object.customMetadata.active === "true" ? ACTIVE_MULTIPART_MARKER_MAX_AGE_MS : EMPTY_MULTIPART_MARKER_MAX_AGE_MS;
6183
+ }
6184
+ async function markerUsage(bucket, prefix, maxAgeMs, budget, excludedKey) {
6185
+ let total = 0;
6186
+ let cursor;
6187
+ const now = Date.now();
6188
+ do {
6189
+ const options = {
6190
+ prefix,
6191
+ cursor,
6192
+ limit: 1e3,
6193
+ include: ["customMetadata"]
6194
+ };
6195
+ const page = await bucket.list(options);
6196
+ countScan(budget, page.objects.length);
6197
+ for (const object of page.objects) {
6198
+ if (now - object.uploaded.getTime() > markerMaxAge(object, maxAgeMs)) {
6199
+ if (budget.staleKeys.length < 1e3) budget.staleKeys.push(object.key);
6200
+ } else if (object.key !== excludedKey) {
6201
+ total += markerBytes(object);
6202
+ }
6203
+ }
6204
+ cursor = page.truncated ? page.cursor : void 0;
6205
+ } while (cursor !== void 0);
6206
+ return total;
6207
+ }
6208
+ async function measuredAllocationUsage(bucket, storage, prefixes, excludedMarkerKey) {
6209
+ const budget = { lists: 0, objects: 0, staleKeys: [] };
6210
+ let total = 0;
6211
+ for (const prefix of prefixes) total += await prefixUsage(bucket, prefix, budget);
6212
+ total += await markerUsage(
6213
+ bucket,
6214
+ pendingMarkerPrefix(storage),
6215
+ PENDING_MARKER_MAX_AGE_MS,
6216
+ budget,
6217
+ excludedMarkerKey
6218
+ );
6219
+ total += await markerUsage(
6220
+ bucket,
6221
+ multipartMarkerPrefix(storage),
6222
+ ACTIVE_MULTIPART_MARKER_MAX_AGE_MS,
6223
+ budget,
6224
+ excludedMarkerKey
6225
+ );
6226
+ if (budget.staleKeys.length > 0) {
6227
+ await bucket.delete(budget.staleKeys).catch((error) => {
6228
+ console.error("[files:quota] stale marker cleanup:", error);
6229
+ });
6230
+ }
6231
+ return total;
6232
+ }
6233
+ function isQuotaSummary(value) {
6234
+ if (!value || typeof value !== "object") return false;
6235
+ const summary = value;
6236
+ return summary.version === QUOTA_SUMMARY_VERSION && Array.isArray(summary.prefixes) && summary.prefixes.every((prefix) => typeof prefix === "string") && Number.isSafeInteger(summary.usedBytes) && (summary.usedBytes ?? -1) >= 0 && Number.isSafeInteger(summary.measuredAt) && (summary.measuredAt ?? -1) >= 0 && typeof summary.dirty === "boolean";
6237
+ }
6238
+ async function readQuotaSummary(bucket, key) {
6239
+ const object = await bucket.get(key);
6240
+ if (!object) return { etag: null, summary: null };
6241
+ try {
6242
+ const parsed = await object.json();
6243
+ return { etag: object.etag, summary: isQuotaSummary(parsed) ? parsed : null };
6244
+ } catch {
6245
+ return { etag: object.etag, summary: null };
6246
+ }
6247
+ }
6248
+ async function writeQuotaSummary(bucket, key, previousEtag, summary) {
6249
+ const onlyIf = previousEtag ? { etagMatches: previousEtag } : new Headers({ "If-None-Match": "*" });
6250
+ const object = await bucket.put(key, JSON.stringify(summary), {
6251
+ onlyIf,
6252
+ httpMetadata: { contentType: "application/json" }
6253
+ });
6254
+ return object ? { etag: object.etag, summary } : null;
6255
+ }
6256
+ async function ensureQuotaSummary(bucket, storage, prefixes, forceMeasure = false, excludedMarkerKey) {
6257
+ for (let attempt = 0; attempt < 4; attempt++) {
6258
+ const current = await readQuotaSummary(bucket, storage.quotaKey);
6259
+ if (!forceMeasure && current.summary && accountPrefixesEqual(current.summary.prefixes, prefixes)) {
6260
+ return current;
6261
+ }
6262
+ const usedBytes = await measuredAllocationUsage(bucket, storage, prefixes, excludedMarkerKey);
6263
+ const repaired = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
6264
+ version: QUOTA_SUMMARY_VERSION,
6265
+ prefixes,
6266
+ usedBytes,
6267
+ measuredAt: Date.now(),
6268
+ dirty: false
6269
+ });
6270
+ if (repaired) return repaired;
6271
+ }
6272
+ return null;
6273
+ }
6274
+ async function markQuotaDirty(bucket, storage) {
6275
+ if (!storage) return;
6276
+ try {
6277
+ for (let attempt = 0; attempt < 4; attempt++) {
6278
+ const current = await readQuotaSummary(bucket, storage.quotaKey);
6279
+ if (!current.summary || current.summary.dirty) return;
6280
+ const written = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
6281
+ ...current.summary,
6282
+ dirty: true
6283
+ });
6284
+ if (written) return;
6285
+ }
6286
+ console.error("[files:quota] could not mark summary reclaimable after concurrent writes");
6287
+ } catch (error) {
6288
+ console.error("[files:quota] could not mark summary reclaimable:", error);
6289
+ }
6290
+ }
6291
+ async function neutralizeMarker(bucket, markerKey) {
6292
+ for (let attempt = 0; attempt < 4; attempt++) {
6293
+ const marker = await bucket.get(markerKey);
6294
+ if (!marker) return true;
6295
+ if (markerBytes(marker) === 0) return true;
6296
+ const body = await marker.arrayBuffer();
6297
+ const object = await bucket.put(markerKey, body, {
6298
+ onlyIf: { etagMatches: marker.etag },
6299
+ customMetadata: {
6300
+ ...marker.customMetadata,
6301
+ reservedBytes: "0",
6302
+ releasedAt: String(Date.now())
6303
+ }
6304
+ });
6305
+ if (object) return true;
6306
+ }
6307
+ return false;
6308
+ }
6309
+ async function releaseQuotaMarker(bucket, storage, markerKey, summaryMayBeStale) {
6310
+ try {
6311
+ if (markerKey && !await neutralizeMarker(bucket, markerKey)) {
6312
+ console.error("[files:quota] could not neutralize reservation marker");
6313
+ return false;
6314
+ }
6315
+ if (summaryMayBeStale) await markQuotaDirty(bucket, storage);
6316
+ if (markerKey) {
6317
+ for (let attempt = 0; attempt < 4; attempt++) {
6318
+ try {
6319
+ await bucket.delete(markerKey);
6320
+ break;
6321
+ } catch (error) {
6322
+ if (attempt === 3) throw error;
6323
+ }
6324
+ }
6325
+ }
6326
+ return true;
6327
+ } catch (error) {
6328
+ console.error("[files:quota] reservation cleanup:", error);
6329
+ return false;
6330
+ }
6331
+ }
6332
+ function parseMultipartMarker(value) {
6333
+ if (!value || typeof value !== "object") return null;
6334
+ const marker = value;
6335
+ if (marker.version !== 1 || typeof marker.reservationId !== "string" || !marker.reservationId || typeof marker.uploadId !== "string" || !marker.uploadId || typeof marker.objectKey !== "string" || !marker.objectKey || typeof marker.uploadKey !== "string" || !marker.uploadKey || typeof marker.admitted !== "boolean" || !Number.isSafeInteger(marker.declaredBytes) || (marker.declaredBytes ?? -1) < 0) {
6336
+ return null;
6337
+ }
6338
+ return marker;
6339
+ }
6340
+ function multipartMarkerBody(reservation) {
6341
+ return JSON.stringify({
6342
+ version: 1,
6343
+ reservationId: reservation.reservationId,
6344
+ uploadId: reservation.uploadId,
6345
+ objectKey: reservation.objectKey,
6346
+ uploadKey: reservation.uploadKey,
6347
+ admitted: reservation.admitted,
6348
+ declaredBytes: reservation.declaredBytes
6349
+ });
6350
+ }
6351
+ async function readMultipartReservation(bucket, storage, reservationId) {
6352
+ if (!storage) return null;
6353
+ const key = multipartMarkerKey(storage, reservationId);
6354
+ let object;
6355
+ try {
6356
+ object = await bucket.get(key);
6357
+ } catch (error) {
6358
+ console.error("[files:quota] reservation read:", error);
6359
+ return "invalid";
6360
+ }
6361
+ if (!object) return null;
6362
+ let marker = null;
6363
+ try {
6364
+ marker = parseMultipartMarker(await object.json());
6365
+ } catch {
6366
+ return "invalid";
6367
+ }
6368
+ if (!marker || marker.reservationId !== reservationId) return "invalid";
6369
+ let reservedBytes;
6370
+ try {
6371
+ reservedBytes = markerBytes(object);
6372
+ } catch {
6373
+ return "invalid";
6374
+ }
6375
+ return {
6376
+ key,
6377
+ etag: object.etag,
6378
+ reservationId,
6379
+ uploadId: marker.uploadId,
6380
+ objectKey: marker.objectKey,
6381
+ uploadKey: marker.uploadKey,
6382
+ admitted: marker.admitted,
6383
+ declaredBytes: marker.declaredBytes,
6384
+ reservedBytes
6385
+ };
6386
+ }
6387
+ async function refreshMultipartReservation(bucket, storage, reservationId, uploadId, objectKey) {
6388
+ if (!storage) return null;
6389
+ for (let attempt = 0; attempt < 4; attempt++) {
6390
+ const reservation = await readMultipartReservation(bucket, storage, reservationId);
6391
+ if (reservation === null || reservation === "invalid") return reservation ?? "invalid";
6392
+ if (!reservation.admitted || reservation.uploadId !== uploadId || reservation.objectKey !== objectKey) {
6393
+ return "invalid";
6394
+ }
6395
+ const object = await bucket.put(reservation.key, multipartMarkerBody(reservation), {
6396
+ onlyIf: { etagMatches: reservation.etag },
6397
+ customMetadata: {
6398
+ quotaKind: "multipart",
6399
+ reservedBytes: String(reservation.reservedBytes),
6400
+ active: "true"
6401
+ }
6402
+ });
6403
+ if (object) return { ...reservation, etag: object.etag };
6404
+ }
6405
+ return "busy";
6406
+ }
6407
+ async function activateMultipartReservation(bucket, storage, reservationId) {
6408
+ if (!storage) return null;
6409
+ for (let attempt = 0; attempt < 4; attempt++) {
6410
+ const reservation = await readMultipartReservation(bucket, storage, reservationId);
6411
+ if (!reservation || reservation === "invalid") return null;
6412
+ if (reservation.admitted) return reservation;
6413
+ const next = { ...reservation, admitted: true };
6414
+ const object = await bucket.put(reservation.key, multipartMarkerBody(next), {
6415
+ onlyIf: { etagMatches: reservation.etag },
6416
+ customMetadata: {
6417
+ quotaKind: "multipart",
6418
+ reservedBytes: String(reservation.reservedBytes),
6419
+ active: "false"
6420
+ }
6421
+ });
6422
+ if (object) {
6423
+ return { ...next, etag: object.etag };
6424
+ }
6425
+ }
6426
+ return null;
6427
+ }
6428
+ function quotaExceeded(incomingBytes, summaryBytes, replacedBytes, limitBytes) {
6429
+ const usedBytes = Math.max(0, summaryBytes - replacedBytes);
6430
+ return fail(
6431
+ 409,
6432
+ storageQuotaMessage(incomingBytes, usedBytes, limitBytes),
6433
+ "storage_quota_exceeded",
6434
+ { usedBytes, limitBytes }
6435
+ );
6436
+ }
6437
+ async function reserveQuotaSummary(bucket, storage, prefixes, initial, chargeBytes, incomingBytes, replacedBytes, limitBytes, markerKey) {
6438
+ if (chargeBytes === 0) return null;
6439
+ let current = initial;
6440
+ let repaired = false;
6441
+ for (let attempt = 0; attempt < 8; attempt++) {
6442
+ if (!current.summary || !accountPrefixesEqual(current.summary.prefixes, prefixes)) {
6443
+ const ensured = await ensureQuotaSummary(
6444
+ bucket,
6445
+ storage,
6446
+ prefixes,
6447
+ false,
6448
+ markerKey ?? void 0
6449
+ );
6450
+ if (!ensured) {
6451
+ return quotaUnavailable(
6452
+ "The storage used by this account could not be verified. Retry shortly."
6453
+ );
6454
+ }
6455
+ current = ensured;
6456
+ }
6457
+ const summary = current.summary;
6458
+ if (!summary) {
6459
+ return quotaUnavailable(
6460
+ "The storage used by this account could not be verified. Retry shortly."
6461
+ );
6462
+ }
6463
+ if (summary.usedBytes + chargeBytes > limitBytes) {
6464
+ const recentlyMeasured = Date.now() - summary.measuredAt < QUOTA_REPAIR_COOLDOWN_MS;
6465
+ if (repaired || !summary.dirty && recentlyMeasured) {
6466
+ return quotaExceeded(incomingBytes, summary.usedBytes, replacedBytes, limitBytes);
6467
+ }
6468
+ const refreshed = await ensureQuotaSummary(
6469
+ bucket,
6470
+ storage,
6471
+ prefixes,
6472
+ true,
6473
+ markerKey ?? void 0
6474
+ );
6475
+ if (!refreshed?.summary) {
6476
+ return quotaUnavailable(
6477
+ "The storage used by this account could not be verified. Retry shortly."
6478
+ );
6479
+ }
6480
+ current = refreshed;
6481
+ repaired = true;
6482
+ continue;
6483
+ }
6484
+ const written = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
6485
+ ...summary,
6486
+ usedBytes: summary.usedBytes + chargeBytes
6487
+ });
6488
+ if (written) return null;
6489
+ current = await readQuotaSummary(bucket, storage.quotaKey);
6490
+ }
6491
+ return quotaUnavailable("Storage is busy with other uploads. Retry shortly.");
6492
+ }
6493
+ async function admitStorage(bucket, storage, incomingBytes, replacedBytes, marker) {
6494
+ if (!storage) return { markerKey: null };
6495
+ let markerKey = null;
6496
+ try {
6497
+ const limit = await storage.limitBytes();
6498
+ if (limit === null) {
6499
+ return quotaUnavailable(
6500
+ "The storage limit for this app could not be verified; nothing was uploaded. Retry shortly."
6501
+ );
6502
+ }
6503
+ const prefixes = await allocationPrefixes(storage);
6504
+ if (prefixes === null) {
6505
+ return quotaUnavailable(
6506
+ "The apps in this storage allocation could not be enumerated; nothing was uploaded. Retry shortly."
6507
+ );
6508
+ }
6509
+ const initial = await ensureQuotaSummary(bucket, storage, prefixes);
6510
+ if (!initial) {
6511
+ return quotaUnavailable(
6512
+ "The storage already used by this account could not be measured; nothing was uploaded. Retry shortly."
6513
+ );
6514
+ }
6515
+ const chargeBytes = Math.max(0, incomingBytes - replacedBytes);
6516
+ const effectiveMarker = marker ?? (chargeBytes > 0 ? {
6517
+ key: `${pendingMarkerPrefix(storage)}${crypto.randomUUID()}`,
6518
+ metadata: { quotaKind: "pending" }
6519
+ } : null);
6520
+ if (effectiveMarker) {
6521
+ const object = await bucket.put(effectiveMarker.key, effectiveMarker.body ?? "", {
6522
+ onlyIf: new Headers({ "If-None-Match": "*" }),
6523
+ customMetadata: {
6524
+ ...effectiveMarker.metadata,
6525
+ reservedBytes: String(chargeBytes)
6526
+ }
6527
+ });
6528
+ if (!object) {
6529
+ return fail(
6530
+ 409,
6531
+ "An upload with this request id is already in progress.",
6532
+ "reservation_conflict"
6533
+ );
6534
+ }
6535
+ markerKey = effectiveMarker.key;
6536
+ }
6537
+ const refusal = await reserveQuotaSummary(
6538
+ bucket,
6539
+ storage,
6540
+ prefixes,
6541
+ initial,
6542
+ chargeBytes,
6543
+ incomingBytes,
6544
+ replacedBytes,
6545
+ limit,
6546
+ effectiveMarker?.key ?? null
6547
+ );
6548
+ if (refusal) {
6549
+ await releaseQuotaMarker(bucket, storage, effectiveMarker?.key ?? null, false);
6550
+ return refusal;
6551
+ }
6552
+ return { markerKey: effectiveMarker?.key ?? null };
6553
+ } catch (error) {
6554
+ console.error("[files:quota] admission:", error);
6555
+ await releaseQuotaMarker(bucket, storage, markerKey, true);
6556
+ return quotaUnavailable(
6557
+ "The storage used by this account could not be verified. Retry shortly."
6558
+ );
6559
+ }
6560
+ }
6561
+ async function storageUsage(bucket, storage) {
6562
+ try {
6563
+ const prefixes = await allocationPrefixes(storage);
6564
+ if (prefixes === null) return null;
6565
+ const current = await ensureQuotaSummary(bucket, storage, prefixes);
6566
+ return current?.summary?.usedBytes ?? null;
6567
+ } catch (error) {
6568
+ console.error("[files:quota] usage:", error);
6569
+ return null;
6570
+ }
6571
+ }
6572
+
6573
+ // src/server/utils/scoped-r2-files.ts
6574
+ var CORS_HEADERS4 = { "Access-Control-Allow-Origin": "*" };
6575
+ function fail2(status, error, code, extra) {
6576
+ return Response.json(
6577
+ { error, ...code ? { code } : {}, ...extra },
6578
+ { status, headers: CORS_HEADERS4 }
6579
+ );
6580
+ }
6581
+ function contentDisposition(originalName, disposition) {
6127
6582
  const safe = originalName.replace(/[^\x20-\x7e]|["\\]/g, "_") || "download";
6128
6583
  const encoded = encodeURIComponent(originalName).replace(
6129
6584
  /[!'()*]/g,
6130
6585
  (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
6131
6586
  );
6132
- return `inline; filename="${safe}"; filename*=UTF-8''${encoded}`;
6587
+ return `${disposition}; filename="${safe}"; filename*=UTF-8''${encoded}`;
6133
6588
  }
6134
6589
  function sanitizeSubpath(raw) {
6135
6590
  if (!raw) return "";
@@ -6139,24 +6594,24 @@ function sanitizeSubpath(raw) {
6139
6594
  }
6140
6595
  function overRequestBound(bytes, slack = 0) {
6141
6596
  if (bytes <= MAX_UPLOAD_REQUEST_BYTES + slack) return null;
6142
- return fail(413, oversizeRequestMessage(bytes), "too_large", {
6597
+ return fail2(413, oversizeRequestMessage(bytes), "too_large", {
6143
6598
  maxBytes: MAX_UPLOAD_REQUEST_BYTES,
6144
6599
  partSize: UPLOAD_PART_BYTES
6145
6600
  });
6146
6601
  }
6147
6602
  function overCeiling(bytes) {
6148
6603
  if (bytes <= MAX_APP_FILE_BYTES) return null;
6149
- return fail(413, oversizeMessage(bytes), "too_large", { maxBytes: MAX_APP_FILE_BYTES });
6604
+ return fail2(413, oversizeMessage(bytes), "too_large", { maxBytes: MAX_APP_FILE_BYTES });
6150
6605
  }
6151
6606
  function overPartBound(bytes) {
6152
6607
  if (bytes <= UPLOAD_PART_BYTES) return null;
6153
- return fail(413, oversizePartMessage(bytes), "too_large", { maxBytes: UPLOAD_PART_BYTES });
6608
+ return fail2(413, oversizePartMessage(bytes), "too_large", { maxBytes: UPLOAD_PART_BYTES });
6154
6609
  }
6155
6610
  function declaredLength(request, what) {
6156
6611
  const raw = request.headers.get("content-length");
6157
6612
  const declared = raw === null ? NaN : Number(raw);
6158
6613
  if (!Number.isSafeInteger(declared) || declared < 0) {
6159
- return fail(
6614
+ return fail2(
6160
6615
  411,
6161
6616
  `${what} must declare a Content-Length. A chunked or length-less body is refused: R2 cannot size a write it has no length for.`,
6162
6617
  "length_required"
@@ -6164,78 +6619,15 @@ function declaredLength(request, what) {
6164
6619
  }
6165
6620
  return declared;
6166
6621
  }
6167
- function isKeyWithinPrefix(key, prefix) {
6168
- return key.startsWith(prefix);
6169
- }
6170
- async function prefixUsage(bucket, prefix) {
6171
- let total = 0;
6172
- let cursor;
6173
- do {
6174
- const page = await bucket.list({ prefix, cursor, limit: 1e3 });
6175
- for (const object of page.objects) total += object.size;
6176
- cursor = page.truncated ? page.cursor : void 0;
6177
- } while (cursor !== void 0);
6178
- return total;
6179
- }
6180
- async function allocationUsage(bucket, storage) {
6181
- const siblings = storage.siblingPrefixes ? await storage.siblingPrefixes() : [];
6182
- if (siblings === null) return null;
6183
- const prefixes = [storage.prefix, ...siblings.filter((p) => p !== storage.prefix)];
6184
- const totals = await Promise.all(prefixes.map((prefix) => prefixUsage(bucket, prefix)));
6185
- return totals.reduce((sum, bytes) => sum + bytes, 0);
6186
- }
6187
- async function admitStorage(bucket, storage, incomingBytes, replacesKey) {
6188
- if (!storage) return null;
6189
- const limit = await storage.limitBytes();
6190
- if (limit === null) {
6191
- return fail(
6192
- 503,
6193
- "The storage limit for this app could not be verified; nothing was uploaded. Retry shortly.",
6194
- "storage_limit_unavailable"
6195
- );
6196
- }
6197
- const stored = await allocationUsage(bucket, storage);
6198
- if (stored === null) {
6199
- return fail(
6200
- 503,
6201
- "The storage already used by this account could not be measured; nothing was uploaded. Retry shortly.",
6202
- "storage_limit_unavailable"
6203
- );
6204
- }
6205
- const replaced = replacesKey === null ? 0 : (await bucket.head(replacesKey))?.size ?? 0;
6206
- const used = stored - replaced;
6207
- if (used + incomingBytes <= limit) return null;
6208
- return fail(409, storageQuotaMessage(incomingBytes, used, limit), "storage_quota_exceeded", {
6209
- usedBytes: used,
6210
- limitBytes: limit
6211
- });
6212
- }
6213
- async function admitAssembledObject(bucket, storage, key, objectBytes) {
6214
- if (!storage) return null;
6215
- const limit = await storage.limitBytes();
6216
- if (limit === null) {
6217
- console.error("[files:quota] limit unavailable at complete; keeping", key);
6218
- return null;
6219
- }
6220
- const stored = await allocationUsage(bucket, storage);
6221
- if (stored === null) {
6222
- console.error("[files:quota] account usage unmeasurable at complete; keeping", key);
6223
- return null;
6224
- }
6225
- if (stored <= limit) return null;
6226
- await bucket.delete(key).catch(() => void 0);
6227
- return fail(
6228
- 409,
6229
- `${storageQuotaMessage(objectBytes, stored - objectBytes, limit)} The assembled upload was not kept.`,
6230
- "storage_quota_exceeded",
6231
- { usedBytes: stored - objectBytes, limitBytes: limit }
6232
- );
6622
+ function isKeyWithinScope(key, prefix, excludedPrefixes) {
6623
+ return key.startsWith(prefix) && !excludedPrefixes.some((excluded) => key.startsWith(excluded));
6233
6624
  }
6234
- function anchorKey(raw, prefix) {
6625
+ function anchorKey(raw, prefix, excludedPrefixes) {
6235
6626
  const safe = sanitizeSubpath(raw);
6236
- if (safe === null) return fail(400, "Invalid key: traversal not allowed", "invalid_key");
6237
- if (!safe) return fail(400, "Invalid key: empty", "invalid_key");
6238
- return { key: `${prefix}${safe}` };
6627
+ if (safe === null) return fail2(400, "Invalid key: traversal not allowed", "invalid_key");
6628
+ if (!safe) return fail2(400, "Invalid key: empty", "invalid_key");
6629
+ const key = `${prefix}${safe}`;
6630
+ return isKeyWithinScope(key, prefix, excludedPrefixes) ? { key } : fail2(403, "Access denied: key belongs to a private scope", "forbidden");
6239
6631
  }
6240
6632
  var VERB_ROUTES = /* @__PURE__ */ new Set([
6241
6633
  "POST upload",
@@ -6249,7 +6641,7 @@ function isFilesVerbPath(method, subpath) {
6249
6641
  }
6250
6642
  function resolveMimeType(raw) {
6251
6643
  const mimeType = raw.split(";")[0].trim().toLowerCase() || "application/octet-stream";
6252
- return DANGEROUS_MIME_TYPES.has(mimeType) ? fail(415, activeContentMessage(mimeType), "active_content") : { mimeType };
6644
+ return mimeType;
6253
6645
  }
6254
6646
  function r2ErrorCode(error) {
6255
6647
  const message = error instanceof Error ? error.message : String(error);
@@ -6259,7 +6651,7 @@ function r2ErrorCode(error) {
6259
6651
  function multipartFailure(error) {
6260
6652
  switch (r2ErrorCode(error)) {
6261
6653
  case 10024:
6262
- return fail(
6654
+ return fail2(
6263
6655
  404,
6264
6656
  "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
6265
6657
  "unknown_upload"
@@ -6269,7 +6661,7 @@ function multipartFailure(error) {
6269
6661
  case 10025:
6270
6662
  // InvalidPart — a named part was never uploaded
6271
6663
  case 10048:
6272
- return fail(
6664
+ return fail2(
6273
6665
  400,
6274
6666
  "The parts do not assemble: every part except the last must be exactly the size the upload advertised, and each must be uploaded before it is named.",
6275
6667
  "bad_parts"
@@ -6277,11 +6669,15 @@ function multipartFailure(error) {
6277
6669
  case 10012:
6278
6670
  // MetadataTooLarge
6279
6671
  case 10020:
6280
- return fail(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
6672
+ return fail2(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
6281
6673
  default: {
6282
6674
  const message = error instanceof Error ? error.message : String(error);
6283
6675
  console.error("[files:multipart] R2 error:", message);
6284
- return fail(503, "The files service could not complete that upload. Retry shortly.", "r2_error");
6676
+ return fail2(
6677
+ 503,
6678
+ "The files service could not complete that upload. Retry shortly.",
6679
+ "r2_error"
6680
+ );
6285
6681
  }
6286
6682
  }
6287
6683
  }
@@ -6294,73 +6690,79 @@ function createScopedR2Handler(config) {
6294
6690
  } catch {
6295
6691
  return Response.json(
6296
6692
  { error: "Invalid path encoding" },
6297
- { status: 400, headers: CORS_HEADERS3 }
6693
+ { status: 400, headers: CORS_HEADERS4 }
6298
6694
  );
6299
6695
  }
6300
6696
  const subpath = sanitizeSubpath(subpathRaw);
6301
6697
  if (subpath === null) {
6302
6698
  return Response.json(
6303
6699
  { error: "Invalid path: traversal not allowed" },
6304
- { status: 400, headers: CORS_HEADERS3 }
6700
+ { status: 400, headers: CORS_HEADERS4 }
6305
6701
  );
6306
6702
  }
6307
6703
  const scope = url.searchParams.get("scope") || "self";
6308
6704
  if (scope !== "self" && scope !== "app") {
6309
- return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS3 });
6705
+ return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS4 });
6310
6706
  }
6311
6707
  const result = config.resolvePrefix(scope, { userId: auth.userId, url });
6312
6708
  if (!result.prefix) {
6313
6709
  const errorMsg = result.error ?? "Invalid scope";
6314
6710
  const status = errorMsg.toLowerCase().includes("auth") ? 401 : 400;
6315
- return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS3 });
6711
+ return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS4 });
6316
6712
  }
6317
6713
  const prefix = result.prefix;
6714
+ const excludedPrefixes = result.excludedPrefixes ?? [];
6318
6715
  if (isFilesVerbPath(request.method, subpath)) {
6319
6716
  if (requireAuthForMutations && !auth.userId) {
6320
6717
  await request.body?.cancel().catch(() => void 0);
6321
- return fail(401, "Authentication required", "unauthorized");
6718
+ return fail2(401, "Authentication required", "unauthorized");
6322
6719
  }
6323
- return subpath === "upload" ? handleUpload(request, url, bucket, prefix, auth, scope) : handleMultipart(request, url, bucket, prefix, auth, scope, subpath);
6720
+ return subpath === "upload" ? handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) : handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath);
6324
6721
  }
6325
6722
  if (!subpath && request.method === "GET") {
6326
- return handleList(bucket, prefix, url, scope, auth);
6723
+ return handleList(bucket, prefix, excludedPrefixes, url, scope, auth);
6327
6724
  }
6328
6725
  if (subpath && request.method === "GET") {
6329
- if (!isKeyWithinPrefix(subpath, prefix)) {
6726
+ if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
6330
6727
  return Response.json(
6331
6728
  { error: "Access denied: key outside scope" },
6332
- { status: 403, headers: CORS_HEADERS3 }
6729
+ { status: 403, headers: CORS_HEADERS4 }
6333
6730
  );
6334
6731
  }
6335
- return handleDownload(bucket, subpath, scope);
6732
+ return handleDownload(request, bucket, subpath, scope);
6336
6733
  }
6337
6734
  if (subpath && request.method === "DELETE") {
6338
6735
  if (requireAuthForMutations && !auth.userId) {
6339
6736
  return Response.json(
6340
6737
  { error: "Authentication required" },
6341
- { status: 401, headers: CORS_HEADERS3 }
6738
+ { status: 401, headers: CORS_HEADERS4 }
6342
6739
  );
6343
6740
  }
6344
- if (!isKeyWithinPrefix(subpath, prefix)) {
6741
+ if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
6345
6742
  return Response.json(
6346
6743
  { error: "Access denied: key outside scope" },
6347
- { status: 403, headers: CORS_HEADERS3 }
6744
+ { status: 403, headers: CORS_HEADERS4 }
6348
6745
  );
6349
6746
  }
6350
6747
  const existed = await bucket.head(subpath) !== null;
6351
- if (existed) await bucket.delete(subpath);
6748
+ if (existed) {
6749
+ await bucket.delete(subpath);
6750
+ await markQuotaDirty(bucket, auth.storage);
6751
+ }
6352
6752
  return Response.json(
6353
6753
  { success: true, deleted: existed ? subpath : null, existed },
6354
- { headers: CORS_HEADERS3 }
6754
+ { headers: CORS_HEADERS4 }
6355
6755
  );
6356
6756
  }
6357
- return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS3 });
6757
+ return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS4 });
6358
6758
  };
6359
6759
  }
6360
- async function handleUpload(request, url, bucket, prefix, auth, scope) {
6760
+ async function handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
6361
6761
  try {
6362
- const declared = overRequestBound(Number(request.headers.get("content-length")), 8 * 1024);
6363
- if (declared) return declared;
6762
+ const declaredBytes = declaredLength(request, "File uploads");
6763
+ if (declaredBytes instanceof Response) return declaredBytes;
6764
+ const tooLargeRequest = overRequestBound(declaredBytes, 8 * 1024);
6765
+ if (tooLargeRequest) return tooLargeRequest;
6364
6766
  const contentType = request.headers.get("content-type") || "";
6365
6767
  let fileData;
6366
6768
  let fileName;
@@ -6368,7 +6770,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
6368
6770
  if (contentType.includes("multipart/form-data")) {
6369
6771
  const formData = await request.formData();
6370
6772
  const file = formData.get("file");
6371
- if (!file) return fail(400, "No file provided", "bad_request");
6773
+ if (!file) return fail2(400, "No file provided", "bad_request");
6372
6774
  const tooBig = overRequestBound(file.size);
6373
6775
  if (tooBig) return tooBig;
6374
6776
  fileData = await file.arrayBuffer();
@@ -6376,7 +6778,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
6376
6778
  rawMimeType = file.type || "application/octet-stream";
6377
6779
  } else {
6378
6780
  const body = await request.json();
6379
- if (!body.data || !body.name) return fail(400, "Missing data or name", "bad_request");
6781
+ if (!body.data || !body.name) return fail2(400, "Missing data or name", "bad_request");
6380
6782
  const base64Data = body.data.replace(/^data:[^;]+;base64,/, "");
6381
6783
  const tooBig = overRequestBound(Math.floor(base64Data.length * 3 / 4));
6382
6784
  if (tooBig) return tooBig;
@@ -6384,34 +6786,60 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
6384
6786
  fileName = body.name;
6385
6787
  rawMimeType = body.mimeType || "application/octet-stream";
6386
6788
  }
6387
- const mime = resolveMimeType(rawMimeType);
6388
- if (mime instanceof Response) return mime;
6389
- const located = locateUpload(url, prefix, fileName);
6789
+ const mimeType = resolveMimeType(rawMimeType);
6790
+ const located = locateUpload(url, prefix, excludedPrefixes, fileName);
6390
6791
  if (located instanceof Response) return located;
6391
6792
  const replacesKey = url.searchParams.get("key") === null ? null : located.key;
6392
- const overQuota = await admitStorage(bucket, auth.storage, fileData.byteLength, replacesKey);
6393
- if (overQuota) return overQuota;
6394
- await bucket.put(located.key, fileData, {
6395
- httpMetadata: { contentType: mime.mimeType },
6396
- customMetadata: fileMetadata(fileName, auth.userId)
6397
- });
6793
+ const replaced = replacesKey === null ? null : await bucket.head(replacesKey);
6794
+ const replacedBytes = replaced?.size ?? 0;
6795
+ const admission = await admitStorage(bucket, auth.storage, fileData.byteLength, replacedBytes);
6796
+ if (admission instanceof Response) return admission;
6797
+ try {
6798
+ const written = await bucket.put(located.key, fileData, {
6799
+ // Replacement credit is valid only while the exact object that earned
6800
+ // it still exists. A concurrent delete or overwrite loses this CAS and
6801
+ // retries from a fresh head instead of landing uncounted bytes.
6802
+ ...replaced ? { onlyIf: { etagMatches: replaced.etag } } : {},
6803
+ httpMetadata: { contentType: mimeType },
6804
+ customMetadata: fileMetadata(fileName, auth.userId)
6805
+ });
6806
+ if (!written) {
6807
+ await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
6808
+ return fail2(
6809
+ 409,
6810
+ "The file changed while this upload was being admitted. Retry the upload.",
6811
+ "write_conflict"
6812
+ );
6813
+ }
6814
+ } catch (error) {
6815
+ await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
6816
+ throw error;
6817
+ }
6818
+ await releaseQuotaMarker(
6819
+ bucket,
6820
+ auth.storage,
6821
+ admission.markerKey,
6822
+ admission.markerKey !== null || fileData.byteLength < replacedBytes
6823
+ );
6398
6824
  return storedFileJson(located.key, fileName, url, scope);
6399
6825
  } catch (err) {
6400
6826
  const msg = err instanceof Error ? err.message : "Upload failed";
6401
6827
  console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
6402
- return fail(500, msg, "upload_failed");
6828
+ return fail2(500, msg, "upload_failed");
6403
6829
  }
6404
6830
  }
6405
- function locateUpload(url, prefix, fileName) {
6831
+ function locateUpload(url, prefix, excludedPrefixes, fileName, generatedId) {
6406
6832
  const requested = url.searchParams.get("key");
6407
- const subkey = requested ?? `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`;
6408
- const anchored = anchorKey(subkey, prefix);
6833
+ const subkey = requested ?? (generatedId ? `${generatedId}-${fileName}` : `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`);
6834
+ const anchored = anchorKey(subkey, prefix, excludedPrefixes);
6409
6835
  return anchored instanceof Response ? anchored : { key: anchored.key, subkey };
6410
6836
  }
6411
- function fileMetadata(fileName, userId) {
6837
+ function fileMetadata(fileName, userId, reservationId, declaredBytes) {
6412
6838
  return {
6413
6839
  originalName: fileName,
6414
6840
  ...userId ? { uploadedBy: userId } : {},
6841
+ ...reservationId ? { reservationId } : {},
6842
+ ...declaredBytes === void 0 ? {} : { declaredBytes: String(declaredBytes) },
6415
6843
  uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
6416
6844
  };
6417
6845
  }
@@ -6420,7 +6848,7 @@ function storedFileJson(key, name, url, scope) {
6420
6848
  fileUrl.searchParams.set("scope", scope);
6421
6849
  return Response.json(
6422
6850
  { success: true, key, url: fileUrl.toString(), name },
6423
- { headers: CORS_HEADERS3 }
6851
+ { headers: CORS_HEADERS4 }
6424
6852
  );
6425
6853
  }
6426
6854
  var MAX_CONTROL_BODY_BYTES = 64 * 1024;
@@ -6428,24 +6856,61 @@ async function readControlJson(request) {
6428
6856
  const declared = declaredLength(request, "This request");
6429
6857
  if (declared instanceof Response) return declared;
6430
6858
  if (declared > MAX_CONTROL_BODY_BYTES) {
6431
- return fail(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
6859
+ return fail2(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
6432
6860
  }
6433
6861
  let parsed;
6434
6862
  try {
6435
6863
  parsed = await request.json();
6436
6864
  } catch {
6437
- return fail(400, "Body must be JSON", "bad_request");
6865
+ return fail2(400, "Body must be JSON", "bad_request");
6438
6866
  }
6439
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail(400, "Body must be a JSON object", "bad_request");
6867
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail2(400, "Body must be a JSON object", "bad_request");
6440
6868
  }
6441
- function resolveSession(url, prefix) {
6869
+ var REQUEST_ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
6870
+ function resolveSession(url, prefix, excludedPrefixes) {
6442
6871
  const uploadKey = url.searchParams.get("uploadKey");
6443
6872
  const uploadId = url.searchParams.get("uploadId");
6444
- if (!uploadKey || !uploadId) {
6445
- return fail(400, "uploadKey and uploadId are required", "bad_request");
6873
+ const reservationId = url.searchParams.get("reservationId");
6874
+ if (!uploadKey || !uploadId || !reservationId) {
6875
+ return fail2(400, "uploadKey, uploadId, and reservationId are required", "bad_request");
6446
6876
  }
6447
- const anchored = anchorKey(uploadKey, prefix);
6448
- return anchored instanceof Response ? anchored : { key: anchored.key, uploadId };
6877
+ if (!REQUEST_ID_RE.test(reservationId)) {
6878
+ return fail2(400, "reservationId is invalid", "bad_request");
6879
+ }
6880
+ const anchored = anchorKey(uploadKey, prefix, excludedPrefixes);
6881
+ return anchored instanceof Response ? anchored : { key: anchored.key, uploadKey, uploadId, reservationId };
6882
+ }
6883
+ function reservationMatches(reservation, session) {
6884
+ return reservation.admitted && reservation.uploadId === session.uploadId && reservation.objectKey === session.key && reservation.uploadKey === session.uploadKey;
6885
+ }
6886
+ function multipartSessionJson(uploadId, uploadKey, reservationId) {
6887
+ return Response.json(
6888
+ {
6889
+ uploadId,
6890
+ uploadKey,
6891
+ reservationId,
6892
+ partSize: UPLOAD_PART_BYTES,
6893
+ maxParts: MAX_UPLOAD_PARTS,
6894
+ maxBytes: MAX_APP_FILE_BYTES
6895
+ },
6896
+ { headers: CORS_HEADERS4 }
6897
+ );
6898
+ }
6899
+ function multipartPartCount(declaredBytes) {
6900
+ return Math.ceil(declaredBytes / UPLOAD_PART_BYTES);
6901
+ }
6902
+ function expectedMultipartPartBytes(declaredBytes, partNumber) {
6903
+ const partCount = multipartPartCount(declaredBytes);
6904
+ if (partNumber < 1 || partNumber > partCount) return null;
6905
+ return partNumber === partCount ? declaredBytes - (partCount - 1) * UPLOAD_PART_BYTES : UPLOAD_PART_BYTES;
6906
+ }
6907
+ function multipartLayoutError(declaredBytes) {
6908
+ return fail2(
6909
+ 400,
6910
+ `This upload must use ${multipartPartCount(declaredBytes)} sequential part(s) at the advertised ${formatBytes(UPLOAD_PART_BYTES)} size, except for the final remainder.`,
6911
+ "bad_part_layout",
6912
+ { partSize: UPLOAD_PART_BYTES, partCount: multipartPartCount(declaredBytes) }
6913
+ );
6449
6914
  }
6450
6915
  async function streamPart(upload, partNumber, body, expectedBytes) {
6451
6916
  let observedBytes = 0;
@@ -6468,57 +6933,121 @@ async function streamPart(upload, partNumber, body, expectedBytes) {
6468
6933
  }
6469
6934
  return { ok: true, part: uploaded.value };
6470
6935
  }
6471
- async function handleMultipart(request, url, bucket, prefix, auth, scope, subpath) {
6936
+ async function handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath) {
6472
6937
  if (subpath === "multipart" && request.method === "POST") {
6473
- return multipartInit(request, url, bucket, prefix, auth);
6938
+ return multipartInit(request, url, bucket, prefix, excludedPrefixes, auth);
6939
+ }
6940
+ if (subpath === "multipart/part") {
6941
+ return multipartPart(request, url, bucket, prefix, excludedPrefixes, auth);
6474
6942
  }
6475
- if (subpath === "multipart/part") return multipartPart(request, url, bucket, prefix);
6476
6943
  if (subpath === "multipart/complete") {
6477
- return multipartComplete(request, url, bucket, prefix, auth, scope);
6944
+ return multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope);
6478
6945
  }
6479
- return multipartAbort(url, bucket, prefix);
6946
+ return multipartAbort(url, bucket, prefix, excludedPrefixes, auth);
6480
6947
  }
6481
- async function multipartInit(request, url, bucket, prefix, auth) {
6948
+ async function multipartInit(request, url, bucket, prefix, excludedPrefixes, auth) {
6482
6949
  const body = await readControlJson(request);
6483
6950
  if (body instanceof Response) return body;
6484
6951
  const size = body.size;
6485
- if (!Number.isSafeInteger(size) || size < 0) {
6486
- return fail(400, "size must be a non-negative integer number of bytes", "bad_request");
6952
+ if (!Number.isSafeInteger(size) || size <= 0) {
6953
+ return fail2(400, "size must be a positive integer number of bytes", "bad_request");
6487
6954
  }
6488
6955
  const tooBig = overCeiling(size);
6489
6956
  if (tooBig) return tooBig;
6490
6957
  const name = typeof body.name === "string" ? body.name.trim() : "";
6491
- if (!name) return fail(400, "name is required", "bad_request");
6492
- const mime = resolveMimeType(typeof body.mimeType === "string" ? body.mimeType : "");
6493
- if (mime instanceof Response) return mime;
6494
- const located = locateUpload(url, prefix, name);
6958
+ if (!name) return fail2(400, "name is required", "bad_request");
6959
+ const requestId = typeof body.requestId === "string" ? body.requestId : "";
6960
+ if (!REQUEST_ID_RE.test(requestId)) {
6961
+ return fail2(
6962
+ 400,
6963
+ "requestId must be 16\u201380 URL-safe characters and must be reused when retrying init.",
6964
+ "bad_request"
6965
+ );
6966
+ }
6967
+ const mimeType = resolveMimeType(typeof body.mimeType === "string" ? body.mimeType : "");
6968
+ const located = locateUpload(url, prefix, excludedPrefixes, name, requestId);
6495
6969
  if (located instanceof Response) return located;
6496
- const replacesKey = url.searchParams.get("key") === null ? null : located.key;
6497
- const overQuota = await admitStorage(bucket, auth.storage, size, replacesKey);
6498
- if (overQuota) return overQuota;
6970
+ const completed = await bucket.head(located.key);
6971
+ if (completed?.customMetadata?.reservationId === requestId) {
6972
+ return fail2(
6973
+ 409,
6974
+ "requestId already completed an upload; start the new upload with a fresh requestId.",
6975
+ "request_id_completed"
6976
+ );
6977
+ }
6978
+ const existing = await readMultipartReservation(bucket, auth.storage, requestId);
6979
+ if (existing === "invalid") {
6980
+ return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
6981
+ }
6982
+ if (existing) {
6983
+ const sameRequest = existing.objectKey === located.key && existing.uploadKey === located.subkey && existing.declaredBytes === size;
6984
+ return sameRequest && existing.admitted ? multipartSessionJson(existing.uploadId, existing.uploadKey, requestId) : sameRequest ? quotaUnavailable("This upload is still being initialized. Retry shortly.") : fail2(
6985
+ 409,
6986
+ "requestId already belongs to a different multipart upload.",
6987
+ "request_id_conflict"
6988
+ );
6989
+ }
6499
6990
  let upload;
6500
6991
  try {
6501
6992
  upload = await bucket.createMultipartUpload(located.key, {
6502
- httpMetadata: { contentType: mime.mimeType },
6503
- customMetadata: fileMetadata(name, auth.userId)
6993
+ httpMetadata: { contentType: mimeType },
6994
+ customMetadata: fileMetadata(name, auth.userId, requestId, size)
6504
6995
  });
6505
6996
  } catch (error) {
6506
6997
  return multipartFailure(error);
6507
6998
  }
6508
- return Response.json(
6509
- {
6999
+ const marker = auth.storage ? {
7000
+ key: multipartMarkerKey(auth.storage, requestId),
7001
+ body: JSON.stringify({
7002
+ version: 1,
7003
+ reservationId: requestId,
6510
7004
  uploadId: upload.uploadId,
6511
- // Echoed back on every later call. Relative to the app, exactly like
6512
- // `?key=`, so the mount that anchors keys anchors this one too.
7005
+ objectKey: located.key,
6513
7006
  uploadKey: located.subkey,
6514
- partSize: UPLOAD_PART_BYTES,
6515
- maxParts: MAX_UPLOAD_PARTS,
6516
- maxBytes: MAX_APP_FILE_BYTES
6517
- },
6518
- { headers: CORS_HEADERS3 }
6519
- );
7007
+ admitted: false,
7008
+ declaredBytes: size
7009
+ }),
7010
+ metadata: {
7011
+ quotaKind: "multipart",
7012
+ active: "false"
7013
+ }
7014
+ } : void 0;
7015
+ const reservedBytes = Math.max(size, 5 * 1024 * 1024);
7016
+ const admission = await admitStorage(bucket, auth.storage, reservedBytes, 0, marker);
7017
+ if (admission instanceof Response) {
7018
+ await upload.abort().catch(() => void 0);
7019
+ if (admission.status === 409 && auth.storage) {
7020
+ const winner = await readMultipartReservation(bucket, auth.storage, requestId);
7021
+ if (winner && winner !== "invalid" && winner.objectKey === located.key && winner.uploadKey === located.subkey && winner.declaredBytes === size && winner.admitted) {
7022
+ return multipartSessionJson(winner.uploadId, winner.uploadKey, requestId);
7023
+ }
7024
+ }
7025
+ return admission;
7026
+ }
7027
+ if (auth.storage && !await activateMultipartReservation(bucket, auth.storage, requestId)) {
7028
+ await upload.abort().catch(() => void 0);
7029
+ await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
7030
+ return quotaUnavailable("This upload reservation could not be finalized. Retry shortly.");
7031
+ }
7032
+ return multipartSessionJson(upload.uploadId, located.subkey, requestId);
6520
7033
  }
6521
- async function multipartPart(request, url, bucket, prefix) {
7034
+ async function abandonMultipart(bucket, session, auth, reservation) {
7035
+ let result = "aborted";
7036
+ try {
7037
+ await bucket.resumeMultipartUpload(session.key, session.uploadId).abort();
7038
+ } catch (error) {
7039
+ if (r2ErrorCode(error) !== 10024) {
7040
+ console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
7041
+ return "failed";
7042
+ }
7043
+ result = "absent";
7044
+ }
7045
+ if (reservation) {
7046
+ if (!await releaseQuotaMarker(bucket, auth.storage, reservation.key, true)) return "failed";
7047
+ }
7048
+ return result;
7049
+ }
7050
+ async function multipartPart(request, url, bucket, prefix, excludedPrefixes, auth) {
6522
7051
  const refuse = async (response) => {
6523
7052
  await request.body?.cancel().catch(() => void 0);
6524
7053
  return response;
@@ -6530,16 +7059,60 @@ async function multipartPart(request, url, bucket, prefix) {
6530
7059
  const partNumber = Number(url.searchParams.get("partNumber"));
6531
7060
  if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
6532
7061
  return refuse(
6533
- fail(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
7062
+ fail2(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
6534
7063
  );
6535
7064
  }
6536
- const session = resolveSession(url, prefix);
7065
+ const session = resolveSession(url, prefix, excludedPrefixes);
6537
7066
  if (session instanceof Response) return refuse(session);
6538
- if (!request.body) return fail(400, "A part body is required", "bad_request");
7067
+ if (!request.body) return fail2(400, "A part body is required", "bad_request");
7068
+ const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
7069
+ if (reservation === "invalid") {
7070
+ return refuse(quotaUnavailable("This upload reservation could not be read. Retry shortly."));
7071
+ }
7072
+ if (auth.storage && reservation && !reservationMatches(reservation, session)) {
7073
+ return refuse(
7074
+ fail2(
7075
+ 404,
7076
+ "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
7077
+ "unknown_upload"
7078
+ )
7079
+ );
7080
+ }
7081
+ if (auth.storage && !reservation) {
7082
+ return refuse(
7083
+ fail2(
7084
+ 404,
7085
+ "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
7086
+ "unknown_upload"
7087
+ )
7088
+ );
7089
+ }
7090
+ if (reservation) {
7091
+ const expectedBytes = expectedMultipartPartBytes(reservation.declaredBytes, partNumber);
7092
+ if (expectedBytes === null || declaredBytes !== expectedBytes) {
7093
+ return refuse(multipartLayoutError(reservation.declaredBytes));
7094
+ }
7095
+ }
7096
+ const refreshed = await refreshMultipartReservation(
7097
+ bucket,
7098
+ auth.storage,
7099
+ session.reservationId,
7100
+ session.uploadId,
7101
+ session.key
7102
+ );
7103
+ if (auth.storage && (refreshed === null || refreshed === "invalid")) {
7104
+ return refuse(quotaUnavailable("This upload reservation is missing or invalid."));
7105
+ }
7106
+ if (refreshed === "busy") {
7107
+ return refuse(quotaUnavailable("This upload reservation is busy. Retry the part shortly."));
7108
+ }
6539
7109
  const upload = bucket.resumeMultipartUpload(session.key, session.uploadId);
6540
7110
  const stored = await streamPart(upload, partNumber, request.body, declaredBytes);
6541
7111
  if (!stored.ok) {
6542
- return stored.lengthMismatch ? fail(
7112
+ if (r2ErrorCode(stored.error) === 10024 && reservation) {
7113
+ await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
7114
+ }
7115
+ return stored.lengthMismatch ? fail2(
6543
7116
  400,
6544
7117
  `Part ${partNumber} declared ${declaredBytes} bytes but its body did not carry them`,
6545
7118
  "bad_request"
@@ -6547,29 +7120,98 @@ async function multipartPart(request, url, bucket, prefix) {
6547
7120
  }
6548
7121
  return Response.json(
6549
7122
  { partNumber: stored.part.partNumber, etag: stored.part.etag },
6550
- { headers: CORS_HEADERS3 }
7123
+ { headers: CORS_HEADERS4 }
6551
7124
  );
6552
7125
  }
6553
- async function multipartComplete(request, url, bucket, prefix, auth, scope) {
7126
+ async function completedReservationObject(bucket, session) {
7127
+ const object = await bucket.head(session.key);
7128
+ return object?.customMetadata?.reservationId === session.reservationId ? object : null;
7129
+ }
7130
+ async function finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope) {
7131
+ const declaredMetadata = Number(object.customMetadata?.declaredBytes);
7132
+ const declaredBytes = reservation?.declaredBytes ?? (Number.isSafeInteger(declaredMetadata) && declaredMetadata >= 0 ? declaredMetadata : MAX_APP_FILE_BYTES);
7133
+ const refusal = overCeiling(object.size) ?? (object.size > declaredBytes ? fail2(
7134
+ 400,
7135
+ `The assembled upload exceeded its declared ${declaredBytes}-byte size and was not kept.`,
7136
+ "declared_size_exceeded"
7137
+ ) : null);
7138
+ if (refusal) {
7139
+ let remaining;
7140
+ try {
7141
+ const current = await bucket.head(session.key);
7142
+ if (current?.etag === object.etag) await bucket.delete(session.key);
7143
+ remaining = await bucket.head(session.key);
7144
+ } catch (error) {
7145
+ console.error("[files:multipart] invalid object cleanup:", error);
7146
+ return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
7147
+ }
7148
+ if (remaining?.etag === object.etag) {
7149
+ return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
7150
+ }
7151
+ if (reservation) {
7152
+ const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
7153
+ if (!cleaned) return quotaUnavailable("The upload was removed, but cleanup must be retried.");
7154
+ }
7155
+ return refusal;
7156
+ }
7157
+ if (reservation) {
7158
+ const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
7159
+ if (!cleaned) {
7160
+ return quotaUnavailable("The file was stored, but its reservation cleanup must be retried.");
7161
+ }
7162
+ }
7163
+ const name = object.customMetadata?.originalName ?? session.uploadKey.split("/").pop() ?? "file";
7164
+ return storedFileJson(session.key, name, url, scope);
7165
+ }
7166
+ async function multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
6554
7167
  const body = await readControlJson(request);
6555
7168
  if (body instanceof Response) return body;
6556
- const session = resolveSession(url, prefix);
7169
+ const session = resolveSession(url, prefix, excludedPrefixes);
6557
7170
  if (session instanceof Response) return session;
7171
+ const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
7172
+ if (reservation === "invalid") {
7173
+ return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
7174
+ }
7175
+ if (auth.storage && reservation && !reservationMatches(reservation, session)) {
7176
+ return fail2(
7177
+ 404,
7178
+ "That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
7179
+ "unknown_upload"
7180
+ );
7181
+ }
7182
+ if (auth.storage && !reservation) {
7183
+ const completed = await completedReservationObject(bucket, session);
7184
+ if (completed) {
7185
+ return finishCompletedMultipart(bucket, session, null, completed, auth, url, scope);
7186
+ }
7187
+ await abandonMultipart(bucket, session, auth, null);
7188
+ return quotaUnavailable("This upload reservation is missing or does not match.");
7189
+ }
6558
7190
  const raw = body.parts;
6559
7191
  if (!Array.isArray(raw) || raw.length === 0) {
6560
- return fail(400, "parts must be a non-empty array", "bad_request");
7192
+ return fail2(400, "parts must be a non-empty array", "bad_request");
6561
7193
  }
6562
7194
  if (raw.length > MAX_UPLOAD_PARTS) {
6563
- return fail(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
7195
+ return fail2(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
7196
+ }
7197
+ if (reservation && raw.length !== multipartPartCount(reservation.declaredBytes)) {
7198
+ return multipartLayoutError(reservation.declaredBytes);
6564
7199
  }
6565
7200
  const parts = [];
6566
7201
  for (let index = 0; index < raw.length; index++) {
6567
7202
  const { partNumber, etag } = raw[index] ?? {};
6568
- if (!Number.isSafeInteger(partNumber) || partNumber < 1) {
6569
- return fail(400, `parts[${index}].partNumber must be a positive integer`, "bad_request");
7203
+ if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
7204
+ return fail2(
7205
+ 400,
7206
+ `parts[${index}].partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`,
7207
+ "bad_request"
7208
+ );
7209
+ }
7210
+ if (reservation && partNumber !== index + 1) {
7211
+ return multipartLayoutError(reservation.declaredBytes);
6570
7212
  }
6571
7213
  if (typeof etag !== "string" || !etag) {
6572
- return fail(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
7214
+ return fail2(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
6573
7215
  }
6574
7216
  parts.push({ partNumber, etag });
6575
7217
  }
@@ -6577,35 +7219,62 @@ async function multipartComplete(request, url, bucket, prefix, auth, scope) {
6577
7219
  try {
6578
7220
  object = await bucket.resumeMultipartUpload(session.key, session.uploadId).complete(parts);
6579
7221
  } catch (error) {
7222
+ const completed = await completedReservationObject(bucket, session);
7223
+ if (completed) {
7224
+ return finishCompletedMultipart(bucket, session, reservation, completed, auth, url, scope);
7225
+ }
7226
+ if (r2ErrorCode(error) === 10024 && reservation) {
7227
+ await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
7228
+ }
6580
7229
  return multipartFailure(error);
6581
7230
  }
6582
- const tooBig = overCeiling(object.size);
6583
- if (tooBig) {
6584
- await bucket.delete(session.key).catch(() => void 0);
6585
- return tooBig;
6586
- }
6587
- const overQuota = await admitAssembledObject(bucket, auth.storage, session.key, object.size);
6588
- if (overQuota) return overQuota;
6589
- const name = object.customMetadata?.originalName ?? session.key.split("/").pop() ?? "file";
6590
- return storedFileJson(session.key, name, url, scope);
7231
+ return finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope);
6591
7232
  }
6592
- async function multipartAbort(url, bucket, prefix) {
6593
- const session = resolveSession(url, prefix);
7233
+ async function multipartAbort(url, bucket, prefix, excludedPrefixes, auth) {
7234
+ const session = resolveSession(url, prefix, excludedPrefixes);
6594
7235
  if (session instanceof Response) return session;
6595
- try {
6596
- await bucket.resumeMultipartUpload(session.key, session.uploadId).abort();
6597
- } catch (error) {
6598
- console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
6599
- return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS3 });
7236
+ const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
7237
+ if (reservation === "invalid") {
7238
+ return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS4 });
6600
7239
  }
6601
- return Response.json({ success: true, aborted: true }, { headers: CORS_HEADERS3 });
7240
+ if (auth.storage && reservation && !reservationMatches(reservation, session)) {
7241
+ return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS4 });
7242
+ }
7243
+ const result = await abandonMultipart(bucket, session, auth, reservation || null);
7244
+ return Response.json({ success: true, aborted: result === "aborted" }, { headers: CORS_HEADERS4 });
6602
7245
  }
6603
- async function handleList(bucket, prefix, url, scope, auth) {
7246
+ async function handleList(bucket, prefix, excludedPrefixes, url, scope, auth) {
6604
7247
  const userPrefix = url.searchParams.get("prefix") || "";
6605
7248
  const listPrefix = `${prefix}${userPrefix}`;
6606
- const limit = parseInt(url.searchParams.get("limit") || "100", 10);
7249
+ const requestedLimit = Number(url.searchParams.get("limit") ?? 100);
7250
+ const limit = Number.isSafeInteger(requestedLimit) ? Math.min(1e3, Math.max(1, requestedLimit)) : 100;
7251
+ if (excludedPrefixes.some((excluded) => listPrefix.startsWith(excluded))) {
7252
+ return Response.json({ files: [], truncated: false }, { headers: CORS_HEADERS4 });
7253
+ }
6607
7254
  const listed = await bucket.list({ prefix: listPrefix, limit });
6608
- const files = listed.objects.map((obj) => {
7255
+ const visible = listed.objects.filter(
7256
+ (object) => isKeyWithinScope(object.key, prefix, excludedPrefixes)
7257
+ );
7258
+ const seen = new Set(visible.map((object) => object.key));
7259
+ let truncated = listed.truncated;
7260
+ for (const excluded of excludedPrefixes.filter((candidate) => candidate.startsWith(listPrefix))) {
7261
+ if (visible.length >= limit) break;
7262
+ const excludedEnd = `${excluded}\u{10FFFF}`;
7263
+ const lastListedKey = listed.objects.at(-1)?.key;
7264
+ const after = await bucket.list({
7265
+ prefix: listPrefix,
7266
+ startAfter: lastListedKey && lastListedKey > excludedEnd ? lastListedKey : excludedEnd,
7267
+ limit: limit - visible.length
7268
+ });
7269
+ for (const object of after.objects) {
7270
+ if (!seen.has(object.key) && isKeyWithinScope(object.key, prefix, excludedPrefixes)) {
7271
+ seen.add(object.key);
7272
+ visible.push(object);
7273
+ }
7274
+ }
7275
+ truncated = after.truncated;
7276
+ }
7277
+ const files = visible.slice(0, limit).map((obj) => {
6609
7278
  const fileUrl = new URL(`/api/files/${encodeKeyPath(obj.key)}`, url.origin);
6610
7279
  fileUrl.searchParams.set("scope", scope);
6611
7280
  return {
@@ -6617,32 +7286,41 @@ async function handleList(bucket, prefix, url, scope, auth) {
6617
7286
  };
6618
7287
  });
6619
7288
  let storage;
6620
- if (auth.storage) {
6621
- const usedBytes = await allocationUsage(bucket, auth.storage);
7289
+ if (auth.storage && auth.includeStorageUsage) {
7290
+ const usedBytes = await storageUsage(bucket, auth.storage);
6622
7291
  if (usedBytes !== null) storage = { usedBytes };
6623
7292
  const limitBytes = await auth.storage.limitBytes();
6624
7293
  if (storage && limitBytes !== null) storage.limitBytes = limitBytes;
6625
7294
  }
6626
7295
  return Response.json(
6627
- { files, truncated: listed.truncated, ...storage ? { storage } : {} },
6628
- { headers: CORS_HEADERS3 }
7296
+ { files, truncated, ...storage ? { storage } : {} },
7297
+ { headers: CORS_HEADERS4 }
6629
7298
  );
6630
7299
  }
6631
- async function handleDownload(bucket, key, scope) {
7300
+ async function handleDownload(request, bucket, key, scope) {
6632
7301
  const object = await bucket.get(key);
6633
7302
  if (!object) {
6634
- return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS3 });
7303
+ return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS4 });
6635
7304
  }
6636
7305
  const headers = new Headers();
6637
- headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream");
7306
+ const storedType = resolveMimeType(object.httpMetadata?.contentType ?? "");
7307
+ const mustDownload = DANGEROUS_MIME_TYPES.has(storedType);
7308
+ headers.set("Content-Type", mustDownload ? "application/octet-stream" : storedType);
6638
7309
  headers.set("X-Content-Type-Options", "nosniff");
7310
+ headers.set("ETag", object.httpEtag);
6639
7311
  headers.set(
6640
7312
  "Cache-Control",
6641
- scope === "app" ? "public, max-age=31536000, immutable" : "private, no-store"
7313
+ scope === "self" ? "private, no-store" : "public, max-age=0, must-revalidate"
6642
7314
  );
6643
7315
  headers.set("Access-Control-Allow-Origin", "*");
6644
- if (object.customMetadata?.originalName) {
6645
- headers.set("Content-Disposition", contentDisposition(object.customMetadata.originalName));
7316
+ const originalName = object.customMetadata?.originalName ?? key.split("/").pop() ?? "download";
7317
+ headers.set(
7318
+ "Content-Disposition",
7319
+ contentDisposition(originalName, mustDownload ? "attachment" : "inline")
7320
+ );
7321
+ if (mustDownload) headers.set("Content-Security-Policy", "sandbox; default-src 'none'");
7322
+ if (scope === "app" && request.headers.get("if-none-match") === object.httpEtag) {
7323
+ return new Response(null, { status: 304, headers });
6646
7324
  }
6647
7325
  return new Response(object.body, { headers });
6648
7326
  }