deepspace 0.19.5 → 0.21.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 +24 -0
- package/dist/{chunk-A7UFIOWO.js → chunk-KDCDRHBA.js} +7 -2
- package/dist/chunk-KDCDRHBA.js.map +1 -0
- package/dist/cli.js +219 -94
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.d.ts +3 -2
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +48 -48
- package/dist/documentation-react.d.ts +2 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +7 -7
- package/dist/documentation-server-core.d.ts +3 -2
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.d.ts +40 -12
- package/dist/documentation.js +40 -7
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +225 -21
- package/dist/index.js +262 -112
- package/dist/index.js.map +1 -1
- package/dist/{public-CFAj1hL2.d.ts → public-DenQ1TsZ.d.ts} +36 -11
- package/dist/server.d.ts +46 -29
- package/dist/server.js +1041 -254
- package/dist/server.js.map +1 -1
- package/dist/testing-mcp.d.ts +69 -0
- package/dist/testing-mcp.js +99 -0
- package/dist/testing-mcp.js.map +1 -0
- package/dist/testing.d.ts +17 -20
- package/dist/testing.js +47 -25
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +73 -32
- package/dist/worker.js +1094 -244
- package/dist/worker.js.map +1 -1
- package/features/ai-chat/feature.json +2 -2
- package/features/ai-chat/src/AiChatPage.tsx +56 -21
- package/features/ai-chat/src/ChatPanel.tsx +33 -10
- package/features/ai-chat/src/__tests__/ChatPanel.lifecycle.test.tsx +156 -0
- package/features/cron/src/CronLogPage.tsx +107 -69
- package/features/file-attachments/feature.json +2 -1
- package/features/file-manager/feature.json +2 -1
- package/features/file-manager/src/FileManagerPage.tsx +227 -194
- package/features/integration-test/src/IntegrationTestPage.tsx +54 -34
- package/package.json +5 -1
- package/dist/chunk-A7UFIOWO.js.map +0 -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,18 @@ function declaredLength(request, what) {
|
|
|
282
737
|
}
|
|
283
738
|
return declared;
|
|
284
739
|
}
|
|
285
|
-
function
|
|
286
|
-
return key.startsWith(prefix);
|
|
287
|
-
}
|
|
288
|
-
async function prefixUsage(bucket, prefix) {
|
|
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;
|
|
740
|
+
function isKeyWithinScope(key, prefix, excludedPrefixes) {
|
|
741
|
+
return key.startsWith(prefix) && !excludedPrefixes.some((excluded) => key.startsWith(excluded));
|
|
297
742
|
}
|
|
298
|
-
|
|
299
|
-
|
|
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);
|
|
743
|
+
function scopeRelativeKey(key, prefix) {
|
|
744
|
+
return key.slice(prefix.length);
|
|
304
745
|
}
|
|
305
|
-
|
|
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) {
|
|
746
|
+
function anchorKey(raw, prefix, excludedPrefixes) {
|
|
353
747
|
const safe = sanitizeSubpath(raw);
|
|
354
|
-
if (safe === null) return
|
|
355
|
-
if (!safe) return
|
|
356
|
-
|
|
748
|
+
if (safe === null) return fail2(400, "Invalid key: traversal not allowed", "invalid_key");
|
|
749
|
+
if (!safe) return fail2(400, "Invalid key: empty", "invalid_key");
|
|
750
|
+
const key = `${prefix}${safe}`;
|
|
751
|
+
return isKeyWithinScope(key, prefix, excludedPrefixes) ? { key } : fail2(403, "Access denied: key belongs to a private scope", "forbidden");
|
|
357
752
|
}
|
|
358
753
|
var VERB_ROUTES = /* @__PURE__ */ new Set([
|
|
359
754
|
"POST upload",
|
|
@@ -367,7 +762,7 @@ function isFilesVerbPath(method, subpath) {
|
|
|
367
762
|
}
|
|
368
763
|
function resolveMimeType(raw) {
|
|
369
764
|
const mimeType = raw.split(";")[0].trim().toLowerCase() || "application/octet-stream";
|
|
370
|
-
return
|
|
765
|
+
return mimeType;
|
|
371
766
|
}
|
|
372
767
|
function r2ErrorCode(error) {
|
|
373
768
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -377,7 +772,7 @@ function r2ErrorCode(error) {
|
|
|
377
772
|
function multipartFailure(error) {
|
|
378
773
|
switch (r2ErrorCode(error)) {
|
|
379
774
|
case 10024:
|
|
380
|
-
return
|
|
775
|
+
return fail2(
|
|
381
776
|
404,
|
|
382
777
|
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
383
778
|
"unknown_upload"
|
|
@@ -387,7 +782,7 @@ function multipartFailure(error) {
|
|
|
387
782
|
case 10025:
|
|
388
783
|
// InvalidPart — a named part was never uploaded
|
|
389
784
|
case 10048:
|
|
390
|
-
return
|
|
785
|
+
return fail2(
|
|
391
786
|
400,
|
|
392
787
|
"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
788
|
"bad_parts"
|
|
@@ -395,11 +790,15 @@ function multipartFailure(error) {
|
|
|
395
790
|
case 10012:
|
|
396
791
|
// MetadataTooLarge
|
|
397
792
|
case 10020:
|
|
398
|
-
return
|
|
793
|
+
return fail2(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
|
|
399
794
|
default: {
|
|
400
795
|
const message = error instanceof Error ? error.message : String(error);
|
|
401
796
|
console.error("[files:multipart] R2 error:", message);
|
|
402
|
-
return
|
|
797
|
+
return fail2(
|
|
798
|
+
503,
|
|
799
|
+
"The files service could not complete that upload. Retry shortly.",
|
|
800
|
+
"r2_error"
|
|
801
|
+
);
|
|
403
802
|
}
|
|
404
803
|
}
|
|
405
804
|
}
|
|
@@ -412,73 +811,79 @@ function createScopedR2Handler(config) {
|
|
|
412
811
|
} catch {
|
|
413
812
|
return Response.json(
|
|
414
813
|
{ error: "Invalid path encoding" },
|
|
415
|
-
{ status: 400, headers:
|
|
814
|
+
{ status: 400, headers: CORS_HEADERS2 }
|
|
416
815
|
);
|
|
417
816
|
}
|
|
418
817
|
const subpath = sanitizeSubpath(subpathRaw);
|
|
419
818
|
if (subpath === null) {
|
|
420
819
|
return Response.json(
|
|
421
820
|
{ error: "Invalid path: traversal not allowed" },
|
|
422
|
-
{ status: 400, headers:
|
|
821
|
+
{ status: 400, headers: CORS_HEADERS2 }
|
|
423
822
|
);
|
|
424
823
|
}
|
|
425
824
|
const scope = url.searchParams.get("scope") || "self";
|
|
426
825
|
if (scope !== "self" && scope !== "app") {
|
|
427
|
-
return Response.json({ error: "Invalid scope" }, { status: 400, headers:
|
|
826
|
+
return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS2 });
|
|
428
827
|
}
|
|
429
828
|
const result = config.resolvePrefix(scope, { userId: auth.userId, url });
|
|
430
829
|
if (!result.prefix) {
|
|
431
830
|
const errorMsg = result.error ?? "Invalid scope";
|
|
432
831
|
const status = errorMsg.toLowerCase().includes("auth") ? 401 : 400;
|
|
433
|
-
return Response.json({ error: errorMsg }, { status, headers:
|
|
832
|
+
return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS2 });
|
|
434
833
|
}
|
|
435
834
|
const prefix = result.prefix;
|
|
835
|
+
const excludedPrefixes = result.excludedPrefixes ?? [];
|
|
436
836
|
if (isFilesVerbPath(request.method, subpath)) {
|
|
437
837
|
if (requireAuthForMutations && !auth.userId) {
|
|
438
838
|
await request.body?.cancel().catch(() => void 0);
|
|
439
|
-
return
|
|
839
|
+
return fail2(401, "Authentication required", "unauthorized");
|
|
440
840
|
}
|
|
441
|
-
return subpath === "upload" ? handleUpload(request, url, bucket, prefix, auth, scope) : handleMultipart(request, url, bucket, prefix, auth, scope, subpath);
|
|
841
|
+
return subpath === "upload" ? handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) : handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath);
|
|
442
842
|
}
|
|
443
843
|
if (!subpath && request.method === "GET") {
|
|
444
|
-
return handleList(bucket, prefix, url, scope, auth);
|
|
844
|
+
return handleList(bucket, prefix, excludedPrefixes, url, scope, auth);
|
|
445
845
|
}
|
|
446
846
|
if (subpath && request.method === "GET") {
|
|
447
|
-
if (!
|
|
847
|
+
if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
|
|
448
848
|
return Response.json(
|
|
449
849
|
{ error: "Access denied: key outside scope" },
|
|
450
|
-
{ status: 403, headers:
|
|
850
|
+
{ status: 403, headers: CORS_HEADERS2 }
|
|
451
851
|
);
|
|
452
852
|
}
|
|
453
|
-
return handleDownload(bucket, subpath, scope);
|
|
853
|
+
return handleDownload(request, bucket, subpath, scope);
|
|
454
854
|
}
|
|
455
855
|
if (subpath && request.method === "DELETE") {
|
|
456
856
|
if (requireAuthForMutations && !auth.userId) {
|
|
457
857
|
return Response.json(
|
|
458
858
|
{ error: "Authentication required" },
|
|
459
|
-
{ status: 401, headers:
|
|
859
|
+
{ status: 401, headers: CORS_HEADERS2 }
|
|
460
860
|
);
|
|
461
861
|
}
|
|
462
|
-
if (!
|
|
862
|
+
if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
|
|
463
863
|
return Response.json(
|
|
464
864
|
{ error: "Access denied: key outside scope" },
|
|
465
|
-
{ status: 403, headers:
|
|
865
|
+
{ status: 403, headers: CORS_HEADERS2 }
|
|
466
866
|
);
|
|
467
867
|
}
|
|
468
868
|
const existed = await bucket.head(subpath) !== null;
|
|
469
|
-
if (existed)
|
|
869
|
+
if (existed) {
|
|
870
|
+
await bucket.delete(subpath);
|
|
871
|
+
await markQuotaDirty(bucket, auth.storage);
|
|
872
|
+
}
|
|
470
873
|
return Response.json(
|
|
471
874
|
{ success: true, deleted: existed ? subpath : null, existed },
|
|
472
|
-
{ headers:
|
|
875
|
+
{ headers: CORS_HEADERS2 }
|
|
473
876
|
);
|
|
474
877
|
}
|
|
475
|
-
return Response.json({ error: "Method not allowed" }, { status: 405, headers:
|
|
878
|
+
return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS2 });
|
|
476
879
|
};
|
|
477
880
|
}
|
|
478
|
-
async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
881
|
+
async function handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
|
|
479
882
|
try {
|
|
480
|
-
const
|
|
481
|
-
if (
|
|
883
|
+
const declaredBytes = declaredLength(request, "File uploads");
|
|
884
|
+
if (declaredBytes instanceof Response) return declaredBytes;
|
|
885
|
+
const tooLargeRequest = overRequestBound(declaredBytes, 8 * 1024);
|
|
886
|
+
if (tooLargeRequest) return tooLargeRequest;
|
|
482
887
|
const contentType = request.headers.get("content-type") || "";
|
|
483
888
|
let fileData;
|
|
484
889
|
let fileName;
|
|
@@ -486,7 +891,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
|
486
891
|
if (contentType.includes("multipart/form-data")) {
|
|
487
892
|
const formData = await request.formData();
|
|
488
893
|
const file = formData.get("file");
|
|
489
|
-
if (!file) return
|
|
894
|
+
if (!file) return fail2(400, "No file provided", "bad_request");
|
|
490
895
|
const tooBig = overRequestBound(file.size);
|
|
491
896
|
if (tooBig) return tooBig;
|
|
492
897
|
fileData = await file.arrayBuffer();
|
|
@@ -494,7 +899,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
|
494
899
|
rawMimeType = file.type || "application/octet-stream";
|
|
495
900
|
} else {
|
|
496
901
|
const body = await request.json();
|
|
497
|
-
if (!body.data || !body.name) return
|
|
902
|
+
if (!body.data || !body.name) return fail2(400, "Missing data or name", "bad_request");
|
|
498
903
|
const base64Data = body.data.replace(/^data:[^;]+;base64,/, "");
|
|
499
904
|
const tooBig = overRequestBound(Math.floor(base64Data.length * 3 / 4));
|
|
500
905
|
if (tooBig) return tooBig;
|
|
@@ -502,43 +907,75 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
|
502
907
|
fileName = body.name;
|
|
503
908
|
rawMimeType = body.mimeType || "application/octet-stream";
|
|
504
909
|
}
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
const located = locateUpload(url, prefix, fileName);
|
|
910
|
+
const mimeType = resolveMimeType(rawMimeType);
|
|
911
|
+
const located = locateUpload(url, prefix, excludedPrefixes, fileName);
|
|
508
912
|
if (located instanceof Response) return located;
|
|
509
913
|
const replacesKey = url.searchParams.get("key") === null ? null : located.key;
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
await bucket.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
914
|
+
const replaced = replacesKey === null ? null : await bucket.head(replacesKey);
|
|
915
|
+
const replacedBytes = replaced?.size ?? 0;
|
|
916
|
+
const admission = await admitStorage(bucket, auth.storage, fileData.byteLength, replacedBytes);
|
|
917
|
+
if (admission instanceof Response) return admission;
|
|
918
|
+
try {
|
|
919
|
+
const written = await bucket.put(located.key, fileData, {
|
|
920
|
+
// Replacement credit is valid only while the exact object that earned
|
|
921
|
+
// it still exists. A concurrent delete or overwrite loses this CAS and
|
|
922
|
+
// retries from a fresh head instead of landing uncounted bytes.
|
|
923
|
+
...replaced ? { onlyIf: { etagMatches: replaced.etag } } : {},
|
|
924
|
+
httpMetadata: { contentType: mimeType },
|
|
925
|
+
customMetadata: fileMetadata(fileName, auth.userId)
|
|
926
|
+
});
|
|
927
|
+
if (!written) {
|
|
928
|
+
await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
|
|
929
|
+
return fail2(
|
|
930
|
+
409,
|
|
931
|
+
"The file changed while this upload was being admitted. Retry the upload.",
|
|
932
|
+
"write_conflict"
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
} catch (error) {
|
|
936
|
+
await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
|
|
937
|
+
throw error;
|
|
938
|
+
}
|
|
939
|
+
await releaseQuotaMarker(
|
|
940
|
+
bucket,
|
|
941
|
+
auth.storage,
|
|
942
|
+
admission.markerKey,
|
|
943
|
+
admission.markerKey !== null || fileData.byteLength < replacedBytes
|
|
944
|
+
);
|
|
945
|
+
return storedFileJson(located.key, prefix, fileName, url, scope);
|
|
517
946
|
} catch (err) {
|
|
518
947
|
const msg = err instanceof Error ? err.message : "Upload failed";
|
|
519
948
|
console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
|
|
520
|
-
return
|
|
949
|
+
return fail2(500, msg, "upload_failed");
|
|
521
950
|
}
|
|
522
951
|
}
|
|
523
|
-
function locateUpload(url, prefix, fileName) {
|
|
952
|
+
function locateUpload(url, prefix, excludedPrefixes, fileName, generatedId) {
|
|
524
953
|
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);
|
|
954
|
+
const subkey = requested ?? (generatedId ? `${generatedId}-${fileName}` : `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`);
|
|
955
|
+
const anchored = anchorKey(subkey, prefix, excludedPrefixes);
|
|
527
956
|
return anchored instanceof Response ? anchored : { key: anchored.key, subkey };
|
|
528
957
|
}
|
|
529
|
-
function fileMetadata(fileName, userId) {
|
|
958
|
+
function fileMetadata(fileName, userId, reservationId, declaredBytes) {
|
|
530
959
|
return {
|
|
531
960
|
originalName: fileName,
|
|
532
961
|
...userId ? { uploadedBy: userId } : {},
|
|
962
|
+
...reservationId ? { reservationId } : {},
|
|
963
|
+
...declaredBytes === void 0 ? {} : { declaredBytes: String(declaredBytes) },
|
|
533
964
|
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
534
965
|
};
|
|
535
966
|
}
|
|
536
|
-
function storedFileJson(key, name, url, scope) {
|
|
967
|
+
function storedFileJson(key, prefix, name, url, scope) {
|
|
537
968
|
const fileUrl = new URL(`/api/files/${encodeKeyPath(key)}`, url.origin);
|
|
538
969
|
fileUrl.searchParams.set("scope", scope);
|
|
539
970
|
return Response.json(
|
|
540
|
-
{
|
|
541
|
-
|
|
971
|
+
{
|
|
972
|
+
success: true,
|
|
973
|
+
key,
|
|
974
|
+
relativeKey: scopeRelativeKey(key, prefix),
|
|
975
|
+
url: fileUrl.toString(),
|
|
976
|
+
name
|
|
977
|
+
},
|
|
978
|
+
{ headers: CORS_HEADERS2 }
|
|
542
979
|
);
|
|
543
980
|
}
|
|
544
981
|
var MAX_CONTROL_BODY_BYTES = 64 * 1024;
|
|
@@ -546,24 +983,61 @@ async function readControlJson(request) {
|
|
|
546
983
|
const declared = declaredLength(request, "This request");
|
|
547
984
|
if (declared instanceof Response) return declared;
|
|
548
985
|
if (declared > MAX_CONTROL_BODY_BYTES) {
|
|
549
|
-
return
|
|
986
|
+
return fail2(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
|
|
550
987
|
}
|
|
551
988
|
let parsed;
|
|
552
989
|
try {
|
|
553
990
|
parsed = await request.json();
|
|
554
991
|
} catch {
|
|
555
|
-
return
|
|
992
|
+
return fail2(400, "Body must be JSON", "bad_request");
|
|
556
993
|
}
|
|
557
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed :
|
|
994
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail2(400, "Body must be a JSON object", "bad_request");
|
|
558
995
|
}
|
|
559
|
-
|
|
996
|
+
var REQUEST_ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
|
|
997
|
+
function resolveSession(url, prefix, excludedPrefixes) {
|
|
560
998
|
const uploadKey = url.searchParams.get("uploadKey");
|
|
561
999
|
const uploadId = url.searchParams.get("uploadId");
|
|
562
|
-
|
|
563
|
-
|
|
1000
|
+
const reservationId = url.searchParams.get("reservationId");
|
|
1001
|
+
if (!uploadKey || !uploadId || !reservationId) {
|
|
1002
|
+
return fail2(400, "uploadKey, uploadId, and reservationId are required", "bad_request");
|
|
1003
|
+
}
|
|
1004
|
+
if (!REQUEST_ID_RE.test(reservationId)) {
|
|
1005
|
+
return fail2(400, "reservationId is invalid", "bad_request");
|
|
564
1006
|
}
|
|
565
|
-
const anchored = anchorKey(uploadKey, prefix);
|
|
566
|
-
return anchored instanceof Response ? anchored : { key: anchored.key, uploadId };
|
|
1007
|
+
const anchored = anchorKey(uploadKey, prefix, excludedPrefixes);
|
|
1008
|
+
return anchored instanceof Response ? anchored : { key: anchored.key, uploadKey, uploadId, reservationId };
|
|
1009
|
+
}
|
|
1010
|
+
function reservationMatches(reservation, session) {
|
|
1011
|
+
return reservation.admitted && reservation.uploadId === session.uploadId && reservation.objectKey === session.key && reservation.uploadKey === session.uploadKey;
|
|
1012
|
+
}
|
|
1013
|
+
function multipartSessionJson(uploadId, uploadKey, reservationId) {
|
|
1014
|
+
return Response.json(
|
|
1015
|
+
{
|
|
1016
|
+
uploadId,
|
|
1017
|
+
uploadKey,
|
|
1018
|
+
reservationId,
|
|
1019
|
+
partSize: UPLOAD_PART_BYTES,
|
|
1020
|
+
maxParts: MAX_UPLOAD_PARTS,
|
|
1021
|
+
maxBytes: MAX_APP_FILE_BYTES
|
|
1022
|
+
},
|
|
1023
|
+
{ headers: CORS_HEADERS2 }
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
function multipartPartCount(declaredBytes) {
|
|
1027
|
+
return Math.ceil(declaredBytes / UPLOAD_PART_BYTES);
|
|
1028
|
+
}
|
|
1029
|
+
function expectedMultipartPartBytes(declaredBytes, partNumber) {
|
|
1030
|
+
const partCount = multipartPartCount(declaredBytes);
|
|
1031
|
+
if (partNumber < 1 || partNumber > partCount) return null;
|
|
1032
|
+
return partNumber === partCount ? declaredBytes - (partCount - 1) * UPLOAD_PART_BYTES : UPLOAD_PART_BYTES;
|
|
1033
|
+
}
|
|
1034
|
+
function multipartLayoutError(declaredBytes) {
|
|
1035
|
+
return fail2(
|
|
1036
|
+
400,
|
|
1037
|
+
`This upload must use ${multipartPartCount(declaredBytes)} sequential part(s) at the advertised ${formatBytes(UPLOAD_PART_BYTES)} size, except for the final remainder.`,
|
|
1038
|
+
"bad_part_layout",
|
|
1039
|
+
{ partSize: UPLOAD_PART_BYTES, partCount: multipartPartCount(declaredBytes) }
|
|
1040
|
+
);
|
|
567
1041
|
}
|
|
568
1042
|
async function streamPart(upload, partNumber, body, expectedBytes) {
|
|
569
1043
|
let observedBytes = 0;
|
|
@@ -586,57 +1060,121 @@ async function streamPart(upload, partNumber, body, expectedBytes) {
|
|
|
586
1060
|
}
|
|
587
1061
|
return { ok: true, part: uploaded.value };
|
|
588
1062
|
}
|
|
589
|
-
async function handleMultipart(request, url, bucket, prefix, auth, scope, subpath) {
|
|
1063
|
+
async function handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath) {
|
|
590
1064
|
if (subpath === "multipart" && request.method === "POST") {
|
|
591
|
-
return multipartInit(request, url, bucket, prefix, auth);
|
|
1065
|
+
return multipartInit(request, url, bucket, prefix, excludedPrefixes, auth);
|
|
1066
|
+
}
|
|
1067
|
+
if (subpath === "multipart/part") {
|
|
1068
|
+
return multipartPart(request, url, bucket, prefix, excludedPrefixes, auth);
|
|
592
1069
|
}
|
|
593
|
-
if (subpath === "multipart/part") return multipartPart(request, url, bucket, prefix);
|
|
594
1070
|
if (subpath === "multipart/complete") {
|
|
595
|
-
return multipartComplete(request, url, bucket, prefix, auth, scope);
|
|
1071
|
+
return multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope);
|
|
596
1072
|
}
|
|
597
|
-
return multipartAbort(url, bucket, prefix);
|
|
1073
|
+
return multipartAbort(url, bucket, prefix, excludedPrefixes, auth);
|
|
598
1074
|
}
|
|
599
|
-
async function multipartInit(request, url, bucket, prefix, auth) {
|
|
1075
|
+
async function multipartInit(request, url, bucket, prefix, excludedPrefixes, auth) {
|
|
600
1076
|
const body = await readControlJson(request);
|
|
601
1077
|
if (body instanceof Response) return body;
|
|
602
1078
|
const size = body.size;
|
|
603
|
-
if (!Number.isSafeInteger(size) || size
|
|
604
|
-
return
|
|
1079
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
1080
|
+
return fail2(400, "size must be a positive integer number of bytes", "bad_request");
|
|
605
1081
|
}
|
|
606
1082
|
const tooBig = overCeiling(size);
|
|
607
1083
|
if (tooBig) return tooBig;
|
|
608
1084
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
609
|
-
if (!name) return
|
|
610
|
-
const
|
|
611
|
-
if (
|
|
612
|
-
|
|
1085
|
+
if (!name) return fail2(400, "name is required", "bad_request");
|
|
1086
|
+
const requestId = typeof body.requestId === "string" ? body.requestId : "";
|
|
1087
|
+
if (!REQUEST_ID_RE.test(requestId)) {
|
|
1088
|
+
return fail2(
|
|
1089
|
+
400,
|
|
1090
|
+
"requestId must be 16\u201380 URL-safe characters and must be reused when retrying init.",
|
|
1091
|
+
"bad_request"
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
const mimeType = resolveMimeType(typeof body.mimeType === "string" ? body.mimeType : "");
|
|
1095
|
+
const located = locateUpload(url, prefix, excludedPrefixes, name, requestId);
|
|
613
1096
|
if (located instanceof Response) return located;
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
1097
|
+
const completed = await bucket.head(located.key);
|
|
1098
|
+
if (completed?.customMetadata?.reservationId === requestId) {
|
|
1099
|
+
return fail2(
|
|
1100
|
+
409,
|
|
1101
|
+
"requestId already completed an upload; start the new upload with a fresh requestId.",
|
|
1102
|
+
"request_id_completed"
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
const existing = await readMultipartReservation(bucket, auth.storage, requestId);
|
|
1106
|
+
if (existing === "invalid") {
|
|
1107
|
+
return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
|
|
1108
|
+
}
|
|
1109
|
+
if (existing) {
|
|
1110
|
+
const sameRequest = existing.objectKey === located.key && existing.uploadKey === located.subkey && existing.declaredBytes === size;
|
|
1111
|
+
return sameRequest && existing.admitted ? multipartSessionJson(existing.uploadId, existing.uploadKey, requestId) : sameRequest ? quotaUnavailable("This upload is still being initialized. Retry shortly.") : fail2(
|
|
1112
|
+
409,
|
|
1113
|
+
"requestId already belongs to a different multipart upload.",
|
|
1114
|
+
"request_id_conflict"
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
617
1117
|
let upload;
|
|
618
1118
|
try {
|
|
619
1119
|
upload = await bucket.createMultipartUpload(located.key, {
|
|
620
|
-
httpMetadata: { contentType:
|
|
621
|
-
customMetadata: fileMetadata(name, auth.userId)
|
|
1120
|
+
httpMetadata: { contentType: mimeType },
|
|
1121
|
+
customMetadata: fileMetadata(name, auth.userId, requestId, size)
|
|
622
1122
|
});
|
|
623
1123
|
} catch (error) {
|
|
624
1124
|
return multipartFailure(error);
|
|
625
1125
|
}
|
|
626
|
-
|
|
627
|
-
|
|
1126
|
+
const marker = auth.storage ? {
|
|
1127
|
+
key: multipartMarkerKey(auth.storage, requestId),
|
|
1128
|
+
body: JSON.stringify({
|
|
1129
|
+
version: 1,
|
|
1130
|
+
reservationId: requestId,
|
|
628
1131
|
uploadId: upload.uploadId,
|
|
629
|
-
|
|
630
|
-
// `?key=`, so the mount that anchors keys anchors this one too.
|
|
1132
|
+
objectKey: located.key,
|
|
631
1133
|
uploadKey: located.subkey,
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
1134
|
+
admitted: false,
|
|
1135
|
+
declaredBytes: size
|
|
1136
|
+
}),
|
|
1137
|
+
metadata: {
|
|
1138
|
+
quotaKind: "multipart",
|
|
1139
|
+
active: "false"
|
|
1140
|
+
}
|
|
1141
|
+
} : void 0;
|
|
1142
|
+
const reservedBytes = Math.max(size, 5 * 1024 * 1024);
|
|
1143
|
+
const admission = await admitStorage(bucket, auth.storage, reservedBytes, 0, marker);
|
|
1144
|
+
if (admission instanceof Response) {
|
|
1145
|
+
await upload.abort().catch(() => void 0);
|
|
1146
|
+
if (admission.status === 409 && auth.storage) {
|
|
1147
|
+
const winner = await readMultipartReservation(bucket, auth.storage, requestId);
|
|
1148
|
+
if (winner && winner !== "invalid" && winner.objectKey === located.key && winner.uploadKey === located.subkey && winner.declaredBytes === size && winner.admitted) {
|
|
1149
|
+
return multipartSessionJson(winner.uploadId, winner.uploadKey, requestId);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return admission;
|
|
1153
|
+
}
|
|
1154
|
+
if (auth.storage && !await activateMultipartReservation(bucket, auth.storage, requestId)) {
|
|
1155
|
+
await upload.abort().catch(() => void 0);
|
|
1156
|
+
await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
|
|
1157
|
+
return quotaUnavailable("This upload reservation could not be finalized. Retry shortly.");
|
|
1158
|
+
}
|
|
1159
|
+
return multipartSessionJson(upload.uploadId, located.subkey, requestId);
|
|
638
1160
|
}
|
|
639
|
-
async function
|
|
1161
|
+
async function abandonMultipart(bucket, session, auth, reservation) {
|
|
1162
|
+
let result = "aborted";
|
|
1163
|
+
try {
|
|
1164
|
+
await bucket.resumeMultipartUpload(session.key, session.uploadId).abort();
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
if (r2ErrorCode(error) !== 10024) {
|
|
1167
|
+
console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
|
|
1168
|
+
return "failed";
|
|
1169
|
+
}
|
|
1170
|
+
result = "absent";
|
|
1171
|
+
}
|
|
1172
|
+
if (reservation) {
|
|
1173
|
+
if (!await releaseQuotaMarker(bucket, auth.storage, reservation.key, true)) return "failed";
|
|
1174
|
+
}
|
|
1175
|
+
return result;
|
|
1176
|
+
}
|
|
1177
|
+
async function multipartPart(request, url, bucket, prefix, excludedPrefixes, auth) {
|
|
640
1178
|
const refuse = async (response) => {
|
|
641
1179
|
await request.body?.cancel().catch(() => void 0);
|
|
642
1180
|
return response;
|
|
@@ -648,16 +1186,60 @@ async function multipartPart(request, url, bucket, prefix) {
|
|
|
648
1186
|
const partNumber = Number(url.searchParams.get("partNumber"));
|
|
649
1187
|
if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
|
|
650
1188
|
return refuse(
|
|
651
|
-
|
|
1189
|
+
fail2(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
|
|
652
1190
|
);
|
|
653
1191
|
}
|
|
654
|
-
const session = resolveSession(url, prefix);
|
|
1192
|
+
const session = resolveSession(url, prefix, excludedPrefixes);
|
|
655
1193
|
if (session instanceof Response) return refuse(session);
|
|
656
|
-
if (!request.body) return
|
|
1194
|
+
if (!request.body) return fail2(400, "A part body is required", "bad_request");
|
|
1195
|
+
const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
|
|
1196
|
+
if (reservation === "invalid") {
|
|
1197
|
+
return refuse(quotaUnavailable("This upload reservation could not be read. Retry shortly."));
|
|
1198
|
+
}
|
|
1199
|
+
if (auth.storage && reservation && !reservationMatches(reservation, session)) {
|
|
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 (auth.storage && !reservation) {
|
|
1209
|
+
return refuse(
|
|
1210
|
+
fail2(
|
|
1211
|
+
404,
|
|
1212
|
+
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
1213
|
+
"unknown_upload"
|
|
1214
|
+
)
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
if (reservation) {
|
|
1218
|
+
const expectedBytes = expectedMultipartPartBytes(reservation.declaredBytes, partNumber);
|
|
1219
|
+
if (expectedBytes === null || declaredBytes !== expectedBytes) {
|
|
1220
|
+
return refuse(multipartLayoutError(reservation.declaredBytes));
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
const refreshed = await refreshMultipartReservation(
|
|
1224
|
+
bucket,
|
|
1225
|
+
auth.storage,
|
|
1226
|
+
session.reservationId,
|
|
1227
|
+
session.uploadId,
|
|
1228
|
+
session.key
|
|
1229
|
+
);
|
|
1230
|
+
if (auth.storage && (refreshed === null || refreshed === "invalid")) {
|
|
1231
|
+
return refuse(quotaUnavailable("This upload reservation is missing or invalid."));
|
|
1232
|
+
}
|
|
1233
|
+
if (refreshed === "busy") {
|
|
1234
|
+
return refuse(quotaUnavailable("This upload reservation is busy. Retry the part shortly."));
|
|
1235
|
+
}
|
|
657
1236
|
const upload = bucket.resumeMultipartUpload(session.key, session.uploadId);
|
|
658
1237
|
const stored = await streamPart(upload, partNumber, request.body, declaredBytes);
|
|
659
1238
|
if (!stored.ok) {
|
|
660
|
-
|
|
1239
|
+
if (r2ErrorCode(stored.error) === 10024 && reservation) {
|
|
1240
|
+
await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
1241
|
+
}
|
|
1242
|
+
return stored.lengthMismatch ? fail2(
|
|
661
1243
|
400,
|
|
662
1244
|
`Part ${partNumber} declared ${declaredBytes} bytes but its body did not carry them`,
|
|
663
1245
|
"bad_request"
|
|
@@ -665,29 +1247,98 @@ async function multipartPart(request, url, bucket, prefix) {
|
|
|
665
1247
|
}
|
|
666
1248
|
return Response.json(
|
|
667
1249
|
{ partNumber: stored.part.partNumber, etag: stored.part.etag },
|
|
668
|
-
{ headers:
|
|
1250
|
+
{ headers: CORS_HEADERS2 }
|
|
669
1251
|
);
|
|
670
1252
|
}
|
|
671
|
-
async function
|
|
1253
|
+
async function completedReservationObject(bucket, session) {
|
|
1254
|
+
const object = await bucket.head(session.key);
|
|
1255
|
+
return object?.customMetadata?.reservationId === session.reservationId ? object : null;
|
|
1256
|
+
}
|
|
1257
|
+
async function finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope, prefix) {
|
|
1258
|
+
const declaredMetadata = Number(object.customMetadata?.declaredBytes);
|
|
1259
|
+
const declaredBytes = reservation?.declaredBytes ?? (Number.isSafeInteger(declaredMetadata) && declaredMetadata >= 0 ? declaredMetadata : MAX_APP_FILE_BYTES);
|
|
1260
|
+
const refusal = overCeiling(object.size) ?? (object.size > declaredBytes ? fail2(
|
|
1261
|
+
400,
|
|
1262
|
+
`The assembled upload exceeded its declared ${declaredBytes}-byte size and was not kept.`,
|
|
1263
|
+
"declared_size_exceeded"
|
|
1264
|
+
) : null);
|
|
1265
|
+
if (refusal) {
|
|
1266
|
+
let remaining;
|
|
1267
|
+
try {
|
|
1268
|
+
const current = await bucket.head(session.key);
|
|
1269
|
+
if (current?.etag === object.etag) await bucket.delete(session.key);
|
|
1270
|
+
remaining = await bucket.head(session.key);
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
console.error("[files:multipart] invalid object cleanup:", error);
|
|
1273
|
+
return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
|
|
1274
|
+
}
|
|
1275
|
+
if (remaining?.etag === object.etag) {
|
|
1276
|
+
return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
|
|
1277
|
+
}
|
|
1278
|
+
if (reservation) {
|
|
1279
|
+
const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
1280
|
+
if (!cleaned) return quotaUnavailable("The upload was removed, but cleanup must be retried.");
|
|
1281
|
+
}
|
|
1282
|
+
return refusal;
|
|
1283
|
+
}
|
|
1284
|
+
if (reservation) {
|
|
1285
|
+
const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
1286
|
+
if (!cleaned) {
|
|
1287
|
+
return quotaUnavailable("The file was stored, but its reservation cleanup must be retried.");
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
const name = object.customMetadata?.originalName ?? session.uploadKey.split("/").pop() ?? "file";
|
|
1291
|
+
return storedFileJson(session.key, prefix, name, url, scope);
|
|
1292
|
+
}
|
|
1293
|
+
async function multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
|
|
672
1294
|
const body = await readControlJson(request);
|
|
673
1295
|
if (body instanceof Response) return body;
|
|
674
|
-
const session = resolveSession(url, prefix);
|
|
1296
|
+
const session = resolveSession(url, prefix, excludedPrefixes);
|
|
675
1297
|
if (session instanceof Response) return session;
|
|
1298
|
+
const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
|
|
1299
|
+
if (reservation === "invalid") {
|
|
1300
|
+
return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
|
|
1301
|
+
}
|
|
1302
|
+
if (auth.storage && reservation && !reservationMatches(reservation, session)) {
|
|
1303
|
+
return fail2(
|
|
1304
|
+
404,
|
|
1305
|
+
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
1306
|
+
"unknown_upload"
|
|
1307
|
+
);
|
|
1308
|
+
}
|
|
1309
|
+
if (auth.storage && !reservation) {
|
|
1310
|
+
const completed = await completedReservationObject(bucket, session);
|
|
1311
|
+
if (completed) {
|
|
1312
|
+
return finishCompletedMultipart(bucket, session, null, completed, auth, url, scope, prefix);
|
|
1313
|
+
}
|
|
1314
|
+
await abandonMultipart(bucket, session, auth, null);
|
|
1315
|
+
return quotaUnavailable("This upload reservation is missing or does not match.");
|
|
1316
|
+
}
|
|
676
1317
|
const raw = body.parts;
|
|
677
1318
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
678
|
-
return
|
|
1319
|
+
return fail2(400, "parts must be a non-empty array", "bad_request");
|
|
679
1320
|
}
|
|
680
1321
|
if (raw.length > MAX_UPLOAD_PARTS) {
|
|
681
|
-
return
|
|
1322
|
+
return fail2(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
|
|
1323
|
+
}
|
|
1324
|
+
if (reservation && raw.length !== multipartPartCount(reservation.declaredBytes)) {
|
|
1325
|
+
return multipartLayoutError(reservation.declaredBytes);
|
|
682
1326
|
}
|
|
683
1327
|
const parts = [];
|
|
684
1328
|
for (let index = 0; index < raw.length; index++) {
|
|
685
1329
|
const { partNumber, etag } = raw[index] ?? {};
|
|
686
|
-
if (!Number.isSafeInteger(partNumber) || partNumber < 1) {
|
|
687
|
-
return
|
|
1330
|
+
if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
|
|
1331
|
+
return fail2(
|
|
1332
|
+
400,
|
|
1333
|
+
`parts[${index}].partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`,
|
|
1334
|
+
"bad_request"
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
if (reservation && partNumber !== index + 1) {
|
|
1338
|
+
return multipartLayoutError(reservation.declaredBytes);
|
|
688
1339
|
}
|
|
689
1340
|
if (typeof etag !== "string" || !etag) {
|
|
690
|
-
return
|
|
1341
|
+
return fail2(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
|
|
691
1342
|
}
|
|
692
1343
|
parts.push({ partNumber, etag });
|
|
693
1344
|
}
|
|
@@ -695,39 +1346,87 @@ async function multipartComplete(request, url, bucket, prefix, auth, scope) {
|
|
|
695
1346
|
try {
|
|
696
1347
|
object = await bucket.resumeMultipartUpload(session.key, session.uploadId).complete(parts);
|
|
697
1348
|
} catch (error) {
|
|
1349
|
+
const completed = await completedReservationObject(bucket, session);
|
|
1350
|
+
if (completed) {
|
|
1351
|
+
return finishCompletedMultipart(
|
|
1352
|
+
bucket,
|
|
1353
|
+
session,
|
|
1354
|
+
reservation,
|
|
1355
|
+
completed,
|
|
1356
|
+
auth,
|
|
1357
|
+
url,
|
|
1358
|
+
scope,
|
|
1359
|
+
prefix
|
|
1360
|
+
);
|
|
1361
|
+
}
|
|
1362
|
+
if (r2ErrorCode(error) === 10024 && reservation) {
|
|
1363
|
+
await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
1364
|
+
}
|
|
698
1365
|
return multipartFailure(error);
|
|
699
1366
|
}
|
|
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);
|
|
1367
|
+
return finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope, prefix);
|
|
709
1368
|
}
|
|
710
|
-
async function multipartAbort(url, bucket, prefix) {
|
|
711
|
-
const session = resolveSession(url, prefix);
|
|
1369
|
+
async function multipartAbort(url, bucket, prefix, excludedPrefixes, auth) {
|
|
1370
|
+
const session = resolveSession(url, prefix, excludedPrefixes);
|
|
712
1371
|
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 });
|
|
1372
|
+
const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
|
|
1373
|
+
if (reservation === "invalid") {
|
|
1374
|
+
return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS2 });
|
|
718
1375
|
}
|
|
719
|
-
|
|
1376
|
+
if (auth.storage && reservation && !reservationMatches(reservation, session)) {
|
|
1377
|
+
return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS2 });
|
|
1378
|
+
}
|
|
1379
|
+
const result = await abandonMultipart(bucket, session, auth, reservation || null);
|
|
1380
|
+
return Response.json({ success: true, aborted: result === "aborted" }, { headers: CORS_HEADERS2 });
|
|
720
1381
|
}
|
|
721
|
-
async function handleList(bucket, prefix, url, scope, auth) {
|
|
1382
|
+
async function handleList(bucket, prefix, excludedPrefixes, url, scope, auth) {
|
|
722
1383
|
const userPrefix = url.searchParams.get("prefix") || "";
|
|
723
1384
|
const listPrefix = `${prefix}${userPrefix}`;
|
|
724
|
-
const
|
|
725
|
-
const
|
|
726
|
-
const
|
|
1385
|
+
const requestedLimit = Number(url.searchParams.get("limit") ?? 100);
|
|
1386
|
+
const limit = Number.isSafeInteger(requestedLimit) ? Math.min(1e3, Math.max(1, requestedLimit)) : 100;
|
|
1387
|
+
const requestCursor = url.searchParams.get("cursor");
|
|
1388
|
+
if (excludedPrefixes.some((excluded) => listPrefix.startsWith(excluded))) {
|
|
1389
|
+
return Response.json({ files: [], truncated: false }, { headers: CORS_HEADERS2 });
|
|
1390
|
+
}
|
|
1391
|
+
const withMetadata = (options) => ({ ...options, include: ["customMetadata"] });
|
|
1392
|
+
const listed = await bucket.list(
|
|
1393
|
+
withMetadata({
|
|
1394
|
+
prefix: listPrefix,
|
|
1395
|
+
limit,
|
|
1396
|
+
...requestCursor ? { cursor: requestCursor } : {}
|
|
1397
|
+
})
|
|
1398
|
+
);
|
|
1399
|
+
const visible = listed.objects.filter(
|
|
1400
|
+
(object) => isKeyWithinScope(object.key, prefix, excludedPrefixes)
|
|
1401
|
+
);
|
|
1402
|
+
const seen = new Set(visible.map((object) => object.key));
|
|
1403
|
+
let frontier = listed;
|
|
1404
|
+
let frontierKey = listed.objects.at(-1)?.key;
|
|
1405
|
+
for (const excluded of excludedPrefixes.filter((candidate) => candidate.startsWith(listPrefix)).sort()) {
|
|
1406
|
+
if (!frontier.truncated || visible.length >= limit) break;
|
|
1407
|
+
const excludedEnd = `${excluded}\u{10FFFF}`;
|
|
1408
|
+
const after = await bucket.list(
|
|
1409
|
+
withMetadata({
|
|
1410
|
+
prefix: listPrefix,
|
|
1411
|
+
startAfter: frontierKey && frontierKey > excludedEnd ? frontierKey : excludedEnd,
|
|
1412
|
+
limit: limit - visible.length
|
|
1413
|
+
})
|
|
1414
|
+
);
|
|
1415
|
+
for (const object of after.objects) {
|
|
1416
|
+
if (!seen.has(object.key) && isKeyWithinScope(object.key, prefix, excludedPrefixes)) {
|
|
1417
|
+
seen.add(object.key);
|
|
1418
|
+
visible.push(object);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
frontierKey = after.objects.at(-1)?.key ?? frontierKey;
|
|
1422
|
+
frontier = after;
|
|
1423
|
+
}
|
|
1424
|
+
const files = visible.slice(0, limit).map((obj) => {
|
|
727
1425
|
const fileUrl = new URL(`/api/files/${encodeKeyPath(obj.key)}`, url.origin);
|
|
728
1426
|
fileUrl.searchParams.set("scope", scope);
|
|
729
1427
|
return {
|
|
730
1428
|
key: obj.key,
|
|
1429
|
+
relativeKey: scopeRelativeKey(obj.key, prefix),
|
|
731
1430
|
size: obj.size,
|
|
732
1431
|
uploaded: obj.uploaded.toISOString(),
|
|
733
1432
|
url: fileUrl.toString(),
|
|
@@ -735,32 +1434,46 @@ async function handleList(bucket, prefix, url, scope, auth) {
|
|
|
735
1434
|
};
|
|
736
1435
|
});
|
|
737
1436
|
let storage;
|
|
738
|
-
if (auth.storage) {
|
|
739
|
-
const usedBytes = await
|
|
1437
|
+
if (auth.storage && auth.includeStorageUsage) {
|
|
1438
|
+
const usedBytes = await storageUsage(bucket, auth.storage);
|
|
740
1439
|
if (usedBytes !== null) storage = { usedBytes };
|
|
741
1440
|
const limitBytes = await auth.storage.limitBytes();
|
|
742
1441
|
if (storage && limitBytes !== null) storage.limitBytes = limitBytes;
|
|
743
1442
|
}
|
|
744
1443
|
return Response.json(
|
|
745
|
-
{
|
|
746
|
-
|
|
1444
|
+
{
|
|
1445
|
+
files,
|
|
1446
|
+
truncated: frontier.truncated,
|
|
1447
|
+
...frontier.truncated ? { cursor: frontier.cursor } : {},
|
|
1448
|
+
...storage ? { storage } : {}
|
|
1449
|
+
},
|
|
1450
|
+
{ headers: CORS_HEADERS2 }
|
|
747
1451
|
);
|
|
748
1452
|
}
|
|
749
|
-
async function handleDownload(bucket, key, scope) {
|
|
1453
|
+
async function handleDownload(request, bucket, key, scope) {
|
|
750
1454
|
const object = await bucket.get(key);
|
|
751
1455
|
if (!object) {
|
|
752
|
-
return Response.json({ error: "File not found" }, { status: 404, headers:
|
|
1456
|
+
return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS2 });
|
|
753
1457
|
}
|
|
754
1458
|
const headers = new Headers();
|
|
755
|
-
|
|
1459
|
+
const storedType = resolveMimeType(object.httpMetadata?.contentType ?? "");
|
|
1460
|
+
const mustDownload = DANGEROUS_MIME_TYPES.has(storedType);
|
|
1461
|
+
headers.set("Content-Type", mustDownload ? "application/octet-stream" : storedType);
|
|
756
1462
|
headers.set("X-Content-Type-Options", "nosniff");
|
|
1463
|
+
headers.set("ETag", object.httpEtag);
|
|
757
1464
|
headers.set(
|
|
758
1465
|
"Cache-Control",
|
|
759
|
-
scope === "
|
|
1466
|
+
scope === "self" ? "private, no-store" : "public, max-age=0, must-revalidate"
|
|
760
1467
|
);
|
|
761
1468
|
headers.set("Access-Control-Allow-Origin", "*");
|
|
762
|
-
|
|
763
|
-
|
|
1469
|
+
const originalName = object.customMetadata?.originalName ?? key.split("/").pop() ?? "download";
|
|
1470
|
+
headers.set(
|
|
1471
|
+
"Content-Disposition",
|
|
1472
|
+
contentDisposition(originalName, mustDownload ? "attachment" : "inline")
|
|
1473
|
+
);
|
|
1474
|
+
if (mustDownload) headers.set("Content-Security-Policy", "sandbox; default-src 'none'");
|
|
1475
|
+
if (scope === "app" && request.headers.get("if-none-match") === object.httpEtag) {
|
|
1476
|
+
return new Response(null, { status: 304, headers });
|
|
764
1477
|
}
|
|
765
1478
|
return new Response(object.body, { headers });
|
|
766
1479
|
}
|
|
@@ -2274,6 +2987,9 @@ var MSG = {
|
|
|
2274
2987
|
CRON_PAUSE: "cron.pause",
|
|
2275
2988
|
CRON_RESUME: "cron.resume",
|
|
2276
2989
|
CRON_STATUS: "cron.status",
|
|
2990
|
+
// Per-mutation receipt, sent only to the socket whose trigger/pause/
|
|
2991
|
+
// resume carried a `requestId` — frames without one stay fire-and-forget.
|
|
2992
|
+
CRON_ACK: "cron.ack",
|
|
2277
2993
|
// ---- JobRoom ---------------------------------------------------------
|
|
2278
2994
|
// Background-job queue with durable, alarm-driven execution. See
|
|
2279
2995
|
// server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
|
|
@@ -2775,6 +3491,14 @@ var serverBuild = {
|
|
|
2775
3491
|
cronTasks: (tasks) => ({ type: MSG.CRON_TASKS, payload: { tasks } }),
|
|
2776
3492
|
cronHistory: (history) => ({ type: MSG.CRON_HISTORY, payload: { history } }),
|
|
2777
3493
|
cronStatus: (tasks, recentHistory) => ({ type: MSG.CRON_STATUS, payload: { tasks, recentHistory } }),
|
|
3494
|
+
cronAckSuccess: (requestId, taskName) => ({
|
|
3495
|
+
type: MSG.CRON_ACK,
|
|
3496
|
+
payload: { requestId, taskName, ok: true }
|
|
3497
|
+
}),
|
|
3498
|
+
cronAckFailure: (requestId, reason, error, taskName) => ({
|
|
3499
|
+
type: MSG.CRON_ACK,
|
|
3500
|
+
payload: { requestId, taskName, ok: false, reason, error }
|
|
3501
|
+
}),
|
|
2778
3502
|
// Jobs
|
|
2779
3503
|
jobSnapshot: (jobs) => ({ type: MSG.JOB_UPDATE, payload: { kind: "snapshot", jobs } }),
|
|
2780
3504
|
jobEnqueued: (job, requestId) => ({
|
|
@@ -3756,7 +4480,7 @@ async function handleSetRole(ctx, ws, attachment, payload) {
|
|
|
3756
4480
|
|
|
3757
4481
|
// src/server/handlers/tools-api.ts
|
|
3758
4482
|
import * as Y3 from "yjs";
|
|
3759
|
-
var
|
|
4483
|
+
var CORS_HEADERS3 = {
|
|
3760
4484
|
"Access-Control-Allow-Origin": "*",
|
|
3761
4485
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
3762
4486
|
"Access-Control-Allow-Headers": "Content-Type, X-User-Id, X-App-Action",
|
|
@@ -3764,23 +4488,23 @@ var CORS_HEADERS2 = {
|
|
|
3764
4488
|
};
|
|
3765
4489
|
async function handleToolsRequest(ctx, request, path) {
|
|
3766
4490
|
if (request.method === "OPTIONS") {
|
|
3767
|
-
return new Response(null, { headers:
|
|
4491
|
+
return new Response(null, { headers: CORS_HEADERS3 });
|
|
3768
4492
|
}
|
|
3769
4493
|
if (path === "tools/list") {
|
|
3770
|
-
return Response.json({ tools: BUILT_IN_TOOLS }, { headers:
|
|
4494
|
+
return Response.json({ tools: BUILT_IN_TOOLS }, { headers: CORS_HEADERS3 });
|
|
3771
4495
|
}
|
|
3772
4496
|
if (path.startsWith("tools/describe/")) {
|
|
3773
4497
|
const toolName = path.replace("tools/describe/", "");
|
|
3774
4498
|
const tool2 = BUILT_IN_TOOLS.find((t) => t.name === toolName);
|
|
3775
4499
|
if (!tool2) {
|
|
3776
|
-
return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers:
|
|
4500
|
+
return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers: CORS_HEADERS3 });
|
|
3777
4501
|
}
|
|
3778
|
-
return Response.json({ tool: tool2 }, { headers:
|
|
4502
|
+
return Response.json({ tool: tool2 }, { headers: CORS_HEADERS3 });
|
|
3779
4503
|
}
|
|
3780
4504
|
if (path.startsWith("tools/execute") && request.method === "POST") {
|
|
3781
4505
|
return handleToolExecute(ctx, request);
|
|
3782
4506
|
}
|
|
3783
|
-
return Response.json({ error: "Not found" }, { status: 404, headers:
|
|
4507
|
+
return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS3 });
|
|
3784
4508
|
}
|
|
3785
4509
|
async function handleToolExecute(ctx, request) {
|
|
3786
4510
|
let body;
|
|
@@ -3789,14 +4513,14 @@ async function handleToolExecute(ctx, request) {
|
|
|
3789
4513
|
} catch {
|
|
3790
4514
|
return Response.json(
|
|
3791
4515
|
{ success: false, error: "Invalid JSON body" },
|
|
3792
|
-
{ status: 400, headers:
|
|
4516
|
+
{ status: 400, headers: CORS_HEADERS3 }
|
|
3793
4517
|
);
|
|
3794
4518
|
}
|
|
3795
4519
|
const { tool: tool2, params = {} } = body;
|
|
3796
4520
|
if (!tool2) {
|
|
3797
4521
|
return Response.json(
|
|
3798
4522
|
{ success: false, error: 'Missing "tool" in request body' },
|
|
3799
|
-
{ status: 400, headers:
|
|
4523
|
+
{ status: 400, headers: CORS_HEADERS3 }
|
|
3800
4524
|
);
|
|
3801
4525
|
}
|
|
3802
4526
|
const userId = request.headers.get("x-user-id") ?? "";
|
|
@@ -3813,7 +4537,7 @@ async function handleToolExecute(ctx, request) {
|
|
|
3813
4537
|
if (tool2.startsWith("yjs.")) {
|
|
3814
4538
|
const result2 = executeYjsTool(ctx, tool2, params, userId, userRole);
|
|
3815
4539
|
const status2 = result2.success ? 200 : result2.error?.includes("not found") ? 404 : 400;
|
|
3816
|
-
return Response.json(result2, { status: status2, headers:
|
|
4540
|
+
return Response.json(result2, { status: status2, headers: CORS_HEADERS3 });
|
|
3817
4541
|
}
|
|
3818
4542
|
const recordCtx = {
|
|
3819
4543
|
sql: ctx.sql,
|
|
@@ -3824,7 +4548,7 @@ async function handleToolExecute(ctx, request) {
|
|
|
3824
4548
|
};
|
|
3825
4549
|
const result = await executeTool2(recordCtx, tool2, params, userId, userRole, isAppAction);
|
|
3826
4550
|
const status = result.success ? 200 : result.error?.includes("not found") ? 404 : 400;
|
|
3827
|
-
return Response.json(result, { status, headers:
|
|
4551
|
+
return Response.json(result, { status, headers: CORS_HEADERS3 });
|
|
3828
4552
|
}
|
|
3829
4553
|
async function executeTool2(ctx, toolName, params, userId, userRole, skipUserRbac) {
|
|
3830
4554
|
switch (toolName) {
|
|
@@ -4060,7 +4784,7 @@ function generateId() {
|
|
|
4060
4784
|
}
|
|
4061
4785
|
|
|
4062
4786
|
// src/server/handlers/debug-api.ts
|
|
4063
|
-
var
|
|
4787
|
+
var CORS_HEADERS4 = {
|
|
4064
4788
|
"Access-Control-Allow-Origin": "*",
|
|
4065
4789
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
4066
4790
|
"Access-Control-Allow-Headers": "Content-Type",
|
|
@@ -4068,7 +4792,7 @@ var CORS_HEADERS3 = {
|
|
|
4068
4792
|
};
|
|
4069
4793
|
async function handleApiRequest(ctx, request, url) {
|
|
4070
4794
|
if (request.method === "OPTIONS") {
|
|
4071
|
-
return new Response(null, { headers:
|
|
4795
|
+
return new Response(null, { headers: CORS_HEADERS4 });
|
|
4072
4796
|
}
|
|
4073
4797
|
const path = url.pathname.replace("/api/", "");
|
|
4074
4798
|
if (path.startsWith("tools/")) {
|
|
@@ -4092,13 +4816,13 @@ async function handleApiRequest(ctx, request, url) {
|
|
|
4092
4816
|
if (path === "debug/query") {
|
|
4093
4817
|
return handleDebugQuery(ctx, url);
|
|
4094
4818
|
}
|
|
4095
|
-
return Response.json({ error: "Not found" }, { status: 404, headers:
|
|
4819
|
+
return Response.json({ error: "Not found" }, { status: 404, headers: CORS_HEADERS4 });
|
|
4096
4820
|
}
|
|
4097
4821
|
function handleDebugUserRole(ctx, url) {
|
|
4098
4822
|
const email = url.searchParams.get("email");
|
|
4099
4823
|
const userId = url.searchParams.get("userId");
|
|
4100
4824
|
if (!email && !userId) {
|
|
4101
|
-
return Response.json({ error: "email or userId parameter required" }, { status: 400, headers:
|
|
4825
|
+
return Response.json({ error: "email or userId parameter required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4102
4826
|
}
|
|
4103
4827
|
const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
|
|
4104
4828
|
const user = userId ? users.find((u) => u.id === userId) : users.find((u) => u.email === email);
|
|
@@ -4106,22 +4830,22 @@ function handleDebugUserRole(ctx, url) {
|
|
|
4106
4830
|
return Response.json({
|
|
4107
4831
|
error: `User not found`,
|
|
4108
4832
|
registeredUsers: users.map((u) => ({ id: u.id, email: u.email, role: u.role }))
|
|
4109
|
-
}, { status: 404, headers:
|
|
4833
|
+
}, { status: 404, headers: CORS_HEADERS4 });
|
|
4110
4834
|
}
|
|
4111
4835
|
return Response.json({
|
|
4112
4836
|
userId: user.id,
|
|
4113
4837
|
email: user.email,
|
|
4114
4838
|
name: user.name,
|
|
4115
4839
|
role: user.role
|
|
4116
|
-
}, { headers:
|
|
4840
|
+
}, { headers: CORS_HEADERS4 });
|
|
4117
4841
|
}
|
|
4118
4842
|
async function handleDebugSetRole(ctx, request) {
|
|
4119
4843
|
const body = await request.json();
|
|
4120
4844
|
if (!body.role) {
|
|
4121
|
-
return Response.json({ error: "role is required" }, { status: 400, headers:
|
|
4845
|
+
return Response.json({ error: "role is required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4122
4846
|
}
|
|
4123
4847
|
if (!body.email && !body.userId) {
|
|
4124
|
-
return Response.json({ error: "email or userId is required" }, { status: 400, headers:
|
|
4848
|
+
return Response.json({ error: "email or userId is required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4125
4849
|
}
|
|
4126
4850
|
const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
|
|
4127
4851
|
const user = body.userId ? users.find((u) => u.id === body.userId) : users.find((u) => u.email === body.email);
|
|
@@ -4129,7 +4853,7 @@ async function handleDebugSetRole(ctx, request) {
|
|
|
4129
4853
|
return Response.json({
|
|
4130
4854
|
error: "User not found",
|
|
4131
4855
|
registeredUsers: users.map((u) => ({ id: u.id, email: u.email, role: u.role }))
|
|
4132
|
-
}, { status: 404, headers:
|
|
4856
|
+
}, { status: 404, headers: CORS_HEADERS4 });
|
|
4133
4857
|
}
|
|
4134
4858
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4135
4859
|
ctx.sql.exec(
|
|
@@ -4143,7 +4867,7 @@ async function handleDebugSetRole(ctx, request) {
|
|
|
4143
4867
|
email: user.email,
|
|
4144
4868
|
previousRole: user.role,
|
|
4145
4869
|
newRole: body.role
|
|
4146
|
-
}, { headers:
|
|
4870
|
+
}, { headers: CORS_HEADERS4 });
|
|
4147
4871
|
}
|
|
4148
4872
|
function handleDebugStatus(ctx) {
|
|
4149
4873
|
const users = getAllUsers(ctx.sql, ctx.schemaRegistry);
|
|
@@ -4161,7 +4885,7 @@ function handleDebugStatus(ctx) {
|
|
|
4161
4885
|
}
|
|
4162
4886
|
return Response.json(
|
|
4163
4887
|
{ users, schemas: schemas.map((s) => s.name), connections },
|
|
4164
|
-
{ headers:
|
|
4888
|
+
{ headers: CORS_HEADERS4 }
|
|
4165
4889
|
);
|
|
4166
4890
|
}
|
|
4167
4891
|
function handleDebugRecords(ctx, path) {
|
|
@@ -4180,12 +4904,12 @@ function handleDebugRecords(ctx, path) {
|
|
|
4180
4904
|
createdAt: r._created_at
|
|
4181
4905
|
};
|
|
4182
4906
|
});
|
|
4183
|
-
return Response.json({ collection, records }, { headers:
|
|
4907
|
+
return Response.json({ collection, records }, { headers: CORS_HEADERS4 });
|
|
4184
4908
|
} catch (error) {
|
|
4185
4909
|
return Response.json({
|
|
4186
4910
|
error: error instanceof Error ? error.message : "Query failed",
|
|
4187
4911
|
collection
|
|
4188
|
-
}, { status: 400, headers:
|
|
4912
|
+
}, { status: 400, headers: CORS_HEADERS4 });
|
|
4189
4913
|
}
|
|
4190
4914
|
}
|
|
4191
4915
|
function handleDebugQuery(ctx, url) {
|
|
@@ -4193,17 +4917,17 @@ function handleDebugQuery(ctx, url) {
|
|
|
4193
4917
|
const userId = url.searchParams.get("userId");
|
|
4194
4918
|
const role = url.searchParams.get("role");
|
|
4195
4919
|
if (!collection) {
|
|
4196
|
-
return Response.json({ error: "collection parameter required" }, { status: 400, headers:
|
|
4920
|
+
return Response.json({ error: "collection parameter required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4197
4921
|
}
|
|
4198
4922
|
if (!userId) {
|
|
4199
|
-
return Response.json({ error: "userId parameter required" }, { status: 400, headers:
|
|
4923
|
+
return Response.json({ error: "userId parameter required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4200
4924
|
}
|
|
4201
4925
|
if (!role) {
|
|
4202
|
-
return Response.json({ error: "role parameter required" }, { status: 400, headers:
|
|
4926
|
+
return Response.json({ error: "role parameter required" }, { status: 400, headers: CORS_HEADERS4 });
|
|
4203
4927
|
}
|
|
4204
4928
|
const user = getUser(ctx.sql, userId, ctx.schemaRegistry);
|
|
4205
4929
|
if (!user) {
|
|
4206
|
-
return Response.json({ error: `User not found: ${userId}` }, { status: 404, headers:
|
|
4930
|
+
return Response.json({ error: `User not found: ${userId}` }, { status: 404, headers: CORS_HEADERS4 });
|
|
4207
4931
|
}
|
|
4208
4932
|
const query = { collection };
|
|
4209
4933
|
const where = {};
|
|
@@ -4222,7 +4946,7 @@ function handleDebugQuery(ctx, url) {
|
|
|
4222
4946
|
asUser: { id: userId, name: user.name, actualRole: user.role, testingAsRole: role },
|
|
4223
4947
|
recordCount: records.length,
|
|
4224
4948
|
records
|
|
4225
|
-
}, { headers:
|
|
4949
|
+
}, { headers: CORS_HEADERS4 });
|
|
4226
4950
|
}
|
|
4227
4951
|
async function handleDebugSql(ctx, request, url) {
|
|
4228
4952
|
let sql;
|
|
@@ -4245,7 +4969,7 @@ async function handleDebugSql(ctx, request, url) {
|
|
|
4245
4969
|
"DESCRIBE <table> - show table schema"
|
|
4246
4970
|
]
|
|
4247
4971
|
}
|
|
4248
|
-
}, { status: 400, headers:
|
|
4972
|
+
}, { status: 400, headers: CORS_HEADERS4 });
|
|
4249
4973
|
}
|
|
4250
4974
|
const sqlUpper = sql.trim().toUpperCase();
|
|
4251
4975
|
try {
|
|
@@ -4254,7 +4978,7 @@ async function handleDebugSql(ctx, request, url) {
|
|
|
4254
4978
|
`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`
|
|
4255
4979
|
);
|
|
4256
4980
|
const tables = cursor2.toArray().map((r) => r.name);
|
|
4257
|
-
return Response.json({ tables }, { headers:
|
|
4981
|
+
return Response.json({ tables }, { headers: CORS_HEADERS4 });
|
|
4258
4982
|
}
|
|
4259
4983
|
if (sqlUpper.startsWith("DESCRIBE ") || sqlUpper.startsWith(".SCHEMA ")) {
|
|
4260
4984
|
const tableName = sql.trim().split(/\s+/)[1];
|
|
@@ -4269,7 +4993,7 @@ async function handleDebugSql(ctx, request, url) {
|
|
|
4269
4993
|
primaryKey: col.pk === 1
|
|
4270
4994
|
};
|
|
4271
4995
|
});
|
|
4272
|
-
return Response.json({ table: tableName, columns }, { headers:
|
|
4996
|
+
return Response.json({ table: tableName, columns }, { headers: CORS_HEADERS4 });
|
|
4273
4997
|
}
|
|
4274
4998
|
const cursor = ctx.sql.exec(sql, ...params);
|
|
4275
4999
|
const rows = cursor.toArray();
|
|
@@ -4277,12 +5001,12 @@ async function handleDebugSql(ctx, request, url) {
|
|
|
4277
5001
|
sql,
|
|
4278
5002
|
rowCount: rows.length,
|
|
4279
5003
|
rows
|
|
4280
|
-
}, { headers:
|
|
5004
|
+
}, { headers: CORS_HEADERS4 });
|
|
4281
5005
|
} catch (error) {
|
|
4282
5006
|
return Response.json({
|
|
4283
5007
|
error: error instanceof Error ? error.message : "Query failed",
|
|
4284
5008
|
sql
|
|
4285
|
-
}, { status: 400, headers:
|
|
5009
|
+
}, { status: 400, headers: CORS_HEADERS4 });
|
|
4286
5010
|
}
|
|
4287
5011
|
}
|
|
4288
5012
|
|
|
@@ -5518,40 +6242,88 @@ var CronRoom = class extends BaseRoom {
|
|
|
5518
6242
|
}
|
|
5519
6243
|
async onMessage(ws, user, message) {
|
|
5520
6244
|
this.ensureInitialized();
|
|
5521
|
-
const { type, payload } = message;
|
|
6245
|
+
const { type, payload = {} } = message;
|
|
5522
6246
|
if (CRON_WRITE_TYPES.has(type) && !user.canWrite) {
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
6247
|
+
const error = "Write access denied: viewer role cannot modify cron tasks";
|
|
6248
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
6249
|
+
this.ackMutation(ws, payload.requestId, {
|
|
6250
|
+
ok: false,
|
|
6251
|
+
taskName: payload.taskName,
|
|
6252
|
+
reason: "read_only",
|
|
6253
|
+
error
|
|
5526
6254
|
});
|
|
5527
6255
|
return;
|
|
5528
6256
|
}
|
|
6257
|
+
try {
|
|
6258
|
+
await this.dispatchMessage(ws, type, payload);
|
|
6259
|
+
} catch (error) {
|
|
6260
|
+
try {
|
|
6261
|
+
this.ackMutation(ws, payload.requestId, {
|
|
6262
|
+
ok: false,
|
|
6263
|
+
taskName: payload.taskName,
|
|
6264
|
+
reason: "failed",
|
|
6265
|
+
error: error instanceof Error ? error.message : String(error)
|
|
6266
|
+
});
|
|
6267
|
+
} catch {
|
|
6268
|
+
}
|
|
6269
|
+
throw error;
|
|
6270
|
+
}
|
|
6271
|
+
}
|
|
6272
|
+
async dispatchMessage(ws, type, payload) {
|
|
5529
6273
|
switch (type) {
|
|
5530
6274
|
case MSG.CRON_TRIGGER: {
|
|
5531
6275
|
const taskName = payload.taskName;
|
|
5532
6276
|
if (!taskName) {
|
|
5533
|
-
|
|
6277
|
+
const error = "Missing taskName";
|
|
6278
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
6279
|
+
this.ackMutation(ws, payload.requestId, { ok: false, reason: "failed", error });
|
|
5534
6280
|
return;
|
|
5535
6281
|
}
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
6282
|
+
const execution = await this.executeTask(taskName);
|
|
6283
|
+
if (!execution) {
|
|
6284
|
+
const error = `Unknown cron task: ${taskName}`;
|
|
6285
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
6286
|
+
this.ackMutation(ws, payload.requestId, {
|
|
6287
|
+
ok: false,
|
|
6288
|
+
taskName,
|
|
6289
|
+
reason: "unknown_task",
|
|
6290
|
+
error
|
|
5540
6291
|
});
|
|
6292
|
+
break;
|
|
5541
6293
|
}
|
|
6294
|
+
this.ackMutation(
|
|
6295
|
+
ws,
|
|
6296
|
+
payload.requestId,
|
|
6297
|
+
execution.success ? { ok: true, taskName } : { ok: false, taskName, reason: "failed", error: execution.error }
|
|
6298
|
+
);
|
|
5542
6299
|
break;
|
|
5543
6300
|
}
|
|
5544
|
-
case MSG.CRON_PAUSE:
|
|
5545
|
-
const taskName = payload.taskName;
|
|
5546
|
-
this.sql.exec(`UPDATE cron_tasks SET paused = 1 WHERE name = ?`, taskName);
|
|
5547
|
-
this.broadcastStatus();
|
|
5548
|
-
break;
|
|
5549
|
-
}
|
|
6301
|
+
case MSG.CRON_PAUSE:
|
|
5550
6302
|
case MSG.CRON_RESUME: {
|
|
5551
6303
|
const taskName = payload.taskName;
|
|
5552
|
-
|
|
5553
|
-
|
|
6304
|
+
if (!taskName) {
|
|
6305
|
+
const error = "Missing taskName";
|
|
6306
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
6307
|
+
this.ackMutation(ws, payload.requestId, { ok: false, reason: "failed", error });
|
|
6308
|
+
return;
|
|
6309
|
+
}
|
|
6310
|
+
const known = this.sql.exec(`SELECT name FROM cron_tasks WHERE name = ?`, taskName).toArray().length > 0;
|
|
6311
|
+
if (!known) {
|
|
6312
|
+
const error = `Unknown cron task: ${taskName}`;
|
|
6313
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
6314
|
+
this.ackMutation(ws, payload.requestId, {
|
|
6315
|
+
ok: false,
|
|
6316
|
+
taskName,
|
|
6317
|
+
reason: "unknown_task",
|
|
6318
|
+
error
|
|
6319
|
+
});
|
|
6320
|
+
break;
|
|
6321
|
+
}
|
|
6322
|
+
const paused = type === MSG.CRON_PAUSE;
|
|
6323
|
+
this.sql.exec(`UPDATE cron_tasks SET paused = ? WHERE name = ?`, paused ? 1 : 0, taskName);
|
|
6324
|
+
if (!paused) this.scheduleNextAlarm();
|
|
5554
6325
|
this.broadcastStatus();
|
|
6326
|
+
this.ackMutation(ws, payload.requestId, { ok: true, taskName });
|
|
5555
6327
|
break;
|
|
5556
6328
|
}
|
|
5557
6329
|
case MSG.CRON_TASKS: {
|
|
@@ -5594,7 +6366,7 @@ var CronRoom = class extends BaseRoom {
|
|
|
5594
6366
|
// ==========================================================================
|
|
5595
6367
|
async executeTask(taskName) {
|
|
5596
6368
|
const taskRow = this.sql.exec(`SELECT interval_minutes, schedule, timezone FROM cron_tasks WHERE name = ?`, taskName).toArray()[0];
|
|
5597
|
-
if (!taskRow) return
|
|
6369
|
+
if (!taskRow) return null;
|
|
5598
6370
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5599
6371
|
const start = Date.now();
|
|
5600
6372
|
let success = true;
|
|
@@ -5630,7 +6402,7 @@ var CronRoom = class extends BaseRoom {
|
|
|
5630
6402
|
`DELETE FROM cron_history WHERE id NOT IN (SELECT id FROM cron_history ORDER BY id DESC LIMIT 500)`
|
|
5631
6403
|
);
|
|
5632
6404
|
this.broadcastStatus();
|
|
5633
|
-
return
|
|
6405
|
+
return { taskName, startedAt, completedAt, success, durationMs, error };
|
|
5634
6406
|
}
|
|
5635
6407
|
// ==========================================================================
|
|
5636
6408
|
// Scheduling
|
|
@@ -5700,6 +6472,21 @@ var CronRoom = class extends BaseRoom {
|
|
|
5700
6472
|
}
|
|
5701
6473
|
});
|
|
5702
6474
|
}
|
|
6475
|
+
/**
|
|
6476
|
+
* Receipt for a mutation frame, addressed only to its sender. Receipts
|
|
6477
|
+
* are opt-in by correlation id: frames without a `requestId` keep the
|
|
6478
|
+
* fire-and-forget contract, so the (untyped) `requestId` from the wire
|
|
6479
|
+
* is the single gate here rather than a check at every call site.
|
|
6480
|
+
* Frames go through the shared `serverBuild` constructors so the wire
|
|
6481
|
+
* shape is enforced by the protocol types, not re-spelled here.
|
|
6482
|
+
*/
|
|
6483
|
+
ackMutation(ws, requestId, result) {
|
|
6484
|
+
if (typeof requestId !== "string") return;
|
|
6485
|
+
this.sendTo(
|
|
6486
|
+
ws,
|
|
6487
|
+
result.ok ? serverBuild.cronAckSuccess(requestId, result.taskName) : serverBuild.cronAckFailure(requestId, result.reason, result.error, result.taskName)
|
|
6488
|
+
);
|
|
6489
|
+
}
|
|
5703
6490
|
};
|
|
5704
6491
|
function computeNextRunAt(task, from) {
|
|
5705
6492
|
if (task.interval_minutes) {
|