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/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/cli.js +28 -34
- package/dist/cli.js.map +1 -1
- package/dist/index.js +24 -13
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +25 -29
- package/dist/server.js +910 -232
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +25 -29
- package/dist/worker.js +877 -199
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -211,9 +211,6 @@ var DANGEROUS_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
|
211
211
|
"application/ecmascript",
|
|
212
212
|
"text/ecmascript"
|
|
213
213
|
]);
|
|
214
|
-
function activeContentMessage(mimeType) {
|
|
215
|
-
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.`;
|
|
216
|
-
}
|
|
217
214
|
function formatBytes(bytes) {
|
|
218
215
|
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
|
|
219
216
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
@@ -233,21 +230,479 @@ function oversizePartMessage(bytes) {
|
|
|
233
230
|
return `This part carries ${formatBytes(bytes)}; a part may carry ${formatBytes(UPLOAD_PART_BYTES)} \u2014 the size the upload advertised.`;
|
|
234
231
|
}
|
|
235
232
|
|
|
236
|
-
// src/server/utils/
|
|
233
|
+
// src/server/utils/r2-storage-quota.ts
|
|
237
234
|
var CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
|
|
235
|
+
var QUOTA_SUMMARY_VERSION = 1;
|
|
236
|
+
var MAX_QUOTA_SCAN_OBJECTS = 5e5;
|
|
237
|
+
var MAX_QUOTA_SCAN_LISTS = 900;
|
|
238
|
+
var QUOTA_REPAIR_COOLDOWN_MS = 6e4;
|
|
239
|
+
var PENDING_MARKER_MAX_AGE_MS = 60 * 6e4;
|
|
240
|
+
var EMPTY_MULTIPART_MARKER_MAX_AGE_MS = 60 * 6e4;
|
|
241
|
+
var ACTIVE_MULTIPART_MARKER_MAX_AGE_MS = 8 * 24 * 60 * 6e4;
|
|
238
242
|
function fail(status, error, code, extra) {
|
|
239
243
|
return Response.json(
|
|
240
244
|
{ error, ...code ? { code } : {}, ...extra },
|
|
241
245
|
{ status, headers: CORS_HEADERS }
|
|
242
246
|
);
|
|
243
247
|
}
|
|
244
|
-
function
|
|
248
|
+
function quotaUnavailable(message) {
|
|
249
|
+
return fail(503, message, "storage_limit_unavailable");
|
|
250
|
+
}
|
|
251
|
+
function quotaKeySegment(value) {
|
|
252
|
+
return encodeURIComponent(value);
|
|
253
|
+
}
|
|
254
|
+
function markerPrefix(storage, kind) {
|
|
255
|
+
return `__deepspace/quota/${kind}/${quotaKeySegment(storage.quotaKey)}/`;
|
|
256
|
+
}
|
|
257
|
+
function pendingMarkerPrefix(storage) {
|
|
258
|
+
return markerPrefix(storage, "pending");
|
|
259
|
+
}
|
|
260
|
+
function multipartMarkerPrefix(storage) {
|
|
261
|
+
return markerPrefix(storage, "multipart");
|
|
262
|
+
}
|
|
263
|
+
function multipartMarkerKey(storage, reservationId) {
|
|
264
|
+
return `${multipartMarkerPrefix(storage)}${quotaKeySegment(reservationId)}`;
|
|
265
|
+
}
|
|
266
|
+
function accountPrefixesEqual(left, right) {
|
|
267
|
+
return left.length === right.length && left.every((prefix, index) => prefix === right[index]);
|
|
268
|
+
}
|
|
269
|
+
async function allocationPrefixes(storage) {
|
|
270
|
+
const siblings = storage.siblingPrefixes ? await storage.siblingPrefixes() : [];
|
|
271
|
+
if (siblings === null) return null;
|
|
272
|
+
return [.../* @__PURE__ */ new Set([storage.prefix, ...siblings])].sort();
|
|
273
|
+
}
|
|
274
|
+
function countScan(budget, count) {
|
|
275
|
+
budget.lists++;
|
|
276
|
+
budget.objects += count;
|
|
277
|
+
if (budget.lists > MAX_QUOTA_SCAN_LISTS || budget.objects > MAX_QUOTA_SCAN_OBJECTS) {
|
|
278
|
+
throw new Error("quota scan work limit exceeded");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function prefixUsage(bucket, prefix, budget) {
|
|
282
|
+
let total = 0;
|
|
283
|
+
let cursor;
|
|
284
|
+
do {
|
|
285
|
+
const page = await bucket.list({ prefix, cursor, limit: 1e3 });
|
|
286
|
+
countScan(budget, page.objects.length);
|
|
287
|
+
for (const object of page.objects) total += object.size;
|
|
288
|
+
cursor = page.truncated ? page.cursor : void 0;
|
|
289
|
+
} while (cursor !== void 0);
|
|
290
|
+
return total;
|
|
291
|
+
}
|
|
292
|
+
function markerBytes(object) {
|
|
293
|
+
const raw = object.customMetadata?.reservedBytes;
|
|
294
|
+
const bytes = raw === void 0 ? NaN : Number(raw);
|
|
295
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0) throw new Error("invalid quota marker");
|
|
296
|
+
return bytes;
|
|
297
|
+
}
|
|
298
|
+
function markerMaxAge(object, fallback) {
|
|
299
|
+
if (object.customMetadata?.quotaKind !== "multipart") return fallback;
|
|
300
|
+
return object.customMetadata.active === "true" ? ACTIVE_MULTIPART_MARKER_MAX_AGE_MS : EMPTY_MULTIPART_MARKER_MAX_AGE_MS;
|
|
301
|
+
}
|
|
302
|
+
async function markerUsage(bucket, prefix, maxAgeMs, budget, excludedKey) {
|
|
303
|
+
let total = 0;
|
|
304
|
+
let cursor;
|
|
305
|
+
const now = Date.now();
|
|
306
|
+
do {
|
|
307
|
+
const options = {
|
|
308
|
+
prefix,
|
|
309
|
+
cursor,
|
|
310
|
+
limit: 1e3,
|
|
311
|
+
include: ["customMetadata"]
|
|
312
|
+
};
|
|
313
|
+
const page = await bucket.list(options);
|
|
314
|
+
countScan(budget, page.objects.length);
|
|
315
|
+
for (const object of page.objects) {
|
|
316
|
+
if (now - object.uploaded.getTime() > markerMaxAge(object, maxAgeMs)) {
|
|
317
|
+
if (budget.staleKeys.length < 1e3) budget.staleKeys.push(object.key);
|
|
318
|
+
} else if (object.key !== excludedKey) {
|
|
319
|
+
total += markerBytes(object);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
cursor = page.truncated ? page.cursor : void 0;
|
|
323
|
+
} while (cursor !== void 0);
|
|
324
|
+
return total;
|
|
325
|
+
}
|
|
326
|
+
async function measuredAllocationUsage(bucket, storage, prefixes, excludedMarkerKey) {
|
|
327
|
+
const budget = { lists: 0, objects: 0, staleKeys: [] };
|
|
328
|
+
let total = 0;
|
|
329
|
+
for (const prefix of prefixes) total += await prefixUsage(bucket, prefix, budget);
|
|
330
|
+
total += await markerUsage(
|
|
331
|
+
bucket,
|
|
332
|
+
pendingMarkerPrefix(storage),
|
|
333
|
+
PENDING_MARKER_MAX_AGE_MS,
|
|
334
|
+
budget,
|
|
335
|
+
excludedMarkerKey
|
|
336
|
+
);
|
|
337
|
+
total += await markerUsage(
|
|
338
|
+
bucket,
|
|
339
|
+
multipartMarkerPrefix(storage),
|
|
340
|
+
ACTIVE_MULTIPART_MARKER_MAX_AGE_MS,
|
|
341
|
+
budget,
|
|
342
|
+
excludedMarkerKey
|
|
343
|
+
);
|
|
344
|
+
if (budget.staleKeys.length > 0) {
|
|
345
|
+
await bucket.delete(budget.staleKeys).catch((error) => {
|
|
346
|
+
console.error("[files:quota] stale marker cleanup:", error);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return total;
|
|
350
|
+
}
|
|
351
|
+
function isQuotaSummary(value) {
|
|
352
|
+
if (!value || typeof value !== "object") return false;
|
|
353
|
+
const summary = value;
|
|
354
|
+
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";
|
|
355
|
+
}
|
|
356
|
+
async function readQuotaSummary(bucket, key) {
|
|
357
|
+
const object = await bucket.get(key);
|
|
358
|
+
if (!object) return { etag: null, summary: null };
|
|
359
|
+
try {
|
|
360
|
+
const parsed = await object.json();
|
|
361
|
+
return { etag: object.etag, summary: isQuotaSummary(parsed) ? parsed : null };
|
|
362
|
+
} catch {
|
|
363
|
+
return { etag: object.etag, summary: null };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async function writeQuotaSummary(bucket, key, previousEtag, summary) {
|
|
367
|
+
const onlyIf = previousEtag ? { etagMatches: previousEtag } : new Headers({ "If-None-Match": "*" });
|
|
368
|
+
const object = await bucket.put(key, JSON.stringify(summary), {
|
|
369
|
+
onlyIf,
|
|
370
|
+
httpMetadata: { contentType: "application/json" }
|
|
371
|
+
});
|
|
372
|
+
return object ? { etag: object.etag, summary } : null;
|
|
373
|
+
}
|
|
374
|
+
async function ensureQuotaSummary(bucket, storage, prefixes, forceMeasure = false, excludedMarkerKey) {
|
|
375
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
376
|
+
const current = await readQuotaSummary(bucket, storage.quotaKey);
|
|
377
|
+
if (!forceMeasure && current.summary && accountPrefixesEqual(current.summary.prefixes, prefixes)) {
|
|
378
|
+
return current;
|
|
379
|
+
}
|
|
380
|
+
const usedBytes = await measuredAllocationUsage(bucket, storage, prefixes, excludedMarkerKey);
|
|
381
|
+
const repaired = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
|
|
382
|
+
version: QUOTA_SUMMARY_VERSION,
|
|
383
|
+
prefixes,
|
|
384
|
+
usedBytes,
|
|
385
|
+
measuredAt: Date.now(),
|
|
386
|
+
dirty: false
|
|
387
|
+
});
|
|
388
|
+
if (repaired) return repaired;
|
|
389
|
+
}
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
async function markQuotaDirty(bucket, storage) {
|
|
393
|
+
if (!storage) return;
|
|
394
|
+
try {
|
|
395
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
396
|
+
const current = await readQuotaSummary(bucket, storage.quotaKey);
|
|
397
|
+
if (!current.summary || current.summary.dirty) return;
|
|
398
|
+
const written = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
|
|
399
|
+
...current.summary,
|
|
400
|
+
dirty: true
|
|
401
|
+
});
|
|
402
|
+
if (written) return;
|
|
403
|
+
}
|
|
404
|
+
console.error("[files:quota] could not mark summary reclaimable after concurrent writes");
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.error("[files:quota] could not mark summary reclaimable:", error);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function neutralizeMarker(bucket, markerKey) {
|
|
410
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
411
|
+
const marker = await bucket.get(markerKey);
|
|
412
|
+
if (!marker) return true;
|
|
413
|
+
if (markerBytes(marker) === 0) return true;
|
|
414
|
+
const body = await marker.arrayBuffer();
|
|
415
|
+
const object = await bucket.put(markerKey, body, {
|
|
416
|
+
onlyIf: { etagMatches: marker.etag },
|
|
417
|
+
customMetadata: {
|
|
418
|
+
...marker.customMetadata,
|
|
419
|
+
reservedBytes: "0",
|
|
420
|
+
releasedAt: String(Date.now())
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
if (object) return true;
|
|
424
|
+
}
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
async function releaseQuotaMarker(bucket, storage, markerKey, summaryMayBeStale) {
|
|
428
|
+
try {
|
|
429
|
+
if (markerKey && !await neutralizeMarker(bucket, markerKey)) {
|
|
430
|
+
console.error("[files:quota] could not neutralize reservation marker");
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
if (summaryMayBeStale) await markQuotaDirty(bucket, storage);
|
|
434
|
+
if (markerKey) {
|
|
435
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
436
|
+
try {
|
|
437
|
+
await bucket.delete(markerKey);
|
|
438
|
+
break;
|
|
439
|
+
} catch (error) {
|
|
440
|
+
if (attempt === 3) throw error;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return true;
|
|
445
|
+
} catch (error) {
|
|
446
|
+
console.error("[files:quota] reservation cleanup:", error);
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function parseMultipartMarker(value) {
|
|
451
|
+
if (!value || typeof value !== "object") return null;
|
|
452
|
+
const marker = value;
|
|
453
|
+
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) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
return marker;
|
|
457
|
+
}
|
|
458
|
+
function multipartMarkerBody(reservation) {
|
|
459
|
+
return JSON.stringify({
|
|
460
|
+
version: 1,
|
|
461
|
+
reservationId: reservation.reservationId,
|
|
462
|
+
uploadId: reservation.uploadId,
|
|
463
|
+
objectKey: reservation.objectKey,
|
|
464
|
+
uploadKey: reservation.uploadKey,
|
|
465
|
+
admitted: reservation.admitted,
|
|
466
|
+
declaredBytes: reservation.declaredBytes
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
async function readMultipartReservation(bucket, storage, reservationId) {
|
|
470
|
+
if (!storage) return null;
|
|
471
|
+
const key = multipartMarkerKey(storage, reservationId);
|
|
472
|
+
let object;
|
|
473
|
+
try {
|
|
474
|
+
object = await bucket.get(key);
|
|
475
|
+
} catch (error) {
|
|
476
|
+
console.error("[files:quota] reservation read:", error);
|
|
477
|
+
return "invalid";
|
|
478
|
+
}
|
|
479
|
+
if (!object) return null;
|
|
480
|
+
let marker = null;
|
|
481
|
+
try {
|
|
482
|
+
marker = parseMultipartMarker(await object.json());
|
|
483
|
+
} catch {
|
|
484
|
+
return "invalid";
|
|
485
|
+
}
|
|
486
|
+
if (!marker || marker.reservationId !== reservationId) return "invalid";
|
|
487
|
+
let reservedBytes;
|
|
488
|
+
try {
|
|
489
|
+
reservedBytes = markerBytes(object);
|
|
490
|
+
} catch {
|
|
491
|
+
return "invalid";
|
|
492
|
+
}
|
|
493
|
+
return {
|
|
494
|
+
key,
|
|
495
|
+
etag: object.etag,
|
|
496
|
+
reservationId,
|
|
497
|
+
uploadId: marker.uploadId,
|
|
498
|
+
objectKey: marker.objectKey,
|
|
499
|
+
uploadKey: marker.uploadKey,
|
|
500
|
+
admitted: marker.admitted,
|
|
501
|
+
declaredBytes: marker.declaredBytes,
|
|
502
|
+
reservedBytes
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
async function refreshMultipartReservation(bucket, storage, reservationId, uploadId, objectKey) {
|
|
506
|
+
if (!storage) return null;
|
|
507
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
508
|
+
const reservation = await readMultipartReservation(bucket, storage, reservationId);
|
|
509
|
+
if (reservation === null || reservation === "invalid") return reservation ?? "invalid";
|
|
510
|
+
if (!reservation.admitted || reservation.uploadId !== uploadId || reservation.objectKey !== objectKey) {
|
|
511
|
+
return "invalid";
|
|
512
|
+
}
|
|
513
|
+
const object = await bucket.put(reservation.key, multipartMarkerBody(reservation), {
|
|
514
|
+
onlyIf: { etagMatches: reservation.etag },
|
|
515
|
+
customMetadata: {
|
|
516
|
+
quotaKind: "multipart",
|
|
517
|
+
reservedBytes: String(reservation.reservedBytes),
|
|
518
|
+
active: "true"
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
if (object) return { ...reservation, etag: object.etag };
|
|
522
|
+
}
|
|
523
|
+
return "busy";
|
|
524
|
+
}
|
|
525
|
+
async function activateMultipartReservation(bucket, storage, reservationId) {
|
|
526
|
+
if (!storage) return null;
|
|
527
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
528
|
+
const reservation = await readMultipartReservation(bucket, storage, reservationId);
|
|
529
|
+
if (!reservation || reservation === "invalid") return null;
|
|
530
|
+
if (reservation.admitted) return reservation;
|
|
531
|
+
const next = { ...reservation, admitted: true };
|
|
532
|
+
const object = await bucket.put(reservation.key, multipartMarkerBody(next), {
|
|
533
|
+
onlyIf: { etagMatches: reservation.etag },
|
|
534
|
+
customMetadata: {
|
|
535
|
+
quotaKind: "multipart",
|
|
536
|
+
reservedBytes: String(reservation.reservedBytes),
|
|
537
|
+
active: "false"
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
if (object) {
|
|
541
|
+
return { ...next, etag: object.etag };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
function quotaExceeded(incomingBytes, summaryBytes, replacedBytes, limitBytes) {
|
|
547
|
+
const usedBytes = Math.max(0, summaryBytes - replacedBytes);
|
|
548
|
+
return fail(
|
|
549
|
+
409,
|
|
550
|
+
storageQuotaMessage(incomingBytes, usedBytes, limitBytes),
|
|
551
|
+
"storage_quota_exceeded",
|
|
552
|
+
{ usedBytes, limitBytes }
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
async function reserveQuotaSummary(bucket, storage, prefixes, initial, chargeBytes, incomingBytes, replacedBytes, limitBytes, markerKey) {
|
|
556
|
+
if (chargeBytes === 0) return null;
|
|
557
|
+
let current = initial;
|
|
558
|
+
let repaired = false;
|
|
559
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
560
|
+
if (!current.summary || !accountPrefixesEqual(current.summary.prefixes, prefixes)) {
|
|
561
|
+
const ensured = await ensureQuotaSummary(
|
|
562
|
+
bucket,
|
|
563
|
+
storage,
|
|
564
|
+
prefixes,
|
|
565
|
+
false,
|
|
566
|
+
markerKey ?? void 0
|
|
567
|
+
);
|
|
568
|
+
if (!ensured) {
|
|
569
|
+
return quotaUnavailable(
|
|
570
|
+
"The storage used by this account could not be verified. Retry shortly."
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
current = ensured;
|
|
574
|
+
}
|
|
575
|
+
const summary = current.summary;
|
|
576
|
+
if (!summary) {
|
|
577
|
+
return quotaUnavailable(
|
|
578
|
+
"The storage used by this account could not be verified. Retry shortly."
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
if (summary.usedBytes + chargeBytes > limitBytes) {
|
|
582
|
+
const recentlyMeasured = Date.now() - summary.measuredAt < QUOTA_REPAIR_COOLDOWN_MS;
|
|
583
|
+
if (repaired || !summary.dirty && recentlyMeasured) {
|
|
584
|
+
return quotaExceeded(incomingBytes, summary.usedBytes, replacedBytes, limitBytes);
|
|
585
|
+
}
|
|
586
|
+
const refreshed = await ensureQuotaSummary(
|
|
587
|
+
bucket,
|
|
588
|
+
storage,
|
|
589
|
+
prefixes,
|
|
590
|
+
true,
|
|
591
|
+
markerKey ?? void 0
|
|
592
|
+
);
|
|
593
|
+
if (!refreshed?.summary) {
|
|
594
|
+
return quotaUnavailable(
|
|
595
|
+
"The storage used by this account could not be verified. Retry shortly."
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
current = refreshed;
|
|
599
|
+
repaired = true;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const written = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
|
|
603
|
+
...summary,
|
|
604
|
+
usedBytes: summary.usedBytes + chargeBytes
|
|
605
|
+
});
|
|
606
|
+
if (written) return null;
|
|
607
|
+
current = await readQuotaSummary(bucket, storage.quotaKey);
|
|
608
|
+
}
|
|
609
|
+
return quotaUnavailable("Storage is busy with other uploads. Retry shortly.");
|
|
610
|
+
}
|
|
611
|
+
async function admitStorage(bucket, storage, incomingBytes, replacedBytes, marker) {
|
|
612
|
+
if (!storage) return { markerKey: null };
|
|
613
|
+
let markerKey = null;
|
|
614
|
+
try {
|
|
615
|
+
const limit = await storage.limitBytes();
|
|
616
|
+
if (limit === null) {
|
|
617
|
+
return quotaUnavailable(
|
|
618
|
+
"The storage limit for this app could not be verified; nothing was uploaded. Retry shortly."
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
const prefixes = await allocationPrefixes(storage);
|
|
622
|
+
if (prefixes === null) {
|
|
623
|
+
return quotaUnavailable(
|
|
624
|
+
"The apps in this storage allocation could not be enumerated; nothing was uploaded. Retry shortly."
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const initial = await ensureQuotaSummary(bucket, storage, prefixes);
|
|
628
|
+
if (!initial) {
|
|
629
|
+
return quotaUnavailable(
|
|
630
|
+
"The storage already used by this account could not be measured; nothing was uploaded. Retry shortly."
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
const chargeBytes = Math.max(0, incomingBytes - replacedBytes);
|
|
634
|
+
const effectiveMarker = marker ?? (chargeBytes > 0 ? {
|
|
635
|
+
key: `${pendingMarkerPrefix(storage)}${crypto.randomUUID()}`,
|
|
636
|
+
metadata: { quotaKind: "pending" }
|
|
637
|
+
} : null);
|
|
638
|
+
if (effectiveMarker) {
|
|
639
|
+
const object = await bucket.put(effectiveMarker.key, effectiveMarker.body ?? "", {
|
|
640
|
+
onlyIf: new Headers({ "If-None-Match": "*" }),
|
|
641
|
+
customMetadata: {
|
|
642
|
+
...effectiveMarker.metadata,
|
|
643
|
+
reservedBytes: String(chargeBytes)
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
if (!object) {
|
|
647
|
+
return fail(
|
|
648
|
+
409,
|
|
649
|
+
"An upload with this request id is already in progress.",
|
|
650
|
+
"reservation_conflict"
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
markerKey = effectiveMarker.key;
|
|
654
|
+
}
|
|
655
|
+
const refusal = await reserveQuotaSummary(
|
|
656
|
+
bucket,
|
|
657
|
+
storage,
|
|
658
|
+
prefixes,
|
|
659
|
+
initial,
|
|
660
|
+
chargeBytes,
|
|
661
|
+
incomingBytes,
|
|
662
|
+
replacedBytes,
|
|
663
|
+
limit,
|
|
664
|
+
effectiveMarker?.key ?? null
|
|
665
|
+
);
|
|
666
|
+
if (refusal) {
|
|
667
|
+
await releaseQuotaMarker(bucket, storage, effectiveMarker?.key ?? null, false);
|
|
668
|
+
return refusal;
|
|
669
|
+
}
|
|
670
|
+
return { markerKey: effectiveMarker?.key ?? null };
|
|
671
|
+
} catch (error) {
|
|
672
|
+
console.error("[files:quota] admission:", error);
|
|
673
|
+
await releaseQuotaMarker(bucket, storage, markerKey, true);
|
|
674
|
+
return quotaUnavailable(
|
|
675
|
+
"The storage used by this account could not be verified. Retry shortly."
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
async function storageUsage(bucket, storage) {
|
|
680
|
+
try {
|
|
681
|
+
const prefixes = await allocationPrefixes(storage);
|
|
682
|
+
if (prefixes === null) return null;
|
|
683
|
+
const current = await ensureQuotaSummary(bucket, storage, prefixes);
|
|
684
|
+
return current?.summary?.usedBytes ?? null;
|
|
685
|
+
} catch (error) {
|
|
686
|
+
console.error("[files:quota] usage:", error);
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// src/server/utils/scoped-r2-files.ts
|
|
692
|
+
var CORS_HEADERS2 = { "Access-Control-Allow-Origin": "*" };
|
|
693
|
+
function fail2(status, error, code, extra) {
|
|
694
|
+
return Response.json(
|
|
695
|
+
{ error, ...code ? { code } : {}, ...extra },
|
|
696
|
+
{ status, headers: CORS_HEADERS2 }
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
function contentDisposition(originalName, disposition) {
|
|
245
700
|
const safe = originalName.replace(/[^\x20-\x7e]|["\\]/g, "_") || "download";
|
|
246
701
|
const encoded = encodeURIComponent(originalName).replace(
|
|
247
702
|
/[!'()*]/g,
|
|
248
703
|
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
249
704
|
);
|
|
250
|
-
return
|
|
705
|
+
return `${disposition}; filename="${safe}"; filename*=UTF-8''${encoded}`;
|
|
251
706
|
}
|
|
252
707
|
function sanitizeSubpath(raw) {
|
|
253
708
|
if (!raw) return "";
|
|
@@ -257,24 +712,24 @@ function sanitizeSubpath(raw) {
|
|
|
257
712
|
}
|
|
258
713
|
function overRequestBound(bytes, slack = 0) {
|
|
259
714
|
if (bytes <= MAX_UPLOAD_REQUEST_BYTES + slack) return null;
|
|
260
|
-
return
|
|
715
|
+
return fail2(413, oversizeRequestMessage(bytes), "too_large", {
|
|
261
716
|
maxBytes: MAX_UPLOAD_REQUEST_BYTES,
|
|
262
717
|
partSize: UPLOAD_PART_BYTES
|
|
263
718
|
});
|
|
264
719
|
}
|
|
265
720
|
function overCeiling(bytes) {
|
|
266
721
|
if (bytes <= MAX_APP_FILE_BYTES) return null;
|
|
267
|
-
return
|
|
722
|
+
return fail2(413, oversizeMessage(bytes), "too_large", { maxBytes: MAX_APP_FILE_BYTES });
|
|
268
723
|
}
|
|
269
724
|
function overPartBound(bytes) {
|
|
270
725
|
if (bytes <= UPLOAD_PART_BYTES) return null;
|
|
271
|
-
return
|
|
726
|
+
return fail2(413, oversizePartMessage(bytes), "too_large", { maxBytes: UPLOAD_PART_BYTES });
|
|
272
727
|
}
|
|
273
728
|
function declaredLength(request, what) {
|
|
274
729
|
const raw = request.headers.get("content-length");
|
|
275
730
|
const declared = raw === null ? NaN : Number(raw);
|
|
276
731
|
if (!Number.isSafeInteger(declared) || declared < 0) {
|
|
277
|
-
return
|
|
732
|
+
return fail2(
|
|
278
733
|
411,
|
|
279
734
|
`${what} must declare a Content-Length. A chunked or length-less body is refused: R2 cannot size a write it has no length for.`,
|
|
280
735
|
"length_required"
|
|
@@ -282,78 +737,15 @@ function declaredLength(request, what) {
|
|
|
282
737
|
}
|
|
283
738
|
return declared;
|
|
284
739
|
}
|
|
285
|
-
function
|
|
286
|
-
return key.startsWith(prefix);
|
|
740
|
+
function isKeyWithinScope(key, prefix, excludedPrefixes) {
|
|
741
|
+
return key.startsWith(prefix) && !excludedPrefixes.some((excluded) => key.startsWith(excluded));
|
|
287
742
|
}
|
|
288
|
-
|
|
289
|
-
let total = 0;
|
|
290
|
-
let cursor;
|
|
291
|
-
do {
|
|
292
|
-
const page = await bucket.list({ prefix, cursor, limit: 1e3 });
|
|
293
|
-
for (const object of page.objects) total += object.size;
|
|
294
|
-
cursor = page.truncated ? page.cursor : void 0;
|
|
295
|
-
} while (cursor !== void 0);
|
|
296
|
-
return total;
|
|
297
|
-
}
|
|
298
|
-
async function allocationUsage(bucket, storage) {
|
|
299
|
-
const siblings = storage.siblingPrefixes ? await storage.siblingPrefixes() : [];
|
|
300
|
-
if (siblings === null) return null;
|
|
301
|
-
const prefixes = [storage.prefix, ...siblings.filter((p) => p !== storage.prefix)];
|
|
302
|
-
const totals = await Promise.all(prefixes.map((prefix) => prefixUsage(bucket, prefix)));
|
|
303
|
-
return totals.reduce((sum, bytes) => sum + bytes, 0);
|
|
304
|
-
}
|
|
305
|
-
async function admitStorage(bucket, storage, incomingBytes, replacesKey) {
|
|
306
|
-
if (!storage) return null;
|
|
307
|
-
const limit = await storage.limitBytes();
|
|
308
|
-
if (limit === null) {
|
|
309
|
-
return fail(
|
|
310
|
-
503,
|
|
311
|
-
"The storage limit for this app could not be verified; nothing was uploaded. Retry shortly.",
|
|
312
|
-
"storage_limit_unavailable"
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
const stored = await allocationUsage(bucket, storage);
|
|
316
|
-
if (stored === null) {
|
|
317
|
-
return fail(
|
|
318
|
-
503,
|
|
319
|
-
"The storage already used by this account could not be measured; nothing was uploaded. Retry shortly.",
|
|
320
|
-
"storage_limit_unavailable"
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
const replaced = replacesKey === null ? 0 : (await bucket.head(replacesKey))?.size ?? 0;
|
|
324
|
-
const used = stored - replaced;
|
|
325
|
-
if (used + incomingBytes <= limit) return null;
|
|
326
|
-
return fail(409, storageQuotaMessage(incomingBytes, used, limit), "storage_quota_exceeded", {
|
|
327
|
-
usedBytes: used,
|
|
328
|
-
limitBytes: limit
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
async function admitAssembledObject(bucket, storage, key, objectBytes) {
|
|
332
|
-
if (!storage) return null;
|
|
333
|
-
const limit = await storage.limitBytes();
|
|
334
|
-
if (limit === null) {
|
|
335
|
-
console.error("[files:quota] limit unavailable at complete; keeping", key);
|
|
336
|
-
return null;
|
|
337
|
-
}
|
|
338
|
-
const stored = await allocationUsage(bucket, storage);
|
|
339
|
-
if (stored === null) {
|
|
340
|
-
console.error("[files:quota] account usage unmeasurable at complete; keeping", key);
|
|
341
|
-
return null;
|
|
342
|
-
}
|
|
343
|
-
if (stored <= limit) return null;
|
|
344
|
-
await bucket.delete(key).catch(() => void 0);
|
|
345
|
-
return fail(
|
|
346
|
-
409,
|
|
347
|
-
`${storageQuotaMessage(objectBytes, stored - objectBytes, limit)} The assembled upload was not kept.`,
|
|
348
|
-
"storage_quota_exceeded",
|
|
349
|
-
{ usedBytes: stored - objectBytes, limitBytes: limit }
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
function anchorKey(raw, prefix) {
|
|
743
|
+
function anchorKey(raw, prefix, excludedPrefixes) {
|
|
353
744
|
const safe = sanitizeSubpath(raw);
|
|
354
|
-
if (safe === null) return
|
|
355
|
-
if (!safe) return
|
|
356
|
-
|
|
745
|
+
if (safe === null) return fail2(400, "Invalid key: traversal not allowed", "invalid_key");
|
|
746
|
+
if (!safe) return fail2(400, "Invalid key: empty", "invalid_key");
|
|
747
|
+
const key = `${prefix}${safe}`;
|
|
748
|
+
return isKeyWithinScope(key, prefix, excludedPrefixes) ? { key } : fail2(403, "Access denied: key belongs to a private scope", "forbidden");
|
|
357
749
|
}
|
|
358
750
|
var VERB_ROUTES = /* @__PURE__ */ new Set([
|
|
359
751
|
"POST upload",
|
|
@@ -367,7 +759,7 @@ function isFilesVerbPath(method, subpath) {
|
|
|
367
759
|
}
|
|
368
760
|
function resolveMimeType(raw) {
|
|
369
761
|
const mimeType = raw.split(";")[0].trim().toLowerCase() || "application/octet-stream";
|
|
370
|
-
return
|
|
762
|
+
return mimeType;
|
|
371
763
|
}
|
|
372
764
|
function r2ErrorCode(error) {
|
|
373
765
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -377,7 +769,7 @@ function r2ErrorCode(error) {
|
|
|
377
769
|
function multipartFailure(error) {
|
|
378
770
|
switch (r2ErrorCode(error)) {
|
|
379
771
|
case 10024:
|
|
380
|
-
return
|
|
772
|
+
return fail2(
|
|
381
773
|
404,
|
|
382
774
|
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
383
775
|
"unknown_upload"
|
|
@@ -387,7 +779,7 @@ function multipartFailure(error) {
|
|
|
387
779
|
case 10025:
|
|
388
780
|
// InvalidPart — a named part was never uploaded
|
|
389
781
|
case 10048:
|
|
390
|
-
return
|
|
782
|
+
return fail2(
|
|
391
783
|
400,
|
|
392
784
|
"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.",
|
|
393
785
|
"bad_parts"
|
|
@@ -395,11 +787,15 @@ function multipartFailure(error) {
|
|
|
395
787
|
case 10012:
|
|
396
788
|
// MetadataTooLarge
|
|
397
789
|
case 10020:
|
|
398
|
-
return
|
|
790
|
+
return fail2(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
|
|
399
791
|
default: {
|
|
400
792
|
const message = error instanceof Error ? error.message : String(error);
|
|
401
793
|
console.error("[files:multipart] R2 error:", message);
|
|
402
|
-
return
|
|
794
|
+
return fail2(
|
|
795
|
+
503,
|
|
796
|
+
"The files service could not complete that upload. Retry shortly.",
|
|
797
|
+
"r2_error"
|
|
798
|
+
);
|
|
403
799
|
}
|
|
404
800
|
}
|
|
405
801
|
}
|
|
@@ -412,73 +808,79 @@ function createScopedR2Handler(config) {
|
|
|
412
808
|
} catch {
|
|
413
809
|
return Response.json(
|
|
414
810
|
{ error: "Invalid path encoding" },
|
|
415
|
-
{ status: 400, headers:
|
|
811
|
+
{ status: 400, headers: CORS_HEADERS2 }
|
|
416
812
|
);
|
|
417
813
|
}
|
|
418
814
|
const subpath = sanitizeSubpath(subpathRaw);
|
|
419
815
|
if (subpath === null) {
|
|
420
816
|
return Response.json(
|
|
421
817
|
{ error: "Invalid path: traversal not allowed" },
|
|
422
|
-
{ status: 400, headers:
|
|
818
|
+
{ status: 400, headers: CORS_HEADERS2 }
|
|
423
819
|
);
|
|
424
820
|
}
|
|
425
821
|
const scope = url.searchParams.get("scope") || "self";
|
|
426
822
|
if (scope !== "self" && scope !== "app") {
|
|
427
|
-
return Response.json({ error: "Invalid scope" }, { status: 400, headers:
|
|
823
|
+
return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS2 });
|
|
428
824
|
}
|
|
429
825
|
const result = config.resolvePrefix(scope, { userId: auth.userId, url });
|
|
430
826
|
if (!result.prefix) {
|
|
431
827
|
const errorMsg = result.error ?? "Invalid scope";
|
|
432
828
|
const status = errorMsg.toLowerCase().includes("auth") ? 401 : 400;
|
|
433
|
-
return Response.json({ error: errorMsg }, { status, headers:
|
|
829
|
+
return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS2 });
|
|
434
830
|
}
|
|
435
831
|
const prefix = result.prefix;
|
|
832
|
+
const excludedPrefixes = result.excludedPrefixes ?? [];
|
|
436
833
|
if (isFilesVerbPath(request.method, subpath)) {
|
|
437
834
|
if (requireAuthForMutations && !auth.userId) {
|
|
438
835
|
await request.body?.cancel().catch(() => void 0);
|
|
439
|
-
return
|
|
836
|
+
return fail2(401, "Authentication required", "unauthorized");
|
|
440
837
|
}
|
|
441
|
-
return subpath === "upload" ? handleUpload(request, url, bucket, prefix, auth, scope) : handleMultipart(request, url, bucket, prefix, auth, scope, subpath);
|
|
838
|
+
return subpath === "upload" ? handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) : handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath);
|
|
442
839
|
}
|
|
443
840
|
if (!subpath && request.method === "GET") {
|
|
444
|
-
return handleList(bucket, prefix, url, scope, auth);
|
|
841
|
+
return handleList(bucket, prefix, excludedPrefixes, url, scope, auth);
|
|
445
842
|
}
|
|
446
843
|
if (subpath && request.method === "GET") {
|
|
447
|
-
if (!
|
|
844
|
+
if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
|
|
448
845
|
return Response.json(
|
|
449
846
|
{ error: "Access denied: key outside scope" },
|
|
450
|
-
{ status: 403, headers:
|
|
847
|
+
{ status: 403, headers: CORS_HEADERS2 }
|
|
451
848
|
);
|
|
452
849
|
}
|
|
453
|
-
return handleDownload(bucket, subpath, scope);
|
|
850
|
+
return handleDownload(request, bucket, subpath, scope);
|
|
454
851
|
}
|
|
455
852
|
if (subpath && request.method === "DELETE") {
|
|
456
853
|
if (requireAuthForMutations && !auth.userId) {
|
|
457
854
|
return Response.json(
|
|
458
855
|
{ error: "Authentication required" },
|
|
459
|
-
{ status: 401, headers:
|
|
856
|
+
{ status: 401, headers: CORS_HEADERS2 }
|
|
460
857
|
);
|
|
461
858
|
}
|
|
462
|
-
if (!
|
|
859
|
+
if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
|
|
463
860
|
return Response.json(
|
|
464
861
|
{ error: "Access denied: key outside scope" },
|
|
465
|
-
{ status: 403, headers:
|
|
862
|
+
{ status: 403, headers: CORS_HEADERS2 }
|
|
466
863
|
);
|
|
467
864
|
}
|
|
468
865
|
const existed = await bucket.head(subpath) !== null;
|
|
469
|
-
if (existed)
|
|
866
|
+
if (existed) {
|
|
867
|
+
await bucket.delete(subpath);
|
|
868
|
+
await markQuotaDirty(bucket, auth.storage);
|
|
869
|
+
}
|
|
470
870
|
return Response.json(
|
|
471
871
|
{ success: true, deleted: existed ? subpath : null, existed },
|
|
472
|
-
{ headers:
|
|
872
|
+
{ headers: CORS_HEADERS2 }
|
|
473
873
|
);
|
|
474
874
|
}
|
|
475
|
-
return Response.json({ error: "Method not allowed" }, { status: 405, headers:
|
|
875
|
+
return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS2 });
|
|
476
876
|
};
|
|
477
877
|
}
|
|
478
|
-
async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
878
|
+
async function handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
|
|
479
879
|
try {
|
|
480
|
-
const
|
|
481
|
-
if (
|
|
880
|
+
const declaredBytes = declaredLength(request, "File uploads");
|
|
881
|
+
if (declaredBytes instanceof Response) return declaredBytes;
|
|
882
|
+
const tooLargeRequest = overRequestBound(declaredBytes, 8 * 1024);
|
|
883
|
+
if (tooLargeRequest) return tooLargeRequest;
|
|
482
884
|
const contentType = request.headers.get("content-type") || "";
|
|
483
885
|
let fileData;
|
|
484
886
|
let fileName;
|
|
@@ -486,7 +888,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
|
486
888
|
if (contentType.includes("multipart/form-data")) {
|
|
487
889
|
const formData = await request.formData();
|
|
488
890
|
const file = formData.get("file");
|
|
489
|
-
if (!file) return
|
|
891
|
+
if (!file) return fail2(400, "No file provided", "bad_request");
|
|
490
892
|
const tooBig = overRequestBound(file.size);
|
|
491
893
|
if (tooBig) return tooBig;
|
|
492
894
|
fileData = await file.arrayBuffer();
|
|
@@ -494,7 +896,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
|
494
896
|
rawMimeType = file.type || "application/octet-stream";
|
|
495
897
|
} else {
|
|
496
898
|
const body = await request.json();
|
|
497
|
-
if (!body.data || !body.name) return
|
|
899
|
+
if (!body.data || !body.name) return fail2(400, "Missing data or name", "bad_request");
|
|
498
900
|
const base64Data = body.data.replace(/^data:[^;]+;base64,/, "");
|
|
499
901
|
const tooBig = overRequestBound(Math.floor(base64Data.length * 3 / 4));
|
|
500
902
|
if (tooBig) return tooBig;
|
|
@@ -502,34 +904,60 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
|
502
904
|
fileName = body.name;
|
|
503
905
|
rawMimeType = body.mimeType || "application/octet-stream";
|
|
504
906
|
}
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
const located = locateUpload(url, prefix, fileName);
|
|
907
|
+
const mimeType = resolveMimeType(rawMimeType);
|
|
908
|
+
const located = locateUpload(url, prefix, excludedPrefixes, fileName);
|
|
508
909
|
if (located instanceof Response) return located;
|
|
509
910
|
const replacesKey = url.searchParams.get("key") === null ? null : located.key;
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
await bucket.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
911
|
+
const replaced = replacesKey === null ? null : await bucket.head(replacesKey);
|
|
912
|
+
const replacedBytes = replaced?.size ?? 0;
|
|
913
|
+
const admission = await admitStorage(bucket, auth.storage, fileData.byteLength, replacedBytes);
|
|
914
|
+
if (admission instanceof Response) return admission;
|
|
915
|
+
try {
|
|
916
|
+
const written = await bucket.put(located.key, fileData, {
|
|
917
|
+
// Replacement credit is valid only while the exact object that earned
|
|
918
|
+
// it still exists. A concurrent delete or overwrite loses this CAS and
|
|
919
|
+
// retries from a fresh head instead of landing uncounted bytes.
|
|
920
|
+
...replaced ? { onlyIf: { etagMatches: replaced.etag } } : {},
|
|
921
|
+
httpMetadata: { contentType: mimeType },
|
|
922
|
+
customMetadata: fileMetadata(fileName, auth.userId)
|
|
923
|
+
});
|
|
924
|
+
if (!written) {
|
|
925
|
+
await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
|
|
926
|
+
return fail2(
|
|
927
|
+
409,
|
|
928
|
+
"The file changed while this upload was being admitted. Retry the upload.",
|
|
929
|
+
"write_conflict"
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
} catch (error) {
|
|
933
|
+
await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
|
|
934
|
+
throw error;
|
|
935
|
+
}
|
|
936
|
+
await releaseQuotaMarker(
|
|
937
|
+
bucket,
|
|
938
|
+
auth.storage,
|
|
939
|
+
admission.markerKey,
|
|
940
|
+
admission.markerKey !== null || fileData.byteLength < replacedBytes
|
|
941
|
+
);
|
|
516
942
|
return storedFileJson(located.key, fileName, url, scope);
|
|
517
943
|
} catch (err) {
|
|
518
944
|
const msg = err instanceof Error ? err.message : "Upload failed";
|
|
519
945
|
console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
|
|
520
|
-
return
|
|
946
|
+
return fail2(500, msg, "upload_failed");
|
|
521
947
|
}
|
|
522
948
|
}
|
|
523
|
-
function locateUpload(url, prefix, fileName) {
|
|
949
|
+
function locateUpload(url, prefix, excludedPrefixes, fileName, generatedId) {
|
|
524
950
|
const requested = url.searchParams.get("key");
|
|
525
|
-
const subkey = requested ?? `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}
|
|
526
|
-
const anchored = anchorKey(subkey, prefix);
|
|
951
|
+
const subkey = requested ?? (generatedId ? `${generatedId}-${fileName}` : `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`);
|
|
952
|
+
const anchored = anchorKey(subkey, prefix, excludedPrefixes);
|
|
527
953
|
return anchored instanceof Response ? anchored : { key: anchored.key, subkey };
|
|
528
954
|
}
|
|
529
|
-
function fileMetadata(fileName, userId) {
|
|
955
|
+
function fileMetadata(fileName, userId, reservationId, declaredBytes) {
|
|
530
956
|
return {
|
|
531
957
|
originalName: fileName,
|
|
532
958
|
...userId ? { uploadedBy: userId } : {},
|
|
959
|
+
...reservationId ? { reservationId } : {},
|
|
960
|
+
...declaredBytes === void 0 ? {} : { declaredBytes: String(declaredBytes) },
|
|
533
961
|
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
534
962
|
};
|
|
535
963
|
}
|
|
@@ -538,7 +966,7 @@ function storedFileJson(key, name, url, scope) {
|
|
|
538
966
|
fileUrl.searchParams.set("scope", scope);
|
|
539
967
|
return Response.json(
|
|
540
968
|
{ success: true, key, url: fileUrl.toString(), name },
|
|
541
|
-
{ headers:
|
|
969
|
+
{ headers: CORS_HEADERS2 }
|
|
542
970
|
);
|
|
543
971
|
}
|
|
544
972
|
var MAX_CONTROL_BODY_BYTES = 64 * 1024;
|
|
@@ -546,24 +974,61 @@ async function readControlJson(request) {
|
|
|
546
974
|
const declared = declaredLength(request, "This request");
|
|
547
975
|
if (declared instanceof Response) return declared;
|
|
548
976
|
if (declared > MAX_CONTROL_BODY_BYTES) {
|
|
549
|
-
return
|
|
977
|
+
return fail2(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
|
|
550
978
|
}
|
|
551
979
|
let parsed;
|
|
552
980
|
try {
|
|
553
981
|
parsed = await request.json();
|
|
554
982
|
} catch {
|
|
555
|
-
return
|
|
983
|
+
return fail2(400, "Body must be JSON", "bad_request");
|
|
556
984
|
}
|
|
557
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed :
|
|
985
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail2(400, "Body must be a JSON object", "bad_request");
|
|
558
986
|
}
|
|
559
|
-
|
|
987
|
+
var REQUEST_ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
|
|
988
|
+
function resolveSession(url, prefix, excludedPrefixes) {
|
|
560
989
|
const uploadKey = url.searchParams.get("uploadKey");
|
|
561
990
|
const uploadId = url.searchParams.get("uploadId");
|
|
562
|
-
|
|
563
|
-
|
|
991
|
+
const reservationId = url.searchParams.get("reservationId");
|
|
992
|
+
if (!uploadKey || !uploadId || !reservationId) {
|
|
993
|
+
return fail2(400, "uploadKey, uploadId, and reservationId are required", "bad_request");
|
|
564
994
|
}
|
|
565
|
-
|
|
566
|
-
|
|
995
|
+
if (!REQUEST_ID_RE.test(reservationId)) {
|
|
996
|
+
return fail2(400, "reservationId is invalid", "bad_request");
|
|
997
|
+
}
|
|
998
|
+
const anchored = anchorKey(uploadKey, prefix, excludedPrefixes);
|
|
999
|
+
return anchored instanceof Response ? anchored : { key: anchored.key, uploadKey, uploadId, reservationId };
|
|
1000
|
+
}
|
|
1001
|
+
function reservationMatches(reservation, session) {
|
|
1002
|
+
return reservation.admitted && reservation.uploadId === session.uploadId && reservation.objectKey === session.key && reservation.uploadKey === session.uploadKey;
|
|
1003
|
+
}
|
|
1004
|
+
function multipartSessionJson(uploadId, uploadKey, reservationId) {
|
|
1005
|
+
return Response.json(
|
|
1006
|
+
{
|
|
1007
|
+
uploadId,
|
|
1008
|
+
uploadKey,
|
|
1009
|
+
reservationId,
|
|
1010
|
+
partSize: UPLOAD_PART_BYTES,
|
|
1011
|
+
maxParts: MAX_UPLOAD_PARTS,
|
|
1012
|
+
maxBytes: MAX_APP_FILE_BYTES
|
|
1013
|
+
},
|
|
1014
|
+
{ headers: CORS_HEADERS2 }
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
function multipartPartCount(declaredBytes) {
|
|
1018
|
+
return Math.ceil(declaredBytes / UPLOAD_PART_BYTES);
|
|
1019
|
+
}
|
|
1020
|
+
function expectedMultipartPartBytes(declaredBytes, partNumber) {
|
|
1021
|
+
const partCount = multipartPartCount(declaredBytes);
|
|
1022
|
+
if (partNumber < 1 || partNumber > partCount) return null;
|
|
1023
|
+
return partNumber === partCount ? declaredBytes - (partCount - 1) * UPLOAD_PART_BYTES : UPLOAD_PART_BYTES;
|
|
1024
|
+
}
|
|
1025
|
+
function multipartLayoutError(declaredBytes) {
|
|
1026
|
+
return fail2(
|
|
1027
|
+
400,
|
|
1028
|
+
`This upload must use ${multipartPartCount(declaredBytes)} sequential part(s) at the advertised ${formatBytes(UPLOAD_PART_BYTES)} size, except for the final remainder.`,
|
|
1029
|
+
"bad_part_layout",
|
|
1030
|
+
{ partSize: UPLOAD_PART_BYTES, partCount: multipartPartCount(declaredBytes) }
|
|
1031
|
+
);
|
|
567
1032
|
}
|
|
568
1033
|
async function streamPart(upload, partNumber, body, expectedBytes) {
|
|
569
1034
|
let observedBytes = 0;
|
|
@@ -586,57 +1051,121 @@ async function streamPart(upload, partNumber, body, expectedBytes) {
|
|
|
586
1051
|
}
|
|
587
1052
|
return { ok: true, part: uploaded.value };
|
|
588
1053
|
}
|
|
589
|
-
async function handleMultipart(request, url, bucket, prefix, auth, scope, subpath) {
|
|
1054
|
+
async function handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath) {
|
|
590
1055
|
if (subpath === "multipart" && request.method === "POST") {
|
|
591
|
-
return multipartInit(request, url, bucket, prefix, auth);
|
|
1056
|
+
return multipartInit(request, url, bucket, prefix, excludedPrefixes, auth);
|
|
1057
|
+
}
|
|
1058
|
+
if (subpath === "multipart/part") {
|
|
1059
|
+
return multipartPart(request, url, bucket, prefix, excludedPrefixes, auth);
|
|
592
1060
|
}
|
|
593
|
-
if (subpath === "multipart/part") return multipartPart(request, url, bucket, prefix);
|
|
594
1061
|
if (subpath === "multipart/complete") {
|
|
595
|
-
return multipartComplete(request, url, bucket, prefix, auth, scope);
|
|
1062
|
+
return multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope);
|
|
596
1063
|
}
|
|
597
|
-
return multipartAbort(url, bucket, prefix);
|
|
1064
|
+
return multipartAbort(url, bucket, prefix, excludedPrefixes, auth);
|
|
598
1065
|
}
|
|
599
|
-
async function multipartInit(request, url, bucket, prefix, auth) {
|
|
1066
|
+
async function multipartInit(request, url, bucket, prefix, excludedPrefixes, auth) {
|
|
600
1067
|
const body = await readControlJson(request);
|
|
601
1068
|
if (body instanceof Response) return body;
|
|
602
1069
|
const size = body.size;
|
|
603
|
-
if (!Number.isSafeInteger(size) || size
|
|
604
|
-
return
|
|
1070
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
1071
|
+
return fail2(400, "size must be a positive integer number of bytes", "bad_request");
|
|
605
1072
|
}
|
|
606
1073
|
const tooBig = overCeiling(size);
|
|
607
1074
|
if (tooBig) return tooBig;
|
|
608
1075
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
609
|
-
if (!name) return
|
|
610
|
-
const
|
|
611
|
-
if (
|
|
612
|
-
|
|
1076
|
+
if (!name) return fail2(400, "name is required", "bad_request");
|
|
1077
|
+
const requestId = typeof body.requestId === "string" ? body.requestId : "";
|
|
1078
|
+
if (!REQUEST_ID_RE.test(requestId)) {
|
|
1079
|
+
return fail2(
|
|
1080
|
+
400,
|
|
1081
|
+
"requestId must be 16\u201380 URL-safe characters and must be reused when retrying init.",
|
|
1082
|
+
"bad_request"
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
const mimeType = resolveMimeType(typeof body.mimeType === "string" ? body.mimeType : "");
|
|
1086
|
+
const located = locateUpload(url, prefix, excludedPrefixes, name, requestId);
|
|
613
1087
|
if (located instanceof Response) return located;
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
1088
|
+
const completed = await bucket.head(located.key);
|
|
1089
|
+
if (completed?.customMetadata?.reservationId === requestId) {
|
|
1090
|
+
return fail2(
|
|
1091
|
+
409,
|
|
1092
|
+
"requestId already completed an upload; start the new upload with a fresh requestId.",
|
|
1093
|
+
"request_id_completed"
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
const existing = await readMultipartReservation(bucket, auth.storage, requestId);
|
|
1097
|
+
if (existing === "invalid") {
|
|
1098
|
+
return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
|
|
1099
|
+
}
|
|
1100
|
+
if (existing) {
|
|
1101
|
+
const sameRequest = existing.objectKey === located.key && existing.uploadKey === located.subkey && existing.declaredBytes === size;
|
|
1102
|
+
return sameRequest && existing.admitted ? multipartSessionJson(existing.uploadId, existing.uploadKey, requestId) : sameRequest ? quotaUnavailable("This upload is still being initialized. Retry shortly.") : fail2(
|
|
1103
|
+
409,
|
|
1104
|
+
"requestId already belongs to a different multipart upload.",
|
|
1105
|
+
"request_id_conflict"
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
617
1108
|
let upload;
|
|
618
1109
|
try {
|
|
619
1110
|
upload = await bucket.createMultipartUpload(located.key, {
|
|
620
|
-
httpMetadata: { contentType:
|
|
621
|
-
customMetadata: fileMetadata(name, auth.userId)
|
|
1111
|
+
httpMetadata: { contentType: mimeType },
|
|
1112
|
+
customMetadata: fileMetadata(name, auth.userId, requestId, size)
|
|
622
1113
|
});
|
|
623
1114
|
} catch (error) {
|
|
624
1115
|
return multipartFailure(error);
|
|
625
1116
|
}
|
|
626
|
-
|
|
627
|
-
|
|
1117
|
+
const marker = auth.storage ? {
|
|
1118
|
+
key: multipartMarkerKey(auth.storage, requestId),
|
|
1119
|
+
body: JSON.stringify({
|
|
1120
|
+
version: 1,
|
|
1121
|
+
reservationId: requestId,
|
|
628
1122
|
uploadId: upload.uploadId,
|
|
629
|
-
|
|
630
|
-
// `?key=`, so the mount that anchors keys anchors this one too.
|
|
1123
|
+
objectKey: located.key,
|
|
631
1124
|
uploadKey: located.subkey,
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1125
|
+
admitted: false,
|
|
1126
|
+
declaredBytes: size
|
|
1127
|
+
}),
|
|
1128
|
+
metadata: {
|
|
1129
|
+
quotaKind: "multipart",
|
|
1130
|
+
active: "false"
|
|
1131
|
+
}
|
|
1132
|
+
} : void 0;
|
|
1133
|
+
const reservedBytes = Math.max(size, 5 * 1024 * 1024);
|
|
1134
|
+
const admission = await admitStorage(bucket, auth.storage, reservedBytes, 0, marker);
|
|
1135
|
+
if (admission instanceof Response) {
|
|
1136
|
+
await upload.abort().catch(() => void 0);
|
|
1137
|
+
if (admission.status === 409 && auth.storage) {
|
|
1138
|
+
const winner = await readMultipartReservation(bucket, auth.storage, requestId);
|
|
1139
|
+
if (winner && winner !== "invalid" && winner.objectKey === located.key && winner.uploadKey === located.subkey && winner.declaredBytes === size && winner.admitted) {
|
|
1140
|
+
return multipartSessionJson(winner.uploadId, winner.uploadKey, requestId);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return admission;
|
|
1144
|
+
}
|
|
1145
|
+
if (auth.storage && !await activateMultipartReservation(bucket, auth.storage, requestId)) {
|
|
1146
|
+
await upload.abort().catch(() => void 0);
|
|
1147
|
+
await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
|
|
1148
|
+
return quotaUnavailable("This upload reservation could not be finalized. Retry shortly.");
|
|
1149
|
+
}
|
|
1150
|
+
return multipartSessionJson(upload.uploadId, located.subkey, requestId);
|
|
638
1151
|
}
|
|
639
|
-
async function
|
|
1152
|
+
async function abandonMultipart(bucket, session, auth, reservation) {
|
|
1153
|
+
let result = "aborted";
|
|
1154
|
+
try {
|
|
1155
|
+
await bucket.resumeMultipartUpload(session.key, session.uploadId).abort();
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
if (r2ErrorCode(error) !== 10024) {
|
|
1158
|
+
console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
|
|
1159
|
+
return "failed";
|
|
1160
|
+
}
|
|
1161
|
+
result = "absent";
|
|
1162
|
+
}
|
|
1163
|
+
if (reservation) {
|
|
1164
|
+
if (!await releaseQuotaMarker(bucket, auth.storage, reservation.key, true)) return "failed";
|
|
1165
|
+
}
|
|
1166
|
+
return result;
|
|
1167
|
+
}
|
|
1168
|
+
async function multipartPart(request, url, bucket, prefix, excludedPrefixes, auth) {
|
|
640
1169
|
const refuse = async (response) => {
|
|
641
1170
|
await request.body?.cancel().catch(() => void 0);
|
|
642
1171
|
return response;
|
|
@@ -648,16 +1177,60 @@ async function multipartPart(request, url, bucket, prefix) {
|
|
|
648
1177
|
const partNumber = Number(url.searchParams.get("partNumber"));
|
|
649
1178
|
if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
|
|
650
1179
|
return refuse(
|
|
651
|
-
|
|
1180
|
+
fail2(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
|
|
652
1181
|
);
|
|
653
1182
|
}
|
|
654
|
-
const session = resolveSession(url, prefix);
|
|
1183
|
+
const session = resolveSession(url, prefix, excludedPrefixes);
|
|
655
1184
|
if (session instanceof Response) return refuse(session);
|
|
656
|
-
if (!request.body) return
|
|
1185
|
+
if (!request.body) return fail2(400, "A part body is required", "bad_request");
|
|
1186
|
+
const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
|
|
1187
|
+
if (reservation === "invalid") {
|
|
1188
|
+
return refuse(quotaUnavailable("This upload reservation could not be read. Retry shortly."));
|
|
1189
|
+
}
|
|
1190
|
+
if (auth.storage && reservation && !reservationMatches(reservation, session)) {
|
|
1191
|
+
return refuse(
|
|
1192
|
+
fail2(
|
|
1193
|
+
404,
|
|
1194
|
+
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
1195
|
+
"unknown_upload"
|
|
1196
|
+
)
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
if (auth.storage && !reservation) {
|
|
1200
|
+
return refuse(
|
|
1201
|
+
fail2(
|
|
1202
|
+
404,
|
|
1203
|
+
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
1204
|
+
"unknown_upload"
|
|
1205
|
+
)
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
if (reservation) {
|
|
1209
|
+
const expectedBytes = expectedMultipartPartBytes(reservation.declaredBytes, partNumber);
|
|
1210
|
+
if (expectedBytes === null || declaredBytes !== expectedBytes) {
|
|
1211
|
+
return refuse(multipartLayoutError(reservation.declaredBytes));
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
const refreshed = await refreshMultipartReservation(
|
|
1215
|
+
bucket,
|
|
1216
|
+
auth.storage,
|
|
1217
|
+
session.reservationId,
|
|
1218
|
+
session.uploadId,
|
|
1219
|
+
session.key
|
|
1220
|
+
);
|
|
1221
|
+
if (auth.storage && (refreshed === null || refreshed === "invalid")) {
|
|
1222
|
+
return refuse(quotaUnavailable("This upload reservation is missing or invalid."));
|
|
1223
|
+
}
|
|
1224
|
+
if (refreshed === "busy") {
|
|
1225
|
+
return refuse(quotaUnavailable("This upload reservation is busy. Retry the part shortly."));
|
|
1226
|
+
}
|
|
657
1227
|
const upload = bucket.resumeMultipartUpload(session.key, session.uploadId);
|
|
658
1228
|
const stored = await streamPart(upload, partNumber, request.body, declaredBytes);
|
|
659
1229
|
if (!stored.ok) {
|
|
660
|
-
|
|
1230
|
+
if (r2ErrorCode(stored.error) === 10024 && reservation) {
|
|
1231
|
+
await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
1232
|
+
}
|
|
1233
|
+
return stored.lengthMismatch ? fail2(
|
|
661
1234
|
400,
|
|
662
1235
|
`Part ${partNumber} declared ${declaredBytes} bytes but its body did not carry them`,
|
|
663
1236
|
"bad_request"
|
|
@@ -665,29 +1238,98 @@ async function multipartPart(request, url, bucket, prefix) {
|
|
|
665
1238
|
}
|
|
666
1239
|
return Response.json(
|
|
667
1240
|
{ partNumber: stored.part.partNumber, etag: stored.part.etag },
|
|
668
|
-
{ headers:
|
|
1241
|
+
{ headers: CORS_HEADERS2 }
|
|
669
1242
|
);
|
|
670
1243
|
}
|
|
671
|
-
async function
|
|
1244
|
+
async function completedReservationObject(bucket, session) {
|
|
1245
|
+
const object = await bucket.head(session.key);
|
|
1246
|
+
return object?.customMetadata?.reservationId === session.reservationId ? object : null;
|
|
1247
|
+
}
|
|
1248
|
+
async function finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope) {
|
|
1249
|
+
const declaredMetadata = Number(object.customMetadata?.declaredBytes);
|
|
1250
|
+
const declaredBytes = reservation?.declaredBytes ?? (Number.isSafeInteger(declaredMetadata) && declaredMetadata >= 0 ? declaredMetadata : MAX_APP_FILE_BYTES);
|
|
1251
|
+
const refusal = overCeiling(object.size) ?? (object.size > declaredBytes ? fail2(
|
|
1252
|
+
400,
|
|
1253
|
+
`The assembled upload exceeded its declared ${declaredBytes}-byte size and was not kept.`,
|
|
1254
|
+
"declared_size_exceeded"
|
|
1255
|
+
) : null);
|
|
1256
|
+
if (refusal) {
|
|
1257
|
+
let remaining;
|
|
1258
|
+
try {
|
|
1259
|
+
const current = await bucket.head(session.key);
|
|
1260
|
+
if (current?.etag === object.etag) await bucket.delete(session.key);
|
|
1261
|
+
remaining = await bucket.head(session.key);
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
console.error("[files:multipart] invalid object cleanup:", error);
|
|
1264
|
+
return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
|
|
1265
|
+
}
|
|
1266
|
+
if (remaining?.etag === object.etag) {
|
|
1267
|
+
return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
|
|
1268
|
+
}
|
|
1269
|
+
if (reservation) {
|
|
1270
|
+
const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
1271
|
+
if (!cleaned) return quotaUnavailable("The upload was removed, but cleanup must be retried.");
|
|
1272
|
+
}
|
|
1273
|
+
return refusal;
|
|
1274
|
+
}
|
|
1275
|
+
if (reservation) {
|
|
1276
|
+
const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
1277
|
+
if (!cleaned) {
|
|
1278
|
+
return quotaUnavailable("The file was stored, but its reservation cleanup must be retried.");
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
const name = object.customMetadata?.originalName ?? session.uploadKey.split("/").pop() ?? "file";
|
|
1282
|
+
return storedFileJson(session.key, name, url, scope);
|
|
1283
|
+
}
|
|
1284
|
+
async function multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
|
|
672
1285
|
const body = await readControlJson(request);
|
|
673
1286
|
if (body instanceof Response) return body;
|
|
674
|
-
const session = resolveSession(url, prefix);
|
|
1287
|
+
const session = resolveSession(url, prefix, excludedPrefixes);
|
|
675
1288
|
if (session instanceof Response) return session;
|
|
1289
|
+
const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
|
|
1290
|
+
if (reservation === "invalid") {
|
|
1291
|
+
return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
|
|
1292
|
+
}
|
|
1293
|
+
if (auth.storage && reservation && !reservationMatches(reservation, session)) {
|
|
1294
|
+
return fail2(
|
|
1295
|
+
404,
|
|
1296
|
+
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
1297
|
+
"unknown_upload"
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
if (auth.storage && !reservation) {
|
|
1301
|
+
const completed = await completedReservationObject(bucket, session);
|
|
1302
|
+
if (completed) {
|
|
1303
|
+
return finishCompletedMultipart(bucket, session, null, completed, auth, url, scope);
|
|
1304
|
+
}
|
|
1305
|
+
await abandonMultipart(bucket, session, auth, null);
|
|
1306
|
+
return quotaUnavailable("This upload reservation is missing or does not match.");
|
|
1307
|
+
}
|
|
676
1308
|
const raw = body.parts;
|
|
677
1309
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
678
|
-
return
|
|
1310
|
+
return fail2(400, "parts must be a non-empty array", "bad_request");
|
|
679
1311
|
}
|
|
680
1312
|
if (raw.length > MAX_UPLOAD_PARTS) {
|
|
681
|
-
return
|
|
1313
|
+
return fail2(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
|
|
1314
|
+
}
|
|
1315
|
+
if (reservation && raw.length !== multipartPartCount(reservation.declaredBytes)) {
|
|
1316
|
+
return multipartLayoutError(reservation.declaredBytes);
|
|
682
1317
|
}
|
|
683
1318
|
const parts = [];
|
|
684
1319
|
for (let index = 0; index < raw.length; index++) {
|
|
685
1320
|
const { partNumber, etag } = raw[index] ?? {};
|
|
686
|
-
if (!Number.isSafeInteger(partNumber) || partNumber < 1) {
|
|
687
|
-
return
|
|
1321
|
+
if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
|
|
1322
|
+
return fail2(
|
|
1323
|
+
400,
|
|
1324
|
+
`parts[${index}].partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`,
|
|
1325
|
+
"bad_request"
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
if (reservation && partNumber !== index + 1) {
|
|
1329
|
+
return multipartLayoutError(reservation.declaredBytes);
|
|
688
1330
|
}
|
|
689
1331
|
if (typeof etag !== "string" || !etag) {
|
|
690
|
-
return
|
|
1332
|
+
return fail2(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
|
|
691
1333
|
}
|
|
692
1334
|
parts.push({ partNumber, etag });
|
|
693
1335
|
}
|
|
@@ -695,35 +1337,62 @@ async function multipartComplete(request, url, bucket, prefix, auth, scope) {
|
|
|
695
1337
|
try {
|
|
696
1338
|
object = await bucket.resumeMultipartUpload(session.key, session.uploadId).complete(parts);
|
|
697
1339
|
} catch (error) {
|
|
1340
|
+
const completed = await completedReservationObject(bucket, session);
|
|
1341
|
+
if (completed) {
|
|
1342
|
+
return finishCompletedMultipart(bucket, session, reservation, completed, auth, url, scope);
|
|
1343
|
+
}
|
|
1344
|
+
if (r2ErrorCode(error) === 10024 && reservation) {
|
|
1345
|
+
await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
1346
|
+
}
|
|
698
1347
|
return multipartFailure(error);
|
|
699
1348
|
}
|
|
700
|
-
|
|
701
|
-
if (tooBig) {
|
|
702
|
-
await bucket.delete(session.key).catch(() => void 0);
|
|
703
|
-
return tooBig;
|
|
704
|
-
}
|
|
705
|
-
const overQuota = await admitAssembledObject(bucket, auth.storage, session.key, object.size);
|
|
706
|
-
if (overQuota) return overQuota;
|
|
707
|
-
const name = object.customMetadata?.originalName ?? session.key.split("/").pop() ?? "file";
|
|
708
|
-
return storedFileJson(session.key, name, url, scope);
|
|
1349
|
+
return finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope);
|
|
709
1350
|
}
|
|
710
|
-
async function multipartAbort(url, bucket, prefix) {
|
|
711
|
-
const session = resolveSession(url, prefix);
|
|
1351
|
+
async function multipartAbort(url, bucket, prefix, excludedPrefixes, auth) {
|
|
1352
|
+
const session = resolveSession(url, prefix, excludedPrefixes);
|
|
712
1353
|
if (session instanceof Response) return session;
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
|
|
717
|
-
return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS });
|
|
1354
|
+
const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
|
|
1355
|
+
if (reservation === "invalid") {
|
|
1356
|
+
return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS2 });
|
|
718
1357
|
}
|
|
719
|
-
|
|
1358
|
+
if (auth.storage && reservation && !reservationMatches(reservation, session)) {
|
|
1359
|
+
return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS2 });
|
|
1360
|
+
}
|
|
1361
|
+
const result = await abandonMultipart(bucket, session, auth, reservation || null);
|
|
1362
|
+
return Response.json({ success: true, aborted: result === "aborted" }, { headers: CORS_HEADERS2 });
|
|
720
1363
|
}
|
|
721
|
-
async function handleList(bucket, prefix, url, scope, auth) {
|
|
1364
|
+
async function handleList(bucket, prefix, excludedPrefixes, url, scope, auth) {
|
|
722
1365
|
const userPrefix = url.searchParams.get("prefix") || "";
|
|
723
1366
|
const listPrefix = `${prefix}${userPrefix}`;
|
|
724
|
-
const
|
|
1367
|
+
const requestedLimit = Number(url.searchParams.get("limit") ?? 100);
|
|
1368
|
+
const limit = Number.isSafeInteger(requestedLimit) ? Math.min(1e3, Math.max(1, requestedLimit)) : 100;
|
|
1369
|
+
if (excludedPrefixes.some((excluded) => listPrefix.startsWith(excluded))) {
|
|
1370
|
+
return Response.json({ files: [], truncated: false }, { headers: CORS_HEADERS2 });
|
|
1371
|
+
}
|
|
725
1372
|
const listed = await bucket.list({ prefix: listPrefix, limit });
|
|
726
|
-
const
|
|
1373
|
+
const visible = listed.objects.filter(
|
|
1374
|
+
(object) => isKeyWithinScope(object.key, prefix, excludedPrefixes)
|
|
1375
|
+
);
|
|
1376
|
+
const seen = new Set(visible.map((object) => object.key));
|
|
1377
|
+
let truncated = listed.truncated;
|
|
1378
|
+
for (const excluded of excludedPrefixes.filter((candidate) => candidate.startsWith(listPrefix))) {
|
|
1379
|
+
if (visible.length >= limit) break;
|
|
1380
|
+
const excludedEnd = `${excluded}\u{10FFFF}`;
|
|
1381
|
+
const lastListedKey = listed.objects.at(-1)?.key;
|
|
1382
|
+
const after = await bucket.list({
|
|
1383
|
+
prefix: listPrefix,
|
|
1384
|
+
startAfter: lastListedKey && lastListedKey > excludedEnd ? lastListedKey : excludedEnd,
|
|
1385
|
+
limit: limit - visible.length
|
|
1386
|
+
});
|
|
1387
|
+
for (const object of after.objects) {
|
|
1388
|
+
if (!seen.has(object.key) && isKeyWithinScope(object.key, prefix, excludedPrefixes)) {
|
|
1389
|
+
seen.add(object.key);
|
|
1390
|
+
visible.push(object);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
truncated = after.truncated;
|
|
1394
|
+
}
|
|
1395
|
+
const files = visible.slice(0, limit).map((obj) => {
|
|
727
1396
|
const fileUrl = new URL(`/api/files/${encodeKeyPath(obj.key)}`, url.origin);
|
|
728
1397
|
fileUrl.searchParams.set("scope", scope);
|
|
729
1398
|
return {
|
|
@@ -735,32 +1404,41 @@ async function handleList(bucket, prefix, url, scope, auth) {
|
|
|
735
1404
|
};
|
|
736
1405
|
});
|
|
737
1406
|
let storage;
|
|
738
|
-
if (auth.storage) {
|
|
739
|
-
const usedBytes = await
|
|
1407
|
+
if (auth.storage && auth.includeStorageUsage) {
|
|
1408
|
+
const usedBytes = await storageUsage(bucket, auth.storage);
|
|
740
1409
|
if (usedBytes !== null) storage = { usedBytes };
|
|
741
1410
|
const limitBytes = await auth.storage.limitBytes();
|
|
742
1411
|
if (storage && limitBytes !== null) storage.limitBytes = limitBytes;
|
|
743
1412
|
}
|
|
744
1413
|
return Response.json(
|
|
745
|
-
{ files, truncated
|
|
746
|
-
{ headers:
|
|
1414
|
+
{ files, truncated, ...storage ? { storage } : {} },
|
|
1415
|
+
{ headers: CORS_HEADERS2 }
|
|
747
1416
|
);
|
|
748
1417
|
}
|
|
749
|
-
async function handleDownload(bucket, key, scope) {
|
|
1418
|
+
async function handleDownload(request, bucket, key, scope) {
|
|
750
1419
|
const object = await bucket.get(key);
|
|
751
1420
|
if (!object) {
|
|
752
|
-
return Response.json({ error: "File not found" }, { status: 404, headers:
|
|
1421
|
+
return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS2 });
|
|
753
1422
|
}
|
|
754
1423
|
const headers = new Headers();
|
|
755
|
-
|
|
1424
|
+
const storedType = resolveMimeType(object.httpMetadata?.contentType ?? "");
|
|
1425
|
+
const mustDownload = DANGEROUS_MIME_TYPES.has(storedType);
|
|
1426
|
+
headers.set("Content-Type", mustDownload ? "application/octet-stream" : storedType);
|
|
756
1427
|
headers.set("X-Content-Type-Options", "nosniff");
|
|
1428
|
+
headers.set("ETag", object.httpEtag);
|
|
757
1429
|
headers.set(
|
|
758
1430
|
"Cache-Control",
|
|
759
|
-
scope === "
|
|
1431
|
+
scope === "self" ? "private, no-store" : "public, max-age=0, must-revalidate"
|
|
760
1432
|
);
|
|
761
1433
|
headers.set("Access-Control-Allow-Origin", "*");
|
|
762
|
-
|
|
763
|
-
|
|
1434
|
+
const originalName = object.customMetadata?.originalName ?? key.split("/").pop() ?? "download";
|
|
1435
|
+
headers.set(
|
|
1436
|
+
"Content-Disposition",
|
|
1437
|
+
contentDisposition(originalName, mustDownload ? "attachment" : "inline")
|
|
1438
|
+
);
|
|
1439
|
+
if (mustDownload) headers.set("Content-Security-Policy", "sandbox; default-src 'none'");
|
|
1440
|
+
if (scope === "app" && request.headers.get("if-none-match") === object.httpEtag) {
|
|
1441
|
+
return new Response(null, { status: 304, headers });
|
|
764
1442
|
}
|
|
765
1443
|
return new Response(object.body, { headers });
|
|
766
1444
|
}
|
|
@@ -3756,7 +4434,7 @@ async function handleSetRole(ctx, ws, attachment, payload) {
|
|
|
3756
4434
|
|
|
3757
4435
|
// src/server/handlers/tools-api.ts
|
|
3758
4436
|
import * as Y3 from "yjs";
|
|
3759
|
-
var
|
|
4437
|
+
var CORS_HEADERS3 = {
|
|
3760
4438
|
"Access-Control-Allow-Origin": "*",
|
|
3761
4439
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
3762
4440
|
"Access-Control-Allow-Headers": "Content-Type, X-User-Id, X-App-Action",
|
|
@@ -3764,23 +4442,23 @@ var CORS_HEADERS2 = {
|
|
|
3764
4442
|
};
|
|
3765
4443
|
async function handleToolsRequest(ctx, request, path) {
|
|
3766
4444
|
if (request.method === "OPTIONS") {
|
|
3767
|
-
return new Response(null, { headers:
|
|
4445
|
+
return new Response(null, { headers: CORS_HEADERS3 });
|
|
3768
4446
|
}
|
|
3769
4447
|
if (path === "tools/list") {
|
|
3770
|
-
return Response.json({ tools: BUILT_IN_TOOLS }, { headers:
|
|
4448
|
+
return Response.json({ tools: BUILT_IN_TOOLS }, { headers: CORS_HEADERS3 });
|
|
3771
4449
|
}
|
|
3772
4450
|
if (path.startsWith("tools/describe/")) {
|
|
3773
4451
|
const toolName = path.replace("tools/describe/", "");
|
|
3774
4452
|
const tool2 = BUILT_IN_TOOLS.find((t) => t.name === toolName);
|
|
3775
4453
|
if (!tool2) {
|
|
3776
|
-
return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers:
|
|
4454
|
+
return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers: CORS_HEADERS3 });
|
|
3777
4455
|
}
|
|
3778
|
-
return Response.json({ tool: tool2 }, { headers:
|
|
4456
|
+
return Response.json({ tool: tool2 }, { headers: CORS_HEADERS3 });
|
|
3779
4457
|
}
|
|
3780
4458
|
if (path.startsWith("tools/execute") && request.method === "POST") {
|
|
3781
4459
|
return handleToolExecute(ctx, request);
|
|
3782
4460
|
}
|
|
3783
|
-
return Response.json({ error: "Not found" }, { status: 404, headers:
|
|
4461
|
+
return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS3 });
|
|
3784
4462
|
}
|
|
3785
4463
|
async function handleToolExecute(ctx, request) {
|
|
3786
4464
|
let body;
|
|
@@ -3789,14 +4467,14 @@ async function handleToolExecute(ctx, request) {
|
|
|
3789
4467
|
} catch {
|
|
3790
4468
|
return Response.json(
|
|
3791
4469
|
{ success: false, error: "Invalid JSON body" },
|
|
3792
|
-
{ status: 400, headers:
|
|
4470
|
+
{ status: 400, headers: CORS_HEADERS3 }
|
|
3793
4471
|
);
|
|
3794
4472
|
}
|
|
3795
4473
|
const { tool: tool2, params = {} } = body;
|
|
3796
4474
|
if (!tool2) {
|
|
3797
4475
|
return Response.json(
|
|
3798
4476
|
{ success: false, error: 'Missing "tool" in request body' },
|
|
3799
|
-
{ status: 400, headers:
|
|
4477
|
+
{ status: 400, headers: CORS_HEADERS3 }
|
|
3800
4478
|
);
|
|
3801
4479
|
}
|
|
3802
4480
|
const userId = request.headers.get("x-user-id") ?? "";
|
|
@@ -3813,7 +4491,7 @@ async function handleToolExecute(ctx, request) {
|
|
|
3813
4491
|
if (tool2.startsWith("yjs.")) {
|
|
3814
4492
|
const result2 = executeYjsTool(ctx, tool2, params, userId, userRole);
|
|
3815
4493
|
const status2 = result2.success ? 200 : result2.error?.includes("not found") ? 404 : 400;
|
|
3816
|
-
return Response.json(result2, { status: status2, headers:
|
|
4494
|
+
return Response.json(result2, { status: status2, headers: CORS_HEADERS3 });
|
|
3817
4495
|
}
|
|
3818
4496
|
const recordCtx = {
|
|
3819
4497
|
sql: ctx.sql,
|
|
@@ -3824,7 +4502,7 @@ async function handleToolExecute(ctx, request) {
|
|
|
3824
4502
|
};
|
|
3825
4503
|
const result = await executeTool2(recordCtx, tool2, params, userId, userRole, isAppAction);
|
|
3826
4504
|
const status = result.success ? 200 : result.error?.includes("not found") ? 404 : 400;
|
|
3827
|
-
return Response.json(result, { status, headers:
|
|
4505
|
+
return Response.json(result, { status, headers: CORS_HEADERS3 });
|
|
3828
4506
|
}
|
|
3829
4507
|
async function executeTool2(ctx, toolName, params, userId, userRole, skipUserRbac) {
|
|
3830
4508
|
switch (toolName) {
|
|
@@ -4060,7 +4738,7 @@ function generateId() {
|
|
|
4060
4738
|
}
|
|
4061
4739
|
|
|
4062
4740
|
// src/server/handlers/debug-api.ts
|
|
4063
|
-
var
|
|
4741
|
+
var CORS_HEADERS4 = {
|
|
4064
4742
|
"Access-Control-Allow-Origin": "*",
|
|
4065
4743
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
4066
4744
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
@@ -4068,7 +4746,7 @@ var CORS_HEADERS3 = {
|
|
|
4068
4746
|
};
|
|
4069
4747
|
async function handleApiRequest(ctx, request, url) {
|
|
4070
4748
|
if (request.method === "OPTIONS") {
|
|
4071
|
-
return new Response(null, { headers:
|
|
4749
|
+
return new Response(null, { headers: CORS_HEADERS4 });
|
|
4072
4750
|
}
|
|
4073
4751
|
const path = url.pathname.replace("/api/", "");
|
|
4074
4752
|
if (path.startsWith("tools/")) {
|
|
@@ -4092,13 +4770,13 @@ async function handleApiRequest(ctx, request, url) {
|
|
|
4092
4770
|
if (path === "debug/query") {
|
|
4093
4771
|
return handleDebugQuery(ctx, url);
|
|
4094
4772
|
}
|
|
4095
|
-
return Response.json({ error: "Not found" }, { status: 404, headers:
|
|
4773
|
+
return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS4 });
|
|
4096
4774
|
}
|
|
4097
4775
|
function handleDebugUserRole(ctx, url) {
|
|
4098
4776
|
const email = url.searchParams.get("email");
|
|
4099
4777
|
const userId = url.searchParams.get("userId");
|
|
4100
4778
|
if (!email && !userId) {
|
|
4101
|
-
return Response.json({ error: "email or userId parameter required" }, { status: 400, headers:
|
|
4779
|
+
return Response.json({ error: "email or userId parameter required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4102
4780
|
}
|
|
4103
4781
|
const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
|
|
4104
4782
|
const user = userId ? users.find((u) => u.id === userId) : users.find((u) => u.email === email);
|
|
@@ -4106,22 +4784,22 @@ function handleDebugUserRole(ctx, url) {
|
|
|
4106
4784
|
return Response.json({
|
|
4107
4785
|
error: `User not found`,
|
|
4108
4786
|
registeredUsers: users.map((u) => ({ id: u.id, email: u.email, role: u.role }))
|
|
4109
|
-
}, { status: 404, headers:
|
|
4787
|
+
}, { status: 404, headers: CORS_HEADERS4 });
|
|
4110
4788
|
}
|
|
4111
4789
|
return Response.json({
|
|
4112
4790
|
userId: user.id,
|
|
4113
4791
|
email: user.email,
|
|
4114
4792
|
name: user.name,
|
|
4115
4793
|
role: user.role
|
|
4116
|
-
}, { headers:
|
|
4794
|
+
}, { headers: CORS_HEADERS4 });
|
|
4117
4795
|
}
|
|
4118
4796
|
async function handleDebugSetRole(ctx, request) {
|
|
4119
4797
|
const body = await request.json();
|
|
4120
4798
|
if (!body.role) {
|
|
4121
|
-
return Response.json({ error: "role is required" }, { status: 400, headers:
|
|
4799
|
+
return Response.json({ error: "role is required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4122
4800
|
}
|
|
4123
4801
|
if (!body.email && !body.userId) {
|
|
4124
|
-
return Response.json({ error: "email or userId is required" }, { status: 400, headers:
|
|
4802
|
+
return Response.json({ error: "email or userId is required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4125
4803
|
}
|
|
4126
4804
|
const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
|
|
4127
4805
|
const user = body.userId ? users.find((u) => u.id === body.userId) : users.find((u) => u.email === body.email);
|
|
@@ -4129,7 +4807,7 @@ async function handleDebugSetRole(ctx, request) {
|
|
|
4129
4807
|
return Response.json({
|
|
4130
4808
|
error: "User not found",
|
|
4131
4809
|
registeredUsers: users.map((u) => ({ id: u.id, email: u.email, role: u.role }))
|
|
4132
|
-
}, { status: 404, headers:
|
|
4810
|
+
}, { status: 404, headers: CORS_HEADERS4 });
|
|
4133
4811
|
}
|
|
4134
4812
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4135
4813
|
ctx.sql.exec(
|
|
@@ -4143,7 +4821,7 @@ async function handleDebugSetRole(ctx, request) {
|
|
|
4143
4821
|
email: user.email,
|
|
4144
4822
|
previousRole: user.role,
|
|
4145
4823
|
newRole: body.role
|
|
4146
|
-
}, { headers:
|
|
4824
|
+
}, { headers: CORS_HEADERS4 });
|
|
4147
4825
|
}
|
|
4148
4826
|
function handleDebugStatus(ctx) {
|
|
4149
4827
|
const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
|
|
@@ -4161,7 +4839,7 @@ function handleDebugStatus(ctx) {
|
|
|
4161
4839
|
}
|
|
4162
4840
|
return Response.json(
|
|
4163
4841
|
{ users, schemas: schemas.map((s) => s.name), connections },
|
|
4164
|
-
{ headers:
|
|
4842
|
+
{ headers: CORS_HEADERS4 }
|
|
4165
4843
|
);
|
|
4166
4844
|
}
|
|
4167
4845
|
function handleDebugRecords(ctx, path) {
|
|
@@ -4180,12 +4858,12 @@ function handleDebugRecords(ctx, path) {
|
|
|
4180
4858
|
createdAt: r._created_at
|
|
4181
4859
|
};
|
|
4182
4860
|
});
|
|
4183
|
-
return Response.json({ collection, records }, { headers:
|
|
4861
|
+
return Response.json({ collection, records }, { headers: CORS_HEADERS4 });
|
|
4184
4862
|
} catch (error) {
|
|
4185
4863
|
return Response.json({
|
|
4186
4864
|
error: error instanceof Error ? error.message : "Query failed",
|
|
4187
4865
|
collection
|
|
4188
|
-
}, { status: 400, headers:
|
|
4866
|
+
}, { status: 400, headers: CORS_HEADERS4 });
|
|
4189
4867
|
}
|
|
4190
4868
|
}
|
|
4191
4869
|
function handleDebugQuery(ctx, url) {
|
|
@@ -4193,17 +4871,17 @@ function handleDebugQuery(ctx, url) {
|
|
|
4193
4871
|
const userId = url.searchParams.get("userId");
|
|
4194
4872
|
const role = url.searchParams.get("role");
|
|
4195
4873
|
if (!collection) {
|
|
4196
|
-
return Response.json({ error: "collection parameter required" }, { status: 400, headers:
|
|
4874
|
+
return Response.json({ error: "collection parameter required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4197
4875
|
}
|
|
4198
4876
|
if (!userId) {
|
|
4199
|
-
return Response.json({ error: "userId parameter required" }, { status: 400, headers:
|
|
4877
|
+
return Response.json({ error: "userId parameter required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4200
4878
|
}
|
|
4201
4879
|
if (!role) {
|
|
4202
|
-
return Response.json({ error: "role parameter required" }, { status: 400, headers:
|
|
4880
|
+
return Response.json({ error: "role parameter required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4203
4881
|
}
|
|
4204
4882
|
const user = getUser(ctx.sql, userId, ctx.schemaRegistry);
|
|
4205
4883
|
if (!user) {
|
|
4206
|
-
return Response.json({ error: `User not found: ${userId}` }, { status: 404, headers:
|
|
4884
|
+
return Response.json({ error: `User not found: ${userId}` }, { status: 404, headers: CORS_HEADERS4 });
|
|
4207
4885
|
}
|
|
4208
4886
|
const query = { collection };
|
|
4209
4887
|
const where = {};
|
|
@@ -4222,7 +4900,7 @@ function handleDebugQuery(ctx, url) {
|
|
|
4222
4900
|
asUser: { id: userId, name: user.name, actualRole: user.role, testingAsRole: role },
|
|
4223
4901
|
recordCount: records.length,
|
|
4224
4902
|
records
|
|
4225
|
-
}, { headers:
|
|
4903
|
+
}, { headers: CORS_HEADERS4 });
|
|
4226
4904
|
}
|
|
4227
4905
|
async function handleDebugSql(ctx, request, url) {
|
|
4228
4906
|
let sql;
|
|
@@ -4245,7 +4923,7 @@ async function handleDebugSql(ctx, request, url) {
|
|
|
4245
4923
|
"DESCRIBE <table> - show table schema"
|
|
4246
4924
|
]
|
|
4247
4925
|
}
|
|
4248
|
-
}, { status: 400, headers:
|
|
4926
|
+
}, { status: 400, headers: CORS_HEADERS4 });
|
|
4249
4927
|
}
|
|
4250
4928
|
const sqlUpper = sql.trim().toUpperCase();
|
|
4251
4929
|
try {
|
|
@@ -4254,7 +4932,7 @@ async function handleDebugSql(ctx, request, url) {
|
|
|
4254
4932
|
`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`
|
|
4255
4933
|
);
|
|
4256
4934
|
const tables = cursor2.toArray().map((r) => r.name);
|
|
4257
|
-
return Response.json({ tables }, { headers:
|
|
4935
|
+
return Response.json({ tables }, { headers: CORS_HEADERS4 });
|
|
4258
4936
|
}
|
|
4259
4937
|
if (sqlUpper.startsWith("DESCRIBE ") || sqlUpper.startsWith(".SCHEMA ")) {
|
|
4260
4938
|
const tableName = sql.trim().split(/\s+/)[1];
|
|
@@ -4269,7 +4947,7 @@ async function handleDebugSql(ctx, request, url) {
|
|
|
4269
4947
|
primaryKey: col.pk === 1
|
|
4270
4948
|
};
|
|
4271
4949
|
});
|
|
4272
|
-
return Response.json({ table: tableName, columns }, { headers:
|
|
4950
|
+
return Response.json({ table: tableName, columns }, { headers: CORS_HEADERS4 });
|
|
4273
4951
|
}
|
|
4274
4952
|
const cursor = ctx.sql.exec(sql, ...params);
|
|
4275
4953
|
const rows = cursor.toArray();
|
|
@@ -4277,12 +4955,12 @@ async function handleDebugSql(ctx, request, url) {
|
|
|
4277
4955
|
sql,
|
|
4278
4956
|
rowCount: rows.length,
|
|
4279
4957
|
rows
|
|
4280
|
-
}, { headers:
|
|
4958
|
+
}, { headers: CORS_HEADERS4 });
|
|
4281
4959
|
} catch (error) {
|
|
4282
4960
|
return Response.json({
|
|
4283
4961
|
error: error instanceof Error ? error.message : "Query failed",
|
|
4284
4962
|
sql
|
|
4285
|
-
}, { status: 400, headers:
|
|
4963
|
+
}, { status: 400, headers: CORS_HEADERS4 });
|
|
4286
4964
|
}
|
|
4287
4965
|
}
|
|
4288
4966
|
|