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/worker.js
CHANGED
|
@@ -351,6 +351,9 @@ var MSG = {
|
|
|
351
351
|
CRON_PAUSE: "cron.pause",
|
|
352
352
|
CRON_RESUME: "cron.resume",
|
|
353
353
|
CRON_STATUS: "cron.status",
|
|
354
|
+
// Per-mutation receipt, sent only to the socket whose trigger/pause/
|
|
355
|
+
// resume carried a `requestId` — frames without one stay fire-and-forget.
|
|
356
|
+
CRON_ACK: "cron.ack",
|
|
354
357
|
// ---- JobRoom ---------------------------------------------------------
|
|
355
358
|
// Background-job queue with durable, alarm-driven execution. See
|
|
356
359
|
// server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
|
|
@@ -1063,9 +1066,9 @@ var clientBuild = {
|
|
|
1063
1066
|
canvasUndo: () => ({ type: MSG.CANVAS_UNDO, payload: EMPTY }),
|
|
1064
1067
|
canvasRedo: () => ({ type: MSG.CANVAS_REDO, payload: EMPTY }),
|
|
1065
1068
|
// Cron
|
|
1066
|
-
cronTrigger: (taskName) => ({ type: MSG.CRON_TRIGGER, payload: { taskName } }),
|
|
1067
|
-
cronPause: (taskName) => ({ type: MSG.CRON_PAUSE, payload: { taskName } }),
|
|
1068
|
-
cronResume: (taskName) => ({ type: MSG.CRON_RESUME, payload: { taskName } }),
|
|
1069
|
+
cronTrigger: (taskName, requestId) => ({ type: MSG.CRON_TRIGGER, payload: { taskName, requestId } }),
|
|
1070
|
+
cronPause: (taskName, requestId) => ({ type: MSG.CRON_PAUSE, payload: { taskName, requestId } }),
|
|
1071
|
+
cronResume: (taskName, requestId) => ({ type: MSG.CRON_RESUME, payload: { taskName, requestId } }),
|
|
1069
1072
|
// Jobs
|
|
1070
1073
|
jobEnqueue: (requestId, type, payload, maxAttempts) => ({
|
|
1071
1074
|
type: MSG.JOB_ENQUEUE,
|
|
@@ -1119,6 +1122,14 @@ var serverBuild = {
|
|
|
1119
1122
|
cronTasks: (tasks) => ({ type: MSG.CRON_TASKS, payload: { tasks } }),
|
|
1120
1123
|
cronHistory: (history) => ({ type: MSG.CRON_HISTORY, payload: { history } }),
|
|
1121
1124
|
cronStatus: (tasks, recentHistory) => ({ type: MSG.CRON_STATUS, payload: { tasks, recentHistory } }),
|
|
1125
|
+
cronAckSuccess: (requestId, taskName) => ({
|
|
1126
|
+
type: MSG.CRON_ACK,
|
|
1127
|
+
payload: { requestId, taskName, ok: true }
|
|
1128
|
+
}),
|
|
1129
|
+
cronAckFailure: (requestId, reason, error, taskName) => ({
|
|
1130
|
+
type: MSG.CRON_ACK,
|
|
1131
|
+
payload: { requestId, taskName, ok: false, reason, error }
|
|
1132
|
+
}),
|
|
1122
1133
|
// Jobs
|
|
1123
1134
|
jobSnapshot: (jobs) => ({ type: MSG.JOB_UPDATE, payload: { kind: "snapshot", jobs } }),
|
|
1124
1135
|
jobEnqueued: (job, requestId) => ({
|
|
@@ -3992,40 +4003,88 @@ var CronRoom = class extends BaseRoom {
|
|
|
3992
4003
|
}
|
|
3993
4004
|
async onMessage(ws, user, message) {
|
|
3994
4005
|
this.ensureInitialized();
|
|
3995
|
-
const { type, payload } = message;
|
|
4006
|
+
const { type, payload = {} } = message;
|
|
3996
4007
|
if (CRON_WRITE_TYPES.has(type) && !user.canWrite) {
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4008
|
+
const error = "Write access denied: viewer role cannot modify cron tasks";
|
|
4009
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
4010
|
+
this.ackMutation(ws, payload.requestId, {
|
|
4011
|
+
ok: false,
|
|
4012
|
+
taskName: payload.taskName,
|
|
4013
|
+
reason: "read_only",
|
|
4014
|
+
error
|
|
4000
4015
|
});
|
|
4001
4016
|
return;
|
|
4002
4017
|
}
|
|
4018
|
+
try {
|
|
4019
|
+
await this.dispatchMessage(ws, type, payload);
|
|
4020
|
+
} catch (error) {
|
|
4021
|
+
try {
|
|
4022
|
+
this.ackMutation(ws, payload.requestId, {
|
|
4023
|
+
ok: false,
|
|
4024
|
+
taskName: payload.taskName,
|
|
4025
|
+
reason: "failed",
|
|
4026
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4027
|
+
});
|
|
4028
|
+
} catch {
|
|
4029
|
+
}
|
|
4030
|
+
throw error;
|
|
4031
|
+
}
|
|
4032
|
+
}
|
|
4033
|
+
async dispatchMessage(ws, type, payload) {
|
|
4003
4034
|
switch (type) {
|
|
4004
4035
|
case MSG.CRON_TRIGGER: {
|
|
4005
4036
|
const taskName = payload.taskName;
|
|
4006
4037
|
if (!taskName) {
|
|
4007
|
-
|
|
4038
|
+
const error = "Missing taskName";
|
|
4039
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
4040
|
+
this.ackMutation(ws, payload.requestId, { ok: false, reason: "failed", error });
|
|
4008
4041
|
return;
|
|
4009
4042
|
}
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4043
|
+
const execution = await this.executeTask(taskName);
|
|
4044
|
+
if (!execution) {
|
|
4045
|
+
const error = `Unknown cron task: ${taskName}`;
|
|
4046
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
4047
|
+
this.ackMutation(ws, payload.requestId, {
|
|
4048
|
+
ok: false,
|
|
4049
|
+
taskName,
|
|
4050
|
+
reason: "unknown_task",
|
|
4051
|
+
error
|
|
4014
4052
|
});
|
|
4053
|
+
break;
|
|
4015
4054
|
}
|
|
4055
|
+
this.ackMutation(
|
|
4056
|
+
ws,
|
|
4057
|
+
payload.requestId,
|
|
4058
|
+
execution.success ? { ok: true, taskName } : { ok: false, taskName, reason: "failed", error: execution.error }
|
|
4059
|
+
);
|
|
4016
4060
|
break;
|
|
4017
4061
|
}
|
|
4018
|
-
case MSG.CRON_PAUSE:
|
|
4019
|
-
const taskName = payload.taskName;
|
|
4020
|
-
this.sql.exec(`UPDATE cron_tasks SET paused = 1 WHERE name = ?`, taskName);
|
|
4021
|
-
this.broadcastStatus();
|
|
4022
|
-
break;
|
|
4023
|
-
}
|
|
4062
|
+
case MSG.CRON_PAUSE:
|
|
4024
4063
|
case MSG.CRON_RESUME: {
|
|
4025
4064
|
const taskName = payload.taskName;
|
|
4026
|
-
|
|
4027
|
-
|
|
4065
|
+
if (!taskName) {
|
|
4066
|
+
const error = "Missing taskName";
|
|
4067
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
4068
|
+
this.ackMutation(ws, payload.requestId, { ok: false, reason: "failed", error });
|
|
4069
|
+
return;
|
|
4070
|
+
}
|
|
4071
|
+
const known = this.sql.exec(`SELECT name FROM cron_tasks WHERE name = ?`, taskName).toArray().length > 0;
|
|
4072
|
+
if (!known) {
|
|
4073
|
+
const error = `Unknown cron task: ${taskName}`;
|
|
4074
|
+
this.sendTo(ws, { type: MSG.ERROR, payload: { error } });
|
|
4075
|
+
this.ackMutation(ws, payload.requestId, {
|
|
4076
|
+
ok: false,
|
|
4077
|
+
taskName,
|
|
4078
|
+
reason: "unknown_task",
|
|
4079
|
+
error
|
|
4080
|
+
});
|
|
4081
|
+
break;
|
|
4082
|
+
}
|
|
4083
|
+
const paused = type === MSG.CRON_PAUSE;
|
|
4084
|
+
this.sql.exec(`UPDATE cron_tasks SET paused = ? WHERE name = ?`, paused ? 1 : 0, taskName);
|
|
4085
|
+
if (!paused) this.scheduleNextAlarm();
|
|
4028
4086
|
this.broadcastStatus();
|
|
4087
|
+
this.ackMutation(ws, payload.requestId, { ok: true, taskName });
|
|
4029
4088
|
break;
|
|
4030
4089
|
}
|
|
4031
4090
|
case MSG.CRON_TASKS: {
|
|
@@ -4068,7 +4127,7 @@ var CronRoom = class extends BaseRoom {
|
|
|
4068
4127
|
// ==========================================================================
|
|
4069
4128
|
async executeTask(taskName) {
|
|
4070
4129
|
const taskRow = this.sql.exec(`SELECT interval_minutes, schedule, timezone FROM cron_tasks WHERE name = ?`, taskName).toArray()[0];
|
|
4071
|
-
if (!taskRow) return
|
|
4130
|
+
if (!taskRow) return null;
|
|
4072
4131
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4073
4132
|
const start = Date.now();
|
|
4074
4133
|
let success = true;
|
|
@@ -4104,7 +4163,7 @@ var CronRoom = class extends BaseRoom {
|
|
|
4104
4163
|
`DELETE FROM cron_history WHERE id NOT IN (SELECT id FROM cron_history ORDER BY id DESC LIMIT 500)`
|
|
4105
4164
|
);
|
|
4106
4165
|
this.broadcastStatus();
|
|
4107
|
-
return
|
|
4166
|
+
return { taskName, startedAt, completedAt, success, durationMs, error };
|
|
4108
4167
|
}
|
|
4109
4168
|
// ==========================================================================
|
|
4110
4169
|
// Scheduling
|
|
@@ -4174,6 +4233,21 @@ var CronRoom = class extends BaseRoom {
|
|
|
4174
4233
|
}
|
|
4175
4234
|
});
|
|
4176
4235
|
}
|
|
4236
|
+
/**
|
|
4237
|
+
* Receipt for a mutation frame, addressed only to its sender. Receipts
|
|
4238
|
+
* are opt-in by correlation id: frames without a `requestId` keep the
|
|
4239
|
+
* fire-and-forget contract, so the (untyped) `requestId` from the wire
|
|
4240
|
+
* is the single gate here rather than a check at every call site.
|
|
4241
|
+
* Frames go through the shared `serverBuild` constructors so the wire
|
|
4242
|
+
* shape is enforced by the protocol types, not re-spelled here.
|
|
4243
|
+
*/
|
|
4244
|
+
ackMutation(ws, requestId, result) {
|
|
4245
|
+
if (typeof requestId !== "string") return;
|
|
4246
|
+
this.sendTo(
|
|
4247
|
+
ws,
|
|
4248
|
+
result.ok ? serverBuild.cronAckSuccess(requestId, result.taskName) : serverBuild.cronAckFailure(requestId, result.reason, result.error, result.taskName)
|
|
4249
|
+
);
|
|
4250
|
+
}
|
|
4177
4251
|
};
|
|
4178
4252
|
function computeNextRunAt(task, from) {
|
|
4179
4253
|
if (task.interval_minutes) {
|
|
@@ -6093,9 +6167,6 @@ var DANGEROUS_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
|
6093
6167
|
"application/ecmascript",
|
|
6094
6168
|
"text/ecmascript"
|
|
6095
6169
|
]);
|
|
6096
|
-
function activeContentMessage(mimeType) {
|
|
6097
|
-
return `${mimeType} is not accepted: the files API serves your app's own origin, so stored HTML/SVG/JavaScript would execute there with your users' sessions. Ship it as a build asset under \`public/\` instead, where it is served as part of your app rather than as user-uploaded content.`;
|
|
6098
|
-
}
|
|
6099
6170
|
function formatBytes(bytes) {
|
|
6100
6171
|
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
|
|
6101
6172
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
@@ -6115,21 +6186,479 @@ function oversizePartMessage(bytes) {
|
|
|
6115
6186
|
return `This part carries ${formatBytes(bytes)}; a part may carry ${formatBytes(UPLOAD_PART_BYTES)} \u2014 the size the upload advertised.`;
|
|
6116
6187
|
}
|
|
6117
6188
|
|
|
6118
|
-
// src/server/utils/
|
|
6189
|
+
// src/server/utils/r2-storage-quota.ts
|
|
6119
6190
|
var CORS_HEADERS3 = { "Access-Control-Allow-Origin": "*" };
|
|
6191
|
+
var QUOTA_SUMMARY_VERSION = 1;
|
|
6192
|
+
var MAX_QUOTA_SCAN_OBJECTS = 5e5;
|
|
6193
|
+
var MAX_QUOTA_SCAN_LISTS = 900;
|
|
6194
|
+
var QUOTA_REPAIR_COOLDOWN_MS = 6e4;
|
|
6195
|
+
var PENDING_MARKER_MAX_AGE_MS = 60 * 6e4;
|
|
6196
|
+
var EMPTY_MULTIPART_MARKER_MAX_AGE_MS = 60 * 6e4;
|
|
6197
|
+
var ACTIVE_MULTIPART_MARKER_MAX_AGE_MS = 8 * 24 * 60 * 6e4;
|
|
6120
6198
|
function fail(status, error, code, extra) {
|
|
6121
6199
|
return Response.json(
|
|
6122
6200
|
{ error, ...code ? { code } : {}, ...extra },
|
|
6123
6201
|
{ status, headers: CORS_HEADERS3 }
|
|
6124
6202
|
);
|
|
6125
6203
|
}
|
|
6126
|
-
function
|
|
6204
|
+
function quotaUnavailable(message) {
|
|
6205
|
+
return fail(503, message, "storage_limit_unavailable");
|
|
6206
|
+
}
|
|
6207
|
+
function quotaKeySegment(value) {
|
|
6208
|
+
return encodeURIComponent(value);
|
|
6209
|
+
}
|
|
6210
|
+
function markerPrefix(storage, kind) {
|
|
6211
|
+
return `__deepspace/quota/${kind}/${quotaKeySegment(storage.quotaKey)}/`;
|
|
6212
|
+
}
|
|
6213
|
+
function pendingMarkerPrefix(storage) {
|
|
6214
|
+
return markerPrefix(storage, "pending");
|
|
6215
|
+
}
|
|
6216
|
+
function multipartMarkerPrefix(storage) {
|
|
6217
|
+
return markerPrefix(storage, "multipart");
|
|
6218
|
+
}
|
|
6219
|
+
function multipartMarkerKey(storage, reservationId) {
|
|
6220
|
+
return `${multipartMarkerPrefix(storage)}${quotaKeySegment(reservationId)}`;
|
|
6221
|
+
}
|
|
6222
|
+
function accountPrefixesEqual(left, right) {
|
|
6223
|
+
return left.length === right.length && left.every((prefix, index) => prefix === right[index]);
|
|
6224
|
+
}
|
|
6225
|
+
async function allocationPrefixes(storage) {
|
|
6226
|
+
const siblings = storage.siblingPrefixes ? await storage.siblingPrefixes() : [];
|
|
6227
|
+
if (siblings === null) return null;
|
|
6228
|
+
return [.../* @__PURE__ */ new Set([storage.prefix, ...siblings])].sort();
|
|
6229
|
+
}
|
|
6230
|
+
function countScan(budget, count) {
|
|
6231
|
+
budget.lists++;
|
|
6232
|
+
budget.objects += count;
|
|
6233
|
+
if (budget.lists > MAX_QUOTA_SCAN_LISTS || budget.objects > MAX_QUOTA_SCAN_OBJECTS) {
|
|
6234
|
+
throw new Error("quota scan work limit exceeded");
|
|
6235
|
+
}
|
|
6236
|
+
}
|
|
6237
|
+
async function prefixUsage(bucket, prefix, budget) {
|
|
6238
|
+
let total = 0;
|
|
6239
|
+
let cursor;
|
|
6240
|
+
do {
|
|
6241
|
+
const page = await bucket.list({ prefix, cursor, limit: 1e3 });
|
|
6242
|
+
countScan(budget, page.objects.length);
|
|
6243
|
+
for (const object of page.objects) total += object.size;
|
|
6244
|
+
cursor = page.truncated ? page.cursor : void 0;
|
|
6245
|
+
} while (cursor !== void 0);
|
|
6246
|
+
return total;
|
|
6247
|
+
}
|
|
6248
|
+
function markerBytes(object) {
|
|
6249
|
+
const raw = object.customMetadata?.reservedBytes;
|
|
6250
|
+
const bytes = raw === void 0 ? NaN : Number(raw);
|
|
6251
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0) throw new Error("invalid quota marker");
|
|
6252
|
+
return bytes;
|
|
6253
|
+
}
|
|
6254
|
+
function markerMaxAge(object, fallback) {
|
|
6255
|
+
if (object.customMetadata?.quotaKind !== "multipart") return fallback;
|
|
6256
|
+
return object.customMetadata.active === "true" ? ACTIVE_MULTIPART_MARKER_MAX_AGE_MS : EMPTY_MULTIPART_MARKER_MAX_AGE_MS;
|
|
6257
|
+
}
|
|
6258
|
+
async function markerUsage(bucket, prefix, maxAgeMs, budget, excludedKey) {
|
|
6259
|
+
let total = 0;
|
|
6260
|
+
let cursor;
|
|
6261
|
+
const now = Date.now();
|
|
6262
|
+
do {
|
|
6263
|
+
const options = {
|
|
6264
|
+
prefix,
|
|
6265
|
+
cursor,
|
|
6266
|
+
limit: 1e3,
|
|
6267
|
+
include: ["customMetadata"]
|
|
6268
|
+
};
|
|
6269
|
+
const page = await bucket.list(options);
|
|
6270
|
+
countScan(budget, page.objects.length);
|
|
6271
|
+
for (const object of page.objects) {
|
|
6272
|
+
if (now - object.uploaded.getTime() > markerMaxAge(object, maxAgeMs)) {
|
|
6273
|
+
if (budget.staleKeys.length < 1e3) budget.staleKeys.push(object.key);
|
|
6274
|
+
} else if (object.key !== excludedKey) {
|
|
6275
|
+
total += markerBytes(object);
|
|
6276
|
+
}
|
|
6277
|
+
}
|
|
6278
|
+
cursor = page.truncated ? page.cursor : void 0;
|
|
6279
|
+
} while (cursor !== void 0);
|
|
6280
|
+
return total;
|
|
6281
|
+
}
|
|
6282
|
+
async function measuredAllocationUsage(bucket, storage, prefixes, excludedMarkerKey) {
|
|
6283
|
+
const budget = { lists: 0, objects: 0, staleKeys: [] };
|
|
6284
|
+
let total = 0;
|
|
6285
|
+
for (const prefix of prefixes) total += await prefixUsage(bucket, prefix, budget);
|
|
6286
|
+
total += await markerUsage(
|
|
6287
|
+
bucket,
|
|
6288
|
+
pendingMarkerPrefix(storage),
|
|
6289
|
+
PENDING_MARKER_MAX_AGE_MS,
|
|
6290
|
+
budget,
|
|
6291
|
+
excludedMarkerKey
|
|
6292
|
+
);
|
|
6293
|
+
total += await markerUsage(
|
|
6294
|
+
bucket,
|
|
6295
|
+
multipartMarkerPrefix(storage),
|
|
6296
|
+
ACTIVE_MULTIPART_MARKER_MAX_AGE_MS,
|
|
6297
|
+
budget,
|
|
6298
|
+
excludedMarkerKey
|
|
6299
|
+
);
|
|
6300
|
+
if (budget.staleKeys.length > 0) {
|
|
6301
|
+
await bucket.delete(budget.staleKeys).catch((error) => {
|
|
6302
|
+
console.error("[files:quota] stale marker cleanup:", error);
|
|
6303
|
+
});
|
|
6304
|
+
}
|
|
6305
|
+
return total;
|
|
6306
|
+
}
|
|
6307
|
+
function isQuotaSummary(value) {
|
|
6308
|
+
if (!value || typeof value !== "object") return false;
|
|
6309
|
+
const summary = value;
|
|
6310
|
+
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";
|
|
6311
|
+
}
|
|
6312
|
+
async function readQuotaSummary(bucket, key) {
|
|
6313
|
+
const object = await bucket.get(key);
|
|
6314
|
+
if (!object) return { etag: null, summary: null };
|
|
6315
|
+
try {
|
|
6316
|
+
const parsed = await object.json();
|
|
6317
|
+
return { etag: object.etag, summary: isQuotaSummary(parsed) ? parsed : null };
|
|
6318
|
+
} catch {
|
|
6319
|
+
return { etag: object.etag, summary: null };
|
|
6320
|
+
}
|
|
6321
|
+
}
|
|
6322
|
+
async function writeQuotaSummary(bucket, key, previousEtag, summary) {
|
|
6323
|
+
const onlyIf = previousEtag ? { etagMatches: previousEtag } : new Headers({ "If-None-Match": "*" });
|
|
6324
|
+
const object = await bucket.put(key, JSON.stringify(summary), {
|
|
6325
|
+
onlyIf,
|
|
6326
|
+
httpMetadata: { contentType: "application/json" }
|
|
6327
|
+
});
|
|
6328
|
+
return object ? { etag: object.etag, summary } : null;
|
|
6329
|
+
}
|
|
6330
|
+
async function ensureQuotaSummary(bucket, storage, prefixes, forceMeasure = false, excludedMarkerKey) {
|
|
6331
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
6332
|
+
const current = await readQuotaSummary(bucket, storage.quotaKey);
|
|
6333
|
+
if (!forceMeasure && current.summary && accountPrefixesEqual(current.summary.prefixes, prefixes)) {
|
|
6334
|
+
return current;
|
|
6335
|
+
}
|
|
6336
|
+
const usedBytes = await measuredAllocationUsage(bucket, storage, prefixes, excludedMarkerKey);
|
|
6337
|
+
const repaired = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
|
|
6338
|
+
version: QUOTA_SUMMARY_VERSION,
|
|
6339
|
+
prefixes,
|
|
6340
|
+
usedBytes,
|
|
6341
|
+
measuredAt: Date.now(),
|
|
6342
|
+
dirty: false
|
|
6343
|
+
});
|
|
6344
|
+
if (repaired) return repaired;
|
|
6345
|
+
}
|
|
6346
|
+
return null;
|
|
6347
|
+
}
|
|
6348
|
+
async function markQuotaDirty(bucket, storage) {
|
|
6349
|
+
if (!storage) return;
|
|
6350
|
+
try {
|
|
6351
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
6352
|
+
const current = await readQuotaSummary(bucket, storage.quotaKey);
|
|
6353
|
+
if (!current.summary || current.summary.dirty) return;
|
|
6354
|
+
const written = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
|
|
6355
|
+
...current.summary,
|
|
6356
|
+
dirty: true
|
|
6357
|
+
});
|
|
6358
|
+
if (written) return;
|
|
6359
|
+
}
|
|
6360
|
+
console.error("[files:quota] could not mark summary reclaimable after concurrent writes");
|
|
6361
|
+
} catch (error) {
|
|
6362
|
+
console.error("[files:quota] could not mark summary reclaimable:", error);
|
|
6363
|
+
}
|
|
6364
|
+
}
|
|
6365
|
+
async function neutralizeMarker(bucket, markerKey) {
|
|
6366
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
6367
|
+
const marker = await bucket.get(markerKey);
|
|
6368
|
+
if (!marker) return true;
|
|
6369
|
+
if (markerBytes(marker) === 0) return true;
|
|
6370
|
+
const body = await marker.arrayBuffer();
|
|
6371
|
+
const object = await bucket.put(markerKey, body, {
|
|
6372
|
+
onlyIf: { etagMatches: marker.etag },
|
|
6373
|
+
customMetadata: {
|
|
6374
|
+
...marker.customMetadata,
|
|
6375
|
+
reservedBytes: "0",
|
|
6376
|
+
releasedAt: String(Date.now())
|
|
6377
|
+
}
|
|
6378
|
+
});
|
|
6379
|
+
if (object) return true;
|
|
6380
|
+
}
|
|
6381
|
+
return false;
|
|
6382
|
+
}
|
|
6383
|
+
async function releaseQuotaMarker(bucket, storage, markerKey, summaryMayBeStale) {
|
|
6384
|
+
try {
|
|
6385
|
+
if (markerKey && !await neutralizeMarker(bucket, markerKey)) {
|
|
6386
|
+
console.error("[files:quota] could not neutralize reservation marker");
|
|
6387
|
+
return false;
|
|
6388
|
+
}
|
|
6389
|
+
if (summaryMayBeStale) await markQuotaDirty(bucket, storage);
|
|
6390
|
+
if (markerKey) {
|
|
6391
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
6392
|
+
try {
|
|
6393
|
+
await bucket.delete(markerKey);
|
|
6394
|
+
break;
|
|
6395
|
+
} catch (error) {
|
|
6396
|
+
if (attempt === 3) throw error;
|
|
6397
|
+
}
|
|
6398
|
+
}
|
|
6399
|
+
}
|
|
6400
|
+
return true;
|
|
6401
|
+
} catch (error) {
|
|
6402
|
+
console.error("[files:quota] reservation cleanup:", error);
|
|
6403
|
+
return false;
|
|
6404
|
+
}
|
|
6405
|
+
}
|
|
6406
|
+
function parseMultipartMarker(value) {
|
|
6407
|
+
if (!value || typeof value !== "object") return null;
|
|
6408
|
+
const marker = value;
|
|
6409
|
+
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) {
|
|
6410
|
+
return null;
|
|
6411
|
+
}
|
|
6412
|
+
return marker;
|
|
6413
|
+
}
|
|
6414
|
+
function multipartMarkerBody(reservation) {
|
|
6415
|
+
return JSON.stringify({
|
|
6416
|
+
version: 1,
|
|
6417
|
+
reservationId: reservation.reservationId,
|
|
6418
|
+
uploadId: reservation.uploadId,
|
|
6419
|
+
objectKey: reservation.objectKey,
|
|
6420
|
+
uploadKey: reservation.uploadKey,
|
|
6421
|
+
admitted: reservation.admitted,
|
|
6422
|
+
declaredBytes: reservation.declaredBytes
|
|
6423
|
+
});
|
|
6424
|
+
}
|
|
6425
|
+
async function readMultipartReservation(bucket, storage, reservationId) {
|
|
6426
|
+
if (!storage) return null;
|
|
6427
|
+
const key = multipartMarkerKey(storage, reservationId);
|
|
6428
|
+
let object;
|
|
6429
|
+
try {
|
|
6430
|
+
object = await bucket.get(key);
|
|
6431
|
+
} catch (error) {
|
|
6432
|
+
console.error("[files:quota] reservation read:", error);
|
|
6433
|
+
return "invalid";
|
|
6434
|
+
}
|
|
6435
|
+
if (!object) return null;
|
|
6436
|
+
let marker = null;
|
|
6437
|
+
try {
|
|
6438
|
+
marker = parseMultipartMarker(await object.json());
|
|
6439
|
+
} catch {
|
|
6440
|
+
return "invalid";
|
|
6441
|
+
}
|
|
6442
|
+
if (!marker || marker.reservationId !== reservationId) return "invalid";
|
|
6443
|
+
let reservedBytes;
|
|
6444
|
+
try {
|
|
6445
|
+
reservedBytes = markerBytes(object);
|
|
6446
|
+
} catch {
|
|
6447
|
+
return "invalid";
|
|
6448
|
+
}
|
|
6449
|
+
return {
|
|
6450
|
+
key,
|
|
6451
|
+
etag: object.etag,
|
|
6452
|
+
reservationId,
|
|
6453
|
+
uploadId: marker.uploadId,
|
|
6454
|
+
objectKey: marker.objectKey,
|
|
6455
|
+
uploadKey: marker.uploadKey,
|
|
6456
|
+
admitted: marker.admitted,
|
|
6457
|
+
declaredBytes: marker.declaredBytes,
|
|
6458
|
+
reservedBytes
|
|
6459
|
+
};
|
|
6460
|
+
}
|
|
6461
|
+
async function refreshMultipartReservation(bucket, storage, reservationId, uploadId, objectKey) {
|
|
6462
|
+
if (!storage) return null;
|
|
6463
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
6464
|
+
const reservation = await readMultipartReservation(bucket, storage, reservationId);
|
|
6465
|
+
if (reservation === null || reservation === "invalid") return reservation ?? "invalid";
|
|
6466
|
+
if (!reservation.admitted || reservation.uploadId !== uploadId || reservation.objectKey !== objectKey) {
|
|
6467
|
+
return "invalid";
|
|
6468
|
+
}
|
|
6469
|
+
const object = await bucket.put(reservation.key, multipartMarkerBody(reservation), {
|
|
6470
|
+
onlyIf: { etagMatches: reservation.etag },
|
|
6471
|
+
customMetadata: {
|
|
6472
|
+
quotaKind: "multipart",
|
|
6473
|
+
reservedBytes: String(reservation.reservedBytes),
|
|
6474
|
+
active: "true"
|
|
6475
|
+
}
|
|
6476
|
+
});
|
|
6477
|
+
if (object) return { ...reservation, etag: object.etag };
|
|
6478
|
+
}
|
|
6479
|
+
return "busy";
|
|
6480
|
+
}
|
|
6481
|
+
async function activateMultipartReservation(bucket, storage, reservationId) {
|
|
6482
|
+
if (!storage) return null;
|
|
6483
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
6484
|
+
const reservation = await readMultipartReservation(bucket, storage, reservationId);
|
|
6485
|
+
if (!reservation || reservation === "invalid") return null;
|
|
6486
|
+
if (reservation.admitted) return reservation;
|
|
6487
|
+
const next = { ...reservation, admitted: true };
|
|
6488
|
+
const object = await bucket.put(reservation.key, multipartMarkerBody(next), {
|
|
6489
|
+
onlyIf: { etagMatches: reservation.etag },
|
|
6490
|
+
customMetadata: {
|
|
6491
|
+
quotaKind: "multipart",
|
|
6492
|
+
reservedBytes: String(reservation.reservedBytes),
|
|
6493
|
+
active: "false"
|
|
6494
|
+
}
|
|
6495
|
+
});
|
|
6496
|
+
if (object) {
|
|
6497
|
+
return { ...next, etag: object.etag };
|
|
6498
|
+
}
|
|
6499
|
+
}
|
|
6500
|
+
return null;
|
|
6501
|
+
}
|
|
6502
|
+
function quotaExceeded(incomingBytes, summaryBytes, replacedBytes, limitBytes) {
|
|
6503
|
+
const usedBytes = Math.max(0, summaryBytes - replacedBytes);
|
|
6504
|
+
return fail(
|
|
6505
|
+
409,
|
|
6506
|
+
storageQuotaMessage(incomingBytes, usedBytes, limitBytes),
|
|
6507
|
+
"storage_quota_exceeded",
|
|
6508
|
+
{ usedBytes, limitBytes }
|
|
6509
|
+
);
|
|
6510
|
+
}
|
|
6511
|
+
async function reserveQuotaSummary(bucket, storage, prefixes, initial, chargeBytes, incomingBytes, replacedBytes, limitBytes, markerKey) {
|
|
6512
|
+
if (chargeBytes === 0) return null;
|
|
6513
|
+
let current = initial;
|
|
6514
|
+
let repaired = false;
|
|
6515
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
6516
|
+
if (!current.summary || !accountPrefixesEqual(current.summary.prefixes, prefixes)) {
|
|
6517
|
+
const ensured = await ensureQuotaSummary(
|
|
6518
|
+
bucket,
|
|
6519
|
+
storage,
|
|
6520
|
+
prefixes,
|
|
6521
|
+
false,
|
|
6522
|
+
markerKey ?? void 0
|
|
6523
|
+
);
|
|
6524
|
+
if (!ensured) {
|
|
6525
|
+
return quotaUnavailable(
|
|
6526
|
+
"The storage used by this account could not be verified. Retry shortly."
|
|
6527
|
+
);
|
|
6528
|
+
}
|
|
6529
|
+
current = ensured;
|
|
6530
|
+
}
|
|
6531
|
+
const summary = current.summary;
|
|
6532
|
+
if (!summary) {
|
|
6533
|
+
return quotaUnavailable(
|
|
6534
|
+
"The storage used by this account could not be verified. Retry shortly."
|
|
6535
|
+
);
|
|
6536
|
+
}
|
|
6537
|
+
if (summary.usedBytes + chargeBytes > limitBytes) {
|
|
6538
|
+
const recentlyMeasured = Date.now() - summary.measuredAt < QUOTA_REPAIR_COOLDOWN_MS;
|
|
6539
|
+
if (repaired || !summary.dirty && recentlyMeasured) {
|
|
6540
|
+
return quotaExceeded(incomingBytes, summary.usedBytes, replacedBytes, limitBytes);
|
|
6541
|
+
}
|
|
6542
|
+
const refreshed = await ensureQuotaSummary(
|
|
6543
|
+
bucket,
|
|
6544
|
+
storage,
|
|
6545
|
+
prefixes,
|
|
6546
|
+
true,
|
|
6547
|
+
markerKey ?? void 0
|
|
6548
|
+
);
|
|
6549
|
+
if (!refreshed?.summary) {
|
|
6550
|
+
return quotaUnavailable(
|
|
6551
|
+
"The storage used by this account could not be verified. Retry shortly."
|
|
6552
|
+
);
|
|
6553
|
+
}
|
|
6554
|
+
current = refreshed;
|
|
6555
|
+
repaired = true;
|
|
6556
|
+
continue;
|
|
6557
|
+
}
|
|
6558
|
+
const written = await writeQuotaSummary(bucket, storage.quotaKey, current.etag, {
|
|
6559
|
+
...summary,
|
|
6560
|
+
usedBytes: summary.usedBytes + chargeBytes
|
|
6561
|
+
});
|
|
6562
|
+
if (written) return null;
|
|
6563
|
+
current = await readQuotaSummary(bucket, storage.quotaKey);
|
|
6564
|
+
}
|
|
6565
|
+
return quotaUnavailable("Storage is busy with other uploads. Retry shortly.");
|
|
6566
|
+
}
|
|
6567
|
+
async function admitStorage(bucket, storage, incomingBytes, replacedBytes, marker) {
|
|
6568
|
+
if (!storage) return { markerKey: null };
|
|
6569
|
+
let markerKey = null;
|
|
6570
|
+
try {
|
|
6571
|
+
const limit = await storage.limitBytes();
|
|
6572
|
+
if (limit === null) {
|
|
6573
|
+
return quotaUnavailable(
|
|
6574
|
+
"The storage limit for this app could not be verified; nothing was uploaded. Retry shortly."
|
|
6575
|
+
);
|
|
6576
|
+
}
|
|
6577
|
+
const prefixes = await allocationPrefixes(storage);
|
|
6578
|
+
if (prefixes === null) {
|
|
6579
|
+
return quotaUnavailable(
|
|
6580
|
+
"The apps in this storage allocation could not be enumerated; nothing was uploaded. Retry shortly."
|
|
6581
|
+
);
|
|
6582
|
+
}
|
|
6583
|
+
const initial = await ensureQuotaSummary(bucket, storage, prefixes);
|
|
6584
|
+
if (!initial) {
|
|
6585
|
+
return quotaUnavailable(
|
|
6586
|
+
"The storage already used by this account could not be measured; nothing was uploaded. Retry shortly."
|
|
6587
|
+
);
|
|
6588
|
+
}
|
|
6589
|
+
const chargeBytes = Math.max(0, incomingBytes - replacedBytes);
|
|
6590
|
+
const effectiveMarker = marker ?? (chargeBytes > 0 ? {
|
|
6591
|
+
key: `${pendingMarkerPrefix(storage)}${crypto.randomUUID()}`,
|
|
6592
|
+
metadata: { quotaKind: "pending" }
|
|
6593
|
+
} : null);
|
|
6594
|
+
if (effectiveMarker) {
|
|
6595
|
+
const object = await bucket.put(effectiveMarker.key, effectiveMarker.body ?? "", {
|
|
6596
|
+
onlyIf: new Headers({ "If-None-Match": "*" }),
|
|
6597
|
+
customMetadata: {
|
|
6598
|
+
...effectiveMarker.metadata,
|
|
6599
|
+
reservedBytes: String(chargeBytes)
|
|
6600
|
+
}
|
|
6601
|
+
});
|
|
6602
|
+
if (!object) {
|
|
6603
|
+
return fail(
|
|
6604
|
+
409,
|
|
6605
|
+
"An upload with this request id is already in progress.",
|
|
6606
|
+
"reservation_conflict"
|
|
6607
|
+
);
|
|
6608
|
+
}
|
|
6609
|
+
markerKey = effectiveMarker.key;
|
|
6610
|
+
}
|
|
6611
|
+
const refusal = await reserveQuotaSummary(
|
|
6612
|
+
bucket,
|
|
6613
|
+
storage,
|
|
6614
|
+
prefixes,
|
|
6615
|
+
initial,
|
|
6616
|
+
chargeBytes,
|
|
6617
|
+
incomingBytes,
|
|
6618
|
+
replacedBytes,
|
|
6619
|
+
limit,
|
|
6620
|
+
effectiveMarker?.key ?? null
|
|
6621
|
+
);
|
|
6622
|
+
if (refusal) {
|
|
6623
|
+
await releaseQuotaMarker(bucket, storage, effectiveMarker?.key ?? null, false);
|
|
6624
|
+
return refusal;
|
|
6625
|
+
}
|
|
6626
|
+
return { markerKey: effectiveMarker?.key ?? null };
|
|
6627
|
+
} catch (error) {
|
|
6628
|
+
console.error("[files:quota] admission:", error);
|
|
6629
|
+
await releaseQuotaMarker(bucket, storage, markerKey, true);
|
|
6630
|
+
return quotaUnavailable(
|
|
6631
|
+
"The storage used by this account could not be verified. Retry shortly."
|
|
6632
|
+
);
|
|
6633
|
+
}
|
|
6634
|
+
}
|
|
6635
|
+
async function storageUsage(bucket, storage) {
|
|
6636
|
+
try {
|
|
6637
|
+
const prefixes = await allocationPrefixes(storage);
|
|
6638
|
+
if (prefixes === null) return null;
|
|
6639
|
+
const current = await ensureQuotaSummary(bucket, storage, prefixes);
|
|
6640
|
+
return current?.summary?.usedBytes ?? null;
|
|
6641
|
+
} catch (error) {
|
|
6642
|
+
console.error("[files:quota] usage:", error);
|
|
6643
|
+
return null;
|
|
6644
|
+
}
|
|
6645
|
+
}
|
|
6646
|
+
|
|
6647
|
+
// src/server/utils/scoped-r2-files.ts
|
|
6648
|
+
var CORS_HEADERS4 = { "Access-Control-Allow-Origin": "*" };
|
|
6649
|
+
function fail2(status, error, code, extra) {
|
|
6650
|
+
return Response.json(
|
|
6651
|
+
{ error, ...code ? { code } : {}, ...extra },
|
|
6652
|
+
{ status, headers: CORS_HEADERS4 }
|
|
6653
|
+
);
|
|
6654
|
+
}
|
|
6655
|
+
function contentDisposition(originalName, disposition) {
|
|
6127
6656
|
const safe = originalName.replace(/[^\x20-\x7e]|["\\]/g, "_") || "download";
|
|
6128
6657
|
const encoded = encodeURIComponent(originalName).replace(
|
|
6129
6658
|
/[!'()*]/g,
|
|
6130
6659
|
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
|
6131
6660
|
);
|
|
6132
|
-
return
|
|
6661
|
+
return `${disposition}; filename="${safe}"; filename*=UTF-8''${encoded}`;
|
|
6133
6662
|
}
|
|
6134
6663
|
function sanitizeSubpath(raw) {
|
|
6135
6664
|
if (!raw) return "";
|
|
@@ -6139,24 +6668,24 @@ function sanitizeSubpath(raw) {
|
|
|
6139
6668
|
}
|
|
6140
6669
|
function overRequestBound(bytes, slack = 0) {
|
|
6141
6670
|
if (bytes <= MAX_UPLOAD_REQUEST_BYTES + slack) return null;
|
|
6142
|
-
return
|
|
6671
|
+
return fail2(413, oversizeRequestMessage(bytes), "too_large", {
|
|
6143
6672
|
maxBytes: MAX_UPLOAD_REQUEST_BYTES,
|
|
6144
6673
|
partSize: UPLOAD_PART_BYTES
|
|
6145
6674
|
});
|
|
6146
6675
|
}
|
|
6147
6676
|
function overCeiling(bytes) {
|
|
6148
6677
|
if (bytes <= MAX_APP_FILE_BYTES) return null;
|
|
6149
|
-
return
|
|
6678
|
+
return fail2(413, oversizeMessage(bytes), "too_large", { maxBytes: MAX_APP_FILE_BYTES });
|
|
6150
6679
|
}
|
|
6151
6680
|
function overPartBound(bytes) {
|
|
6152
6681
|
if (bytes <= UPLOAD_PART_BYTES) return null;
|
|
6153
|
-
return
|
|
6682
|
+
return fail2(413, oversizePartMessage(bytes), "too_large", { maxBytes: UPLOAD_PART_BYTES });
|
|
6154
6683
|
}
|
|
6155
6684
|
function declaredLength(request, what) {
|
|
6156
6685
|
const raw = request.headers.get("content-length");
|
|
6157
6686
|
const declared = raw === null ? NaN : Number(raw);
|
|
6158
6687
|
if (!Number.isSafeInteger(declared) || declared < 0) {
|
|
6159
|
-
return
|
|
6688
|
+
return fail2(
|
|
6160
6689
|
411,
|
|
6161
6690
|
`${what} must declare a Content-Length. A chunked or length-less body is refused: R2 cannot size a write it has no length for.`,
|
|
6162
6691
|
"length_required"
|
|
@@ -6164,78 +6693,18 @@ function declaredLength(request, what) {
|
|
|
6164
6693
|
}
|
|
6165
6694
|
return declared;
|
|
6166
6695
|
}
|
|
6167
|
-
function
|
|
6168
|
-
return key.startsWith(prefix);
|
|
6696
|
+
function isKeyWithinScope(key, prefix, excludedPrefixes) {
|
|
6697
|
+
return key.startsWith(prefix) && !excludedPrefixes.some((excluded) => key.startsWith(excluded));
|
|
6169
6698
|
}
|
|
6170
|
-
|
|
6171
|
-
|
|
6172
|
-
let cursor;
|
|
6173
|
-
do {
|
|
6174
|
-
const page = await bucket.list({ prefix, cursor, limit: 1e3 });
|
|
6175
|
-
for (const object of page.objects) total += object.size;
|
|
6176
|
-
cursor = page.truncated ? page.cursor : void 0;
|
|
6177
|
-
} while (cursor !== void 0);
|
|
6178
|
-
return total;
|
|
6179
|
-
}
|
|
6180
|
-
async function allocationUsage(bucket, storage) {
|
|
6181
|
-
const siblings = storage.siblingPrefixes ? await storage.siblingPrefixes() : [];
|
|
6182
|
-
if (siblings === null) return null;
|
|
6183
|
-
const prefixes = [storage.prefix, ...siblings.filter((p) => p !== storage.prefix)];
|
|
6184
|
-
const totals = await Promise.all(prefixes.map((prefix) => prefixUsage(bucket, prefix)));
|
|
6185
|
-
return totals.reduce((sum, bytes) => sum + bytes, 0);
|
|
6699
|
+
function scopeRelativeKey(key, prefix) {
|
|
6700
|
+
return key.slice(prefix.length);
|
|
6186
6701
|
}
|
|
6187
|
-
|
|
6188
|
-
if (!storage) return null;
|
|
6189
|
-
const limit = await storage.limitBytes();
|
|
6190
|
-
if (limit === null) {
|
|
6191
|
-
return fail(
|
|
6192
|
-
503,
|
|
6193
|
-
"The storage limit for this app could not be verified; nothing was uploaded. Retry shortly.",
|
|
6194
|
-
"storage_limit_unavailable"
|
|
6195
|
-
);
|
|
6196
|
-
}
|
|
6197
|
-
const stored = await allocationUsage(bucket, storage);
|
|
6198
|
-
if (stored === null) {
|
|
6199
|
-
return fail(
|
|
6200
|
-
503,
|
|
6201
|
-
"The storage already used by this account could not be measured; nothing was uploaded. Retry shortly.",
|
|
6202
|
-
"storage_limit_unavailable"
|
|
6203
|
-
);
|
|
6204
|
-
}
|
|
6205
|
-
const replaced = replacesKey === null ? 0 : (await bucket.head(replacesKey))?.size ?? 0;
|
|
6206
|
-
const used = stored - replaced;
|
|
6207
|
-
if (used + incomingBytes <= limit) return null;
|
|
6208
|
-
return fail(409, storageQuotaMessage(incomingBytes, used, limit), "storage_quota_exceeded", {
|
|
6209
|
-
usedBytes: used,
|
|
6210
|
-
limitBytes: limit
|
|
6211
|
-
});
|
|
6212
|
-
}
|
|
6213
|
-
async function admitAssembledObject(bucket, storage, key, objectBytes) {
|
|
6214
|
-
if (!storage) return null;
|
|
6215
|
-
const limit = await storage.limitBytes();
|
|
6216
|
-
if (limit === null) {
|
|
6217
|
-
console.error("[files:quota] limit unavailable at complete; keeping", key);
|
|
6218
|
-
return null;
|
|
6219
|
-
}
|
|
6220
|
-
const stored = await allocationUsage(bucket, storage);
|
|
6221
|
-
if (stored === null) {
|
|
6222
|
-
console.error("[files:quota] account usage unmeasurable at complete; keeping", key);
|
|
6223
|
-
return null;
|
|
6224
|
-
}
|
|
6225
|
-
if (stored <= limit) return null;
|
|
6226
|
-
await bucket.delete(key).catch(() => void 0);
|
|
6227
|
-
return fail(
|
|
6228
|
-
409,
|
|
6229
|
-
`${storageQuotaMessage(objectBytes, stored - objectBytes, limit)} The assembled upload was not kept.`,
|
|
6230
|
-
"storage_quota_exceeded",
|
|
6231
|
-
{ usedBytes: stored - objectBytes, limitBytes: limit }
|
|
6232
|
-
);
|
|
6233
|
-
}
|
|
6234
|
-
function anchorKey(raw, prefix) {
|
|
6702
|
+
function anchorKey(raw, prefix, excludedPrefixes) {
|
|
6235
6703
|
const safe = sanitizeSubpath(raw);
|
|
6236
|
-
if (safe === null) return
|
|
6237
|
-
if (!safe) return
|
|
6238
|
-
|
|
6704
|
+
if (safe === null) return fail2(400, "Invalid key: traversal not allowed", "invalid_key");
|
|
6705
|
+
if (!safe) return fail2(400, "Invalid key: empty", "invalid_key");
|
|
6706
|
+
const key = `${prefix}${safe}`;
|
|
6707
|
+
return isKeyWithinScope(key, prefix, excludedPrefixes) ? { key } : fail2(403, "Access denied: key belongs to a private scope", "forbidden");
|
|
6239
6708
|
}
|
|
6240
6709
|
var VERB_ROUTES = /* @__PURE__ */ new Set([
|
|
6241
6710
|
"POST upload",
|
|
@@ -6249,7 +6718,7 @@ function isFilesVerbPath(method, subpath) {
|
|
|
6249
6718
|
}
|
|
6250
6719
|
function resolveMimeType(raw) {
|
|
6251
6720
|
const mimeType = raw.split(";")[0].trim().toLowerCase() || "application/octet-stream";
|
|
6252
|
-
return
|
|
6721
|
+
return mimeType;
|
|
6253
6722
|
}
|
|
6254
6723
|
function r2ErrorCode(error) {
|
|
6255
6724
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -6259,7 +6728,7 @@ function r2ErrorCode(error) {
|
|
|
6259
6728
|
function multipartFailure(error) {
|
|
6260
6729
|
switch (r2ErrorCode(error)) {
|
|
6261
6730
|
case 10024:
|
|
6262
|
-
return
|
|
6731
|
+
return fail2(
|
|
6263
6732
|
404,
|
|
6264
6733
|
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
6265
6734
|
"unknown_upload"
|
|
@@ -6269,7 +6738,7 @@ function multipartFailure(error) {
|
|
|
6269
6738
|
case 10025:
|
|
6270
6739
|
// InvalidPart — a named part was never uploaded
|
|
6271
6740
|
case 10048:
|
|
6272
|
-
return
|
|
6741
|
+
return fail2(
|
|
6273
6742
|
400,
|
|
6274
6743
|
"The parts do not assemble: every part except the last must be exactly the size the upload advertised, and each must be uploaded before it is named.",
|
|
6275
6744
|
"bad_parts"
|
|
@@ -6277,11 +6746,15 @@ function multipartFailure(error) {
|
|
|
6277
6746
|
case 10012:
|
|
6278
6747
|
// MetadataTooLarge
|
|
6279
6748
|
case 10020:
|
|
6280
|
-
return
|
|
6749
|
+
return fail2(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
|
|
6281
6750
|
default: {
|
|
6282
6751
|
const message = error instanceof Error ? error.message : String(error);
|
|
6283
6752
|
console.error("[files:multipart] R2 error:", message);
|
|
6284
|
-
return
|
|
6753
|
+
return fail2(
|
|
6754
|
+
503,
|
|
6755
|
+
"The files service could not complete that upload. Retry shortly.",
|
|
6756
|
+
"r2_error"
|
|
6757
|
+
);
|
|
6285
6758
|
}
|
|
6286
6759
|
}
|
|
6287
6760
|
}
|
|
@@ -6294,73 +6767,79 @@ function createScopedR2Handler(config) {
|
|
|
6294
6767
|
} catch {
|
|
6295
6768
|
return Response.json(
|
|
6296
6769
|
{ error: "Invalid path encoding" },
|
|
6297
|
-
{ status: 400, headers:
|
|
6770
|
+
{ status: 400, headers: CORS_HEADERS4 }
|
|
6298
6771
|
);
|
|
6299
6772
|
}
|
|
6300
6773
|
const subpath = sanitizeSubpath(subpathRaw);
|
|
6301
6774
|
if (subpath === null) {
|
|
6302
6775
|
return Response.json(
|
|
6303
6776
|
{ error: "Invalid path: traversal not allowed" },
|
|
6304
|
-
{ status: 400, headers:
|
|
6777
|
+
{ status: 400, headers: CORS_HEADERS4 }
|
|
6305
6778
|
);
|
|
6306
6779
|
}
|
|
6307
6780
|
const scope = url.searchParams.get("scope") || "self";
|
|
6308
6781
|
if (scope !== "self" && scope !== "app") {
|
|
6309
|
-
return Response.json({ error: "Invalid scope" }, { status: 400, headers:
|
|
6782
|
+
return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS4 });
|
|
6310
6783
|
}
|
|
6311
6784
|
const result = config.resolvePrefix(scope, { userId: auth.userId, url });
|
|
6312
6785
|
if (!result.prefix) {
|
|
6313
6786
|
const errorMsg = result.error ?? "Invalid scope";
|
|
6314
6787
|
const status = errorMsg.toLowerCase().includes("auth") ? 401 : 400;
|
|
6315
|
-
return Response.json({ error: errorMsg }, { status, headers:
|
|
6788
|
+
return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS4 });
|
|
6316
6789
|
}
|
|
6317
6790
|
const prefix = result.prefix;
|
|
6791
|
+
const excludedPrefixes = result.excludedPrefixes ?? [];
|
|
6318
6792
|
if (isFilesVerbPath(request.method, subpath)) {
|
|
6319
6793
|
if (requireAuthForMutations && !auth.userId) {
|
|
6320
6794
|
await request.body?.cancel().catch(() => void 0);
|
|
6321
|
-
return
|
|
6795
|
+
return fail2(401, "Authentication required", "unauthorized");
|
|
6322
6796
|
}
|
|
6323
|
-
return subpath === "upload" ? handleUpload(request, url, bucket, prefix, auth, scope) : handleMultipart(request, url, bucket, prefix, auth, scope, subpath);
|
|
6797
|
+
return subpath === "upload" ? handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) : handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath);
|
|
6324
6798
|
}
|
|
6325
6799
|
if (!subpath && request.method === "GET") {
|
|
6326
|
-
return handleList(bucket, prefix, url, scope, auth);
|
|
6800
|
+
return handleList(bucket, prefix, excludedPrefixes, url, scope, auth);
|
|
6327
6801
|
}
|
|
6328
6802
|
if (subpath && request.method === "GET") {
|
|
6329
|
-
if (!
|
|
6803
|
+
if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
|
|
6330
6804
|
return Response.json(
|
|
6331
6805
|
{ error: "Access denied: key outside scope" },
|
|
6332
|
-
{ status: 403, headers:
|
|
6806
|
+
{ status: 403, headers: CORS_HEADERS4 }
|
|
6333
6807
|
);
|
|
6334
6808
|
}
|
|
6335
|
-
return handleDownload(bucket, subpath, scope);
|
|
6809
|
+
return handleDownload(request, bucket, subpath, scope);
|
|
6336
6810
|
}
|
|
6337
6811
|
if (subpath && request.method === "DELETE") {
|
|
6338
6812
|
if (requireAuthForMutations && !auth.userId) {
|
|
6339
6813
|
return Response.json(
|
|
6340
6814
|
{ error: "Authentication required" },
|
|
6341
|
-
{ status: 401, headers:
|
|
6815
|
+
{ status: 401, headers: CORS_HEADERS4 }
|
|
6342
6816
|
);
|
|
6343
6817
|
}
|
|
6344
|
-
if (!
|
|
6818
|
+
if (!isKeyWithinScope(subpath, prefix, excludedPrefixes)) {
|
|
6345
6819
|
return Response.json(
|
|
6346
6820
|
{ error: "Access denied: key outside scope" },
|
|
6347
|
-
{ status: 403, headers:
|
|
6821
|
+
{ status: 403, headers: CORS_HEADERS4 }
|
|
6348
6822
|
);
|
|
6349
6823
|
}
|
|
6350
6824
|
const existed = await bucket.head(subpath) !== null;
|
|
6351
|
-
if (existed)
|
|
6825
|
+
if (existed) {
|
|
6826
|
+
await bucket.delete(subpath);
|
|
6827
|
+
await markQuotaDirty(bucket, auth.storage);
|
|
6828
|
+
}
|
|
6352
6829
|
return Response.json(
|
|
6353
6830
|
{ success: true, deleted: existed ? subpath : null, existed },
|
|
6354
|
-
{ headers:
|
|
6831
|
+
{ headers: CORS_HEADERS4 }
|
|
6355
6832
|
);
|
|
6356
6833
|
}
|
|
6357
|
-
return Response.json({ error: "Method not allowed" }, { status: 405, headers:
|
|
6834
|
+
return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS4 });
|
|
6358
6835
|
};
|
|
6359
6836
|
}
|
|
6360
|
-
async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
6837
|
+
async function handleUpload(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
|
|
6361
6838
|
try {
|
|
6362
|
-
const
|
|
6363
|
-
if (
|
|
6839
|
+
const declaredBytes = declaredLength(request, "File uploads");
|
|
6840
|
+
if (declaredBytes instanceof Response) return declaredBytes;
|
|
6841
|
+
const tooLargeRequest = overRequestBound(declaredBytes, 8 * 1024);
|
|
6842
|
+
if (tooLargeRequest) return tooLargeRequest;
|
|
6364
6843
|
const contentType = request.headers.get("content-type") || "";
|
|
6365
6844
|
let fileData;
|
|
6366
6845
|
let fileName;
|
|
@@ -6368,7 +6847,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
|
6368
6847
|
if (contentType.includes("multipart/form-data")) {
|
|
6369
6848
|
const formData = await request.formData();
|
|
6370
6849
|
const file = formData.get("file");
|
|
6371
|
-
if (!file) return
|
|
6850
|
+
if (!file) return fail2(400, "No file provided", "bad_request");
|
|
6372
6851
|
const tooBig = overRequestBound(file.size);
|
|
6373
6852
|
if (tooBig) return tooBig;
|
|
6374
6853
|
fileData = await file.arrayBuffer();
|
|
@@ -6376,7 +6855,7 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
|
6376
6855
|
rawMimeType = file.type || "application/octet-stream";
|
|
6377
6856
|
} else {
|
|
6378
6857
|
const body = await request.json();
|
|
6379
|
-
if (!body.data || !body.name) return
|
|
6858
|
+
if (!body.data || !body.name) return fail2(400, "Missing data or name", "bad_request");
|
|
6380
6859
|
const base64Data = body.data.replace(/^data:[^;]+;base64,/, "");
|
|
6381
6860
|
const tooBig = overRequestBound(Math.floor(base64Data.length * 3 / 4));
|
|
6382
6861
|
if (tooBig) return tooBig;
|
|
@@ -6384,43 +6863,75 @@ async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
|
6384
6863
|
fileName = body.name;
|
|
6385
6864
|
rawMimeType = body.mimeType || "application/octet-stream";
|
|
6386
6865
|
}
|
|
6387
|
-
const
|
|
6388
|
-
|
|
6389
|
-
const located = locateUpload(url, prefix, fileName);
|
|
6866
|
+
const mimeType = resolveMimeType(rawMimeType);
|
|
6867
|
+
const located = locateUpload(url, prefix, excludedPrefixes, fileName);
|
|
6390
6868
|
if (located instanceof Response) return located;
|
|
6391
6869
|
const replacesKey = url.searchParams.get("key") === null ? null : located.key;
|
|
6392
|
-
const
|
|
6393
|
-
|
|
6394
|
-
await bucket.
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
|
|
6870
|
+
const replaced = replacesKey === null ? null : await bucket.head(replacesKey);
|
|
6871
|
+
const replacedBytes = replaced?.size ?? 0;
|
|
6872
|
+
const admission = await admitStorage(bucket, auth.storage, fileData.byteLength, replacedBytes);
|
|
6873
|
+
if (admission instanceof Response) return admission;
|
|
6874
|
+
try {
|
|
6875
|
+
const written = await bucket.put(located.key, fileData, {
|
|
6876
|
+
// Replacement credit is valid only while the exact object that earned
|
|
6877
|
+
// it still exists. A concurrent delete or overwrite loses this CAS and
|
|
6878
|
+
// retries from a fresh head instead of landing uncounted bytes.
|
|
6879
|
+
...replaced ? { onlyIf: { etagMatches: replaced.etag } } : {},
|
|
6880
|
+
httpMetadata: { contentType: mimeType },
|
|
6881
|
+
customMetadata: fileMetadata(fileName, auth.userId)
|
|
6882
|
+
});
|
|
6883
|
+
if (!written) {
|
|
6884
|
+
await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
|
|
6885
|
+
return fail2(
|
|
6886
|
+
409,
|
|
6887
|
+
"The file changed while this upload was being admitted. Retry the upload.",
|
|
6888
|
+
"write_conflict"
|
|
6889
|
+
);
|
|
6890
|
+
}
|
|
6891
|
+
} catch (error) {
|
|
6892
|
+
await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
|
|
6893
|
+
throw error;
|
|
6894
|
+
}
|
|
6895
|
+
await releaseQuotaMarker(
|
|
6896
|
+
bucket,
|
|
6897
|
+
auth.storage,
|
|
6898
|
+
admission.markerKey,
|
|
6899
|
+
admission.markerKey !== null || fileData.byteLength < replacedBytes
|
|
6900
|
+
);
|
|
6901
|
+
return storedFileJson(located.key, prefix, fileName, url, scope);
|
|
6399
6902
|
} catch (err) {
|
|
6400
6903
|
const msg = err instanceof Error ? err.message : "Upload failed";
|
|
6401
6904
|
console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
|
|
6402
|
-
return
|
|
6905
|
+
return fail2(500, msg, "upload_failed");
|
|
6403
6906
|
}
|
|
6404
6907
|
}
|
|
6405
|
-
function locateUpload(url, prefix, fileName) {
|
|
6908
|
+
function locateUpload(url, prefix, excludedPrefixes, fileName, generatedId) {
|
|
6406
6909
|
const requested = url.searchParams.get("key");
|
|
6407
|
-
const subkey = requested ?? `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}
|
|
6408
|
-
const anchored = anchorKey(subkey, prefix);
|
|
6910
|
+
const subkey = requested ?? (generatedId ? `${generatedId}-${fileName}` : `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`);
|
|
6911
|
+
const anchored = anchorKey(subkey, prefix, excludedPrefixes);
|
|
6409
6912
|
return anchored instanceof Response ? anchored : { key: anchored.key, subkey };
|
|
6410
6913
|
}
|
|
6411
|
-
function fileMetadata(fileName, userId) {
|
|
6914
|
+
function fileMetadata(fileName, userId, reservationId, declaredBytes) {
|
|
6412
6915
|
return {
|
|
6413
6916
|
originalName: fileName,
|
|
6414
6917
|
...userId ? { uploadedBy: userId } : {},
|
|
6918
|
+
...reservationId ? { reservationId } : {},
|
|
6919
|
+
...declaredBytes === void 0 ? {} : { declaredBytes: String(declaredBytes) },
|
|
6415
6920
|
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6416
6921
|
};
|
|
6417
6922
|
}
|
|
6418
|
-
function storedFileJson(key, name, url, scope) {
|
|
6923
|
+
function storedFileJson(key, prefix, name, url, scope) {
|
|
6419
6924
|
const fileUrl = new URL(`/api/files/${encodeKeyPath(key)}`, url.origin);
|
|
6420
6925
|
fileUrl.searchParams.set("scope", scope);
|
|
6421
6926
|
return Response.json(
|
|
6422
|
-
{
|
|
6423
|
-
|
|
6927
|
+
{
|
|
6928
|
+
success: true,
|
|
6929
|
+
key,
|
|
6930
|
+
relativeKey: scopeRelativeKey(key, prefix),
|
|
6931
|
+
url: fileUrl.toString(),
|
|
6932
|
+
name
|
|
6933
|
+
},
|
|
6934
|
+
{ headers: CORS_HEADERS4 }
|
|
6424
6935
|
);
|
|
6425
6936
|
}
|
|
6426
6937
|
var MAX_CONTROL_BODY_BYTES = 64 * 1024;
|
|
@@ -6428,24 +6939,61 @@ async function readControlJson(request) {
|
|
|
6428
6939
|
const declared = declaredLength(request, "This request");
|
|
6429
6940
|
if (declared instanceof Response) return declared;
|
|
6430
6941
|
if (declared > MAX_CONTROL_BODY_BYTES) {
|
|
6431
|
-
return
|
|
6942
|
+
return fail2(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
|
|
6432
6943
|
}
|
|
6433
6944
|
let parsed;
|
|
6434
6945
|
try {
|
|
6435
6946
|
parsed = await request.json();
|
|
6436
6947
|
} catch {
|
|
6437
|
-
return
|
|
6948
|
+
return fail2(400, "Body must be JSON", "bad_request");
|
|
6438
6949
|
}
|
|
6439
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed :
|
|
6950
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail2(400, "Body must be a JSON object", "bad_request");
|
|
6440
6951
|
}
|
|
6441
|
-
|
|
6952
|
+
var REQUEST_ID_RE = /^[A-Za-z0-9_-]{16,80}$/;
|
|
6953
|
+
function resolveSession(url, prefix, excludedPrefixes) {
|
|
6442
6954
|
const uploadKey = url.searchParams.get("uploadKey");
|
|
6443
6955
|
const uploadId = url.searchParams.get("uploadId");
|
|
6444
|
-
|
|
6445
|
-
|
|
6956
|
+
const reservationId = url.searchParams.get("reservationId");
|
|
6957
|
+
if (!uploadKey || !uploadId || !reservationId) {
|
|
6958
|
+
return fail2(400, "uploadKey, uploadId, and reservationId are required", "bad_request");
|
|
6959
|
+
}
|
|
6960
|
+
if (!REQUEST_ID_RE.test(reservationId)) {
|
|
6961
|
+
return fail2(400, "reservationId is invalid", "bad_request");
|
|
6446
6962
|
}
|
|
6447
|
-
const anchored = anchorKey(uploadKey, prefix);
|
|
6448
|
-
return anchored instanceof Response ? anchored : { key: anchored.key, uploadId };
|
|
6963
|
+
const anchored = anchorKey(uploadKey, prefix, excludedPrefixes);
|
|
6964
|
+
return anchored instanceof Response ? anchored : { key: anchored.key, uploadKey, uploadId, reservationId };
|
|
6965
|
+
}
|
|
6966
|
+
function reservationMatches(reservation, session) {
|
|
6967
|
+
return reservation.admitted && reservation.uploadId === session.uploadId && reservation.objectKey === session.key && reservation.uploadKey === session.uploadKey;
|
|
6968
|
+
}
|
|
6969
|
+
function multipartSessionJson(uploadId, uploadKey, reservationId) {
|
|
6970
|
+
return Response.json(
|
|
6971
|
+
{
|
|
6972
|
+
uploadId,
|
|
6973
|
+
uploadKey,
|
|
6974
|
+
reservationId,
|
|
6975
|
+
partSize: UPLOAD_PART_BYTES,
|
|
6976
|
+
maxParts: MAX_UPLOAD_PARTS,
|
|
6977
|
+
maxBytes: MAX_APP_FILE_BYTES
|
|
6978
|
+
},
|
|
6979
|
+
{ headers: CORS_HEADERS4 }
|
|
6980
|
+
);
|
|
6981
|
+
}
|
|
6982
|
+
function multipartPartCount(declaredBytes) {
|
|
6983
|
+
return Math.ceil(declaredBytes / UPLOAD_PART_BYTES);
|
|
6984
|
+
}
|
|
6985
|
+
function expectedMultipartPartBytes(declaredBytes, partNumber) {
|
|
6986
|
+
const partCount = multipartPartCount(declaredBytes);
|
|
6987
|
+
if (partNumber < 1 || partNumber > partCount) return null;
|
|
6988
|
+
return partNumber === partCount ? declaredBytes - (partCount - 1) * UPLOAD_PART_BYTES : UPLOAD_PART_BYTES;
|
|
6989
|
+
}
|
|
6990
|
+
function multipartLayoutError(declaredBytes) {
|
|
6991
|
+
return fail2(
|
|
6992
|
+
400,
|
|
6993
|
+
`This upload must use ${multipartPartCount(declaredBytes)} sequential part(s) at the advertised ${formatBytes(UPLOAD_PART_BYTES)} size, except for the final remainder.`,
|
|
6994
|
+
"bad_part_layout",
|
|
6995
|
+
{ partSize: UPLOAD_PART_BYTES, partCount: multipartPartCount(declaredBytes) }
|
|
6996
|
+
);
|
|
6449
6997
|
}
|
|
6450
6998
|
async function streamPart(upload, partNumber, body, expectedBytes) {
|
|
6451
6999
|
let observedBytes = 0;
|
|
@@ -6468,57 +7016,121 @@ async function streamPart(upload, partNumber, body, expectedBytes) {
|
|
|
6468
7016
|
}
|
|
6469
7017
|
return { ok: true, part: uploaded.value };
|
|
6470
7018
|
}
|
|
6471
|
-
async function handleMultipart(request, url, bucket, prefix, auth, scope, subpath) {
|
|
7019
|
+
async function handleMultipart(request, url, bucket, prefix, excludedPrefixes, auth, scope, subpath) {
|
|
6472
7020
|
if (subpath === "multipart" && request.method === "POST") {
|
|
6473
|
-
return multipartInit(request, url, bucket, prefix, auth);
|
|
7021
|
+
return multipartInit(request, url, bucket, prefix, excludedPrefixes, auth);
|
|
7022
|
+
}
|
|
7023
|
+
if (subpath === "multipart/part") {
|
|
7024
|
+
return multipartPart(request, url, bucket, prefix, excludedPrefixes, auth);
|
|
6474
7025
|
}
|
|
6475
|
-
if (subpath === "multipart/part") return multipartPart(request, url, bucket, prefix);
|
|
6476
7026
|
if (subpath === "multipart/complete") {
|
|
6477
|
-
return multipartComplete(request, url, bucket, prefix, auth, scope);
|
|
7027
|
+
return multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope);
|
|
6478
7028
|
}
|
|
6479
|
-
return multipartAbort(url, bucket, prefix);
|
|
7029
|
+
return multipartAbort(url, bucket, prefix, excludedPrefixes, auth);
|
|
6480
7030
|
}
|
|
6481
|
-
async function multipartInit(request, url, bucket, prefix, auth) {
|
|
7031
|
+
async function multipartInit(request, url, bucket, prefix, excludedPrefixes, auth) {
|
|
6482
7032
|
const body = await readControlJson(request);
|
|
6483
7033
|
if (body instanceof Response) return body;
|
|
6484
7034
|
const size = body.size;
|
|
6485
|
-
if (!Number.isSafeInteger(size) || size
|
|
6486
|
-
return
|
|
7035
|
+
if (!Number.isSafeInteger(size) || size <= 0) {
|
|
7036
|
+
return fail2(400, "size must be a positive integer number of bytes", "bad_request");
|
|
6487
7037
|
}
|
|
6488
7038
|
const tooBig = overCeiling(size);
|
|
6489
7039
|
if (tooBig) return tooBig;
|
|
6490
7040
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
6491
|
-
if (!name) return
|
|
6492
|
-
const
|
|
6493
|
-
if (
|
|
6494
|
-
|
|
7041
|
+
if (!name) return fail2(400, "name is required", "bad_request");
|
|
7042
|
+
const requestId = typeof body.requestId === "string" ? body.requestId : "";
|
|
7043
|
+
if (!REQUEST_ID_RE.test(requestId)) {
|
|
7044
|
+
return fail2(
|
|
7045
|
+
400,
|
|
7046
|
+
"requestId must be 16\u201380 URL-safe characters and must be reused when retrying init.",
|
|
7047
|
+
"bad_request"
|
|
7048
|
+
);
|
|
7049
|
+
}
|
|
7050
|
+
const mimeType = resolveMimeType(typeof body.mimeType === "string" ? body.mimeType : "");
|
|
7051
|
+
const located = locateUpload(url, prefix, excludedPrefixes, name, requestId);
|
|
6495
7052
|
if (located instanceof Response) return located;
|
|
6496
|
-
const
|
|
6497
|
-
|
|
6498
|
-
|
|
7053
|
+
const completed = await bucket.head(located.key);
|
|
7054
|
+
if (completed?.customMetadata?.reservationId === requestId) {
|
|
7055
|
+
return fail2(
|
|
7056
|
+
409,
|
|
7057
|
+
"requestId already completed an upload; start the new upload with a fresh requestId.",
|
|
7058
|
+
"request_id_completed"
|
|
7059
|
+
);
|
|
7060
|
+
}
|
|
7061
|
+
const existing = await readMultipartReservation(bucket, auth.storage, requestId);
|
|
7062
|
+
if (existing === "invalid") {
|
|
7063
|
+
return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
|
|
7064
|
+
}
|
|
7065
|
+
if (existing) {
|
|
7066
|
+
const sameRequest = existing.objectKey === located.key && existing.uploadKey === located.subkey && existing.declaredBytes === size;
|
|
7067
|
+
return sameRequest && existing.admitted ? multipartSessionJson(existing.uploadId, existing.uploadKey, requestId) : sameRequest ? quotaUnavailable("This upload is still being initialized. Retry shortly.") : fail2(
|
|
7068
|
+
409,
|
|
7069
|
+
"requestId already belongs to a different multipart upload.",
|
|
7070
|
+
"request_id_conflict"
|
|
7071
|
+
);
|
|
7072
|
+
}
|
|
6499
7073
|
let upload;
|
|
6500
7074
|
try {
|
|
6501
7075
|
upload = await bucket.createMultipartUpload(located.key, {
|
|
6502
|
-
httpMetadata: { contentType:
|
|
6503
|
-
customMetadata: fileMetadata(name, auth.userId)
|
|
7076
|
+
httpMetadata: { contentType: mimeType },
|
|
7077
|
+
customMetadata: fileMetadata(name, auth.userId, requestId, size)
|
|
6504
7078
|
});
|
|
6505
7079
|
} catch (error) {
|
|
6506
7080
|
return multipartFailure(error);
|
|
6507
7081
|
}
|
|
6508
|
-
|
|
6509
|
-
|
|
7082
|
+
const marker = auth.storage ? {
|
|
7083
|
+
key: multipartMarkerKey(auth.storage, requestId),
|
|
7084
|
+
body: JSON.stringify({
|
|
7085
|
+
version: 1,
|
|
7086
|
+
reservationId: requestId,
|
|
6510
7087
|
uploadId: upload.uploadId,
|
|
6511
|
-
|
|
6512
|
-
// `?key=`, so the mount that anchors keys anchors this one too.
|
|
7088
|
+
objectKey: located.key,
|
|
6513
7089
|
uploadKey: located.subkey,
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
7090
|
+
admitted: false,
|
|
7091
|
+
declaredBytes: size
|
|
7092
|
+
}),
|
|
7093
|
+
metadata: {
|
|
7094
|
+
quotaKind: "multipart",
|
|
7095
|
+
active: "false"
|
|
7096
|
+
}
|
|
7097
|
+
} : void 0;
|
|
7098
|
+
const reservedBytes = Math.max(size, 5 * 1024 * 1024);
|
|
7099
|
+
const admission = await admitStorage(bucket, auth.storage, reservedBytes, 0, marker);
|
|
7100
|
+
if (admission instanceof Response) {
|
|
7101
|
+
await upload.abort().catch(() => void 0);
|
|
7102
|
+
if (admission.status === 409 && auth.storage) {
|
|
7103
|
+
const winner = await readMultipartReservation(bucket, auth.storage, requestId);
|
|
7104
|
+
if (winner && winner !== "invalid" && winner.objectKey === located.key && winner.uploadKey === located.subkey && winner.declaredBytes === size && winner.admitted) {
|
|
7105
|
+
return multipartSessionJson(winner.uploadId, winner.uploadKey, requestId);
|
|
7106
|
+
}
|
|
7107
|
+
}
|
|
7108
|
+
return admission;
|
|
7109
|
+
}
|
|
7110
|
+
if (auth.storage && !await activateMultipartReservation(bucket, auth.storage, requestId)) {
|
|
7111
|
+
await upload.abort().catch(() => void 0);
|
|
7112
|
+
await releaseQuotaMarker(bucket, auth.storage, admission.markerKey, true);
|
|
7113
|
+
return quotaUnavailable("This upload reservation could not be finalized. Retry shortly.");
|
|
7114
|
+
}
|
|
7115
|
+
return multipartSessionJson(upload.uploadId, located.subkey, requestId);
|
|
7116
|
+
}
|
|
7117
|
+
async function abandonMultipart(bucket, session, auth, reservation) {
|
|
7118
|
+
let result = "aborted";
|
|
7119
|
+
try {
|
|
7120
|
+
await bucket.resumeMultipartUpload(session.key, session.uploadId).abort();
|
|
7121
|
+
} catch (error) {
|
|
7122
|
+
if (r2ErrorCode(error) !== 10024) {
|
|
7123
|
+
console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
|
|
7124
|
+
return "failed";
|
|
7125
|
+
}
|
|
7126
|
+
result = "absent";
|
|
7127
|
+
}
|
|
7128
|
+
if (reservation) {
|
|
7129
|
+
if (!await releaseQuotaMarker(bucket, auth.storage, reservation.key, true)) return "failed";
|
|
7130
|
+
}
|
|
7131
|
+
return result;
|
|
6520
7132
|
}
|
|
6521
|
-
async function multipartPart(request, url, bucket, prefix) {
|
|
7133
|
+
async function multipartPart(request, url, bucket, prefix, excludedPrefixes, auth) {
|
|
6522
7134
|
const refuse = async (response) => {
|
|
6523
7135
|
await request.body?.cancel().catch(() => void 0);
|
|
6524
7136
|
return response;
|
|
@@ -6530,16 +7142,60 @@ async function multipartPart(request, url, bucket, prefix) {
|
|
|
6530
7142
|
const partNumber = Number(url.searchParams.get("partNumber"));
|
|
6531
7143
|
if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
|
|
6532
7144
|
return refuse(
|
|
6533
|
-
|
|
7145
|
+
fail2(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
|
|
6534
7146
|
);
|
|
6535
7147
|
}
|
|
6536
|
-
const session = resolveSession(url, prefix);
|
|
7148
|
+
const session = resolveSession(url, prefix, excludedPrefixes);
|
|
6537
7149
|
if (session instanceof Response) return refuse(session);
|
|
6538
|
-
if (!request.body) return
|
|
7150
|
+
if (!request.body) return fail2(400, "A part body is required", "bad_request");
|
|
7151
|
+
const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
|
|
7152
|
+
if (reservation === "invalid") {
|
|
7153
|
+
return refuse(quotaUnavailable("This upload reservation could not be read. Retry shortly."));
|
|
7154
|
+
}
|
|
7155
|
+
if (auth.storage && reservation && !reservationMatches(reservation, session)) {
|
|
7156
|
+
return refuse(
|
|
7157
|
+
fail2(
|
|
7158
|
+
404,
|
|
7159
|
+
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
7160
|
+
"unknown_upload"
|
|
7161
|
+
)
|
|
7162
|
+
);
|
|
7163
|
+
}
|
|
7164
|
+
if (auth.storage && !reservation) {
|
|
7165
|
+
return refuse(
|
|
7166
|
+
fail2(
|
|
7167
|
+
404,
|
|
7168
|
+
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
7169
|
+
"unknown_upload"
|
|
7170
|
+
)
|
|
7171
|
+
);
|
|
7172
|
+
}
|
|
7173
|
+
if (reservation) {
|
|
7174
|
+
const expectedBytes = expectedMultipartPartBytes(reservation.declaredBytes, partNumber);
|
|
7175
|
+
if (expectedBytes === null || declaredBytes !== expectedBytes) {
|
|
7176
|
+
return refuse(multipartLayoutError(reservation.declaredBytes));
|
|
7177
|
+
}
|
|
7178
|
+
}
|
|
7179
|
+
const refreshed = await refreshMultipartReservation(
|
|
7180
|
+
bucket,
|
|
7181
|
+
auth.storage,
|
|
7182
|
+
session.reservationId,
|
|
7183
|
+
session.uploadId,
|
|
7184
|
+
session.key
|
|
7185
|
+
);
|
|
7186
|
+
if (auth.storage && (refreshed === null || refreshed === "invalid")) {
|
|
7187
|
+
return refuse(quotaUnavailable("This upload reservation is missing or invalid."));
|
|
7188
|
+
}
|
|
7189
|
+
if (refreshed === "busy") {
|
|
7190
|
+
return refuse(quotaUnavailable("This upload reservation is busy. Retry the part shortly."));
|
|
7191
|
+
}
|
|
6539
7192
|
const upload = bucket.resumeMultipartUpload(session.key, session.uploadId);
|
|
6540
7193
|
const stored = await streamPart(upload, partNumber, request.body, declaredBytes);
|
|
6541
7194
|
if (!stored.ok) {
|
|
6542
|
-
|
|
7195
|
+
if (r2ErrorCode(stored.error) === 10024 && reservation) {
|
|
7196
|
+
await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
7197
|
+
}
|
|
7198
|
+
return stored.lengthMismatch ? fail2(
|
|
6543
7199
|
400,
|
|
6544
7200
|
`Part ${partNumber} declared ${declaredBytes} bytes but its body did not carry them`,
|
|
6545
7201
|
"bad_request"
|
|
@@ -6547,29 +7203,98 @@ async function multipartPart(request, url, bucket, prefix) {
|
|
|
6547
7203
|
}
|
|
6548
7204
|
return Response.json(
|
|
6549
7205
|
{ partNumber: stored.part.partNumber, etag: stored.part.etag },
|
|
6550
|
-
{ headers:
|
|
7206
|
+
{ headers: CORS_HEADERS4 }
|
|
6551
7207
|
);
|
|
6552
7208
|
}
|
|
6553
|
-
async function
|
|
7209
|
+
async function completedReservationObject(bucket, session) {
|
|
7210
|
+
const object = await bucket.head(session.key);
|
|
7211
|
+
return object?.customMetadata?.reservationId === session.reservationId ? object : null;
|
|
7212
|
+
}
|
|
7213
|
+
async function finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope, prefix) {
|
|
7214
|
+
const declaredMetadata = Number(object.customMetadata?.declaredBytes);
|
|
7215
|
+
const declaredBytes = reservation?.declaredBytes ?? (Number.isSafeInteger(declaredMetadata) && declaredMetadata >= 0 ? declaredMetadata : MAX_APP_FILE_BYTES);
|
|
7216
|
+
const refusal = overCeiling(object.size) ?? (object.size > declaredBytes ? fail2(
|
|
7217
|
+
400,
|
|
7218
|
+
`The assembled upload exceeded its declared ${declaredBytes}-byte size and was not kept.`,
|
|
7219
|
+
"declared_size_exceeded"
|
|
7220
|
+
) : null);
|
|
7221
|
+
if (refusal) {
|
|
7222
|
+
let remaining;
|
|
7223
|
+
try {
|
|
7224
|
+
const current = await bucket.head(session.key);
|
|
7225
|
+
if (current?.etag === object.etag) await bucket.delete(session.key);
|
|
7226
|
+
remaining = await bucket.head(session.key);
|
|
7227
|
+
} catch (error) {
|
|
7228
|
+
console.error("[files:multipart] invalid object cleanup:", error);
|
|
7229
|
+
return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
|
|
7230
|
+
}
|
|
7231
|
+
if (remaining?.etag === object.etag) {
|
|
7232
|
+
return quotaUnavailable("The invalid assembled file could not be removed. Retry completion.");
|
|
7233
|
+
}
|
|
7234
|
+
if (reservation) {
|
|
7235
|
+
const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
7236
|
+
if (!cleaned) return quotaUnavailable("The upload was removed, but cleanup must be retried.");
|
|
7237
|
+
}
|
|
7238
|
+
return refusal;
|
|
7239
|
+
}
|
|
7240
|
+
if (reservation) {
|
|
7241
|
+
const cleaned = await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
7242
|
+
if (!cleaned) {
|
|
7243
|
+
return quotaUnavailable("The file was stored, but its reservation cleanup must be retried.");
|
|
7244
|
+
}
|
|
7245
|
+
}
|
|
7246
|
+
const name = object.customMetadata?.originalName ?? session.uploadKey.split("/").pop() ?? "file";
|
|
7247
|
+
return storedFileJson(session.key, prefix, name, url, scope);
|
|
7248
|
+
}
|
|
7249
|
+
async function multipartComplete(request, url, bucket, prefix, excludedPrefixes, auth, scope) {
|
|
6554
7250
|
const body = await readControlJson(request);
|
|
6555
7251
|
if (body instanceof Response) return body;
|
|
6556
|
-
const session = resolveSession(url, prefix);
|
|
7252
|
+
const session = resolveSession(url, prefix, excludedPrefixes);
|
|
6557
7253
|
if (session instanceof Response) return session;
|
|
7254
|
+
const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
|
|
7255
|
+
if (reservation === "invalid") {
|
|
7256
|
+
return quotaUnavailable("This upload reservation could not be read. Retry shortly.");
|
|
7257
|
+
}
|
|
7258
|
+
if (auth.storage && reservation && !reservationMatches(reservation, session)) {
|
|
7259
|
+
return fail2(
|
|
7260
|
+
404,
|
|
7261
|
+
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
7262
|
+
"unknown_upload"
|
|
7263
|
+
);
|
|
7264
|
+
}
|
|
7265
|
+
if (auth.storage && !reservation) {
|
|
7266
|
+
const completed = await completedReservationObject(bucket, session);
|
|
7267
|
+
if (completed) {
|
|
7268
|
+
return finishCompletedMultipart(bucket, session, null, completed, auth, url, scope, prefix);
|
|
7269
|
+
}
|
|
7270
|
+
await abandonMultipart(bucket, session, auth, null);
|
|
7271
|
+
return quotaUnavailable("This upload reservation is missing or does not match.");
|
|
7272
|
+
}
|
|
6558
7273
|
const raw = body.parts;
|
|
6559
7274
|
if (!Array.isArray(raw) || raw.length === 0) {
|
|
6560
|
-
return
|
|
7275
|
+
return fail2(400, "parts must be a non-empty array", "bad_request");
|
|
6561
7276
|
}
|
|
6562
7277
|
if (raw.length > MAX_UPLOAD_PARTS) {
|
|
6563
|
-
return
|
|
7278
|
+
return fail2(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
|
|
7279
|
+
}
|
|
7280
|
+
if (reservation && raw.length !== multipartPartCount(reservation.declaredBytes)) {
|
|
7281
|
+
return multipartLayoutError(reservation.declaredBytes);
|
|
6564
7282
|
}
|
|
6565
7283
|
const parts = [];
|
|
6566
7284
|
for (let index = 0; index < raw.length; index++) {
|
|
6567
7285
|
const { partNumber, etag } = raw[index] ?? {};
|
|
6568
|
-
if (!Number.isSafeInteger(partNumber) || partNumber < 1) {
|
|
6569
|
-
return
|
|
7286
|
+
if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
|
|
7287
|
+
return fail2(
|
|
7288
|
+
400,
|
|
7289
|
+
`parts[${index}].partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`,
|
|
7290
|
+
"bad_request"
|
|
7291
|
+
);
|
|
7292
|
+
}
|
|
7293
|
+
if (reservation && partNumber !== index + 1) {
|
|
7294
|
+
return multipartLayoutError(reservation.declaredBytes);
|
|
6570
7295
|
}
|
|
6571
7296
|
if (typeof etag !== "string" || !etag) {
|
|
6572
|
-
return
|
|
7297
|
+
return fail2(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
|
|
6573
7298
|
}
|
|
6574
7299
|
parts.push({ partNumber, etag });
|
|
6575
7300
|
}
|
|
@@ -6577,39 +7302,87 @@ async function multipartComplete(request, url, bucket, prefix, auth, scope) {
|
|
|
6577
7302
|
try {
|
|
6578
7303
|
object = await bucket.resumeMultipartUpload(session.key, session.uploadId).complete(parts);
|
|
6579
7304
|
} catch (error) {
|
|
7305
|
+
const completed = await completedReservationObject(bucket, session);
|
|
7306
|
+
if (completed) {
|
|
7307
|
+
return finishCompletedMultipart(
|
|
7308
|
+
bucket,
|
|
7309
|
+
session,
|
|
7310
|
+
reservation,
|
|
7311
|
+
completed,
|
|
7312
|
+
auth,
|
|
7313
|
+
url,
|
|
7314
|
+
scope,
|
|
7315
|
+
prefix
|
|
7316
|
+
);
|
|
7317
|
+
}
|
|
7318
|
+
if (r2ErrorCode(error) === 10024 && reservation) {
|
|
7319
|
+
await releaseQuotaMarker(bucket, auth.storage, reservation.key, true);
|
|
7320
|
+
}
|
|
6580
7321
|
return multipartFailure(error);
|
|
6581
7322
|
}
|
|
6582
|
-
|
|
6583
|
-
if (tooBig) {
|
|
6584
|
-
await bucket.delete(session.key).catch(() => void 0);
|
|
6585
|
-
return tooBig;
|
|
6586
|
-
}
|
|
6587
|
-
const overQuota = await admitAssembledObject(bucket, auth.storage, session.key, object.size);
|
|
6588
|
-
if (overQuota) return overQuota;
|
|
6589
|
-
const name = object.customMetadata?.originalName ?? session.key.split("/").pop() ?? "file";
|
|
6590
|
-
return storedFileJson(session.key, name, url, scope);
|
|
7323
|
+
return finishCompletedMultipart(bucket, session, reservation, object, auth, url, scope, prefix);
|
|
6591
7324
|
}
|
|
6592
|
-
async function multipartAbort(url, bucket, prefix) {
|
|
6593
|
-
const session = resolveSession(url, prefix);
|
|
7325
|
+
async function multipartAbort(url, bucket, prefix, excludedPrefixes, auth) {
|
|
7326
|
+
const session = resolveSession(url, prefix, excludedPrefixes);
|
|
6594
7327
|
if (session instanceof Response) return session;
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
7328
|
+
const reservation = await readMultipartReservation(bucket, auth.storage, session.reservationId);
|
|
7329
|
+
if (reservation === "invalid") {
|
|
7330
|
+
return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS4 });
|
|
7331
|
+
}
|
|
7332
|
+
if (auth.storage && reservation && !reservationMatches(reservation, session)) {
|
|
7333
|
+
return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS4 });
|
|
6600
7334
|
}
|
|
6601
|
-
|
|
7335
|
+
const result = await abandonMultipart(bucket, session, auth, reservation || null);
|
|
7336
|
+
return Response.json({ success: true, aborted: result === "aborted" }, { headers: CORS_HEADERS4 });
|
|
6602
7337
|
}
|
|
6603
|
-
async function handleList(bucket, prefix, url, scope, auth) {
|
|
7338
|
+
async function handleList(bucket, prefix, excludedPrefixes, url, scope, auth) {
|
|
6604
7339
|
const userPrefix = url.searchParams.get("prefix") || "";
|
|
6605
7340
|
const listPrefix = `${prefix}${userPrefix}`;
|
|
6606
|
-
const
|
|
6607
|
-
const
|
|
6608
|
-
const
|
|
7341
|
+
const requestedLimit = Number(url.searchParams.get("limit") ?? 100);
|
|
7342
|
+
const limit = Number.isSafeInteger(requestedLimit) ? Math.min(1e3, Math.max(1, requestedLimit)) : 100;
|
|
7343
|
+
const requestCursor = url.searchParams.get("cursor");
|
|
7344
|
+
if (excludedPrefixes.some((excluded) => listPrefix.startsWith(excluded))) {
|
|
7345
|
+
return Response.json({ files: [], truncated: false }, { headers: CORS_HEADERS4 });
|
|
7346
|
+
}
|
|
7347
|
+
const withMetadata = (options) => ({ ...options, include: ["customMetadata"] });
|
|
7348
|
+
const listed = await bucket.list(
|
|
7349
|
+
withMetadata({
|
|
7350
|
+
prefix: listPrefix,
|
|
7351
|
+
limit,
|
|
7352
|
+
...requestCursor ? { cursor: requestCursor } : {}
|
|
7353
|
+
})
|
|
7354
|
+
);
|
|
7355
|
+
const visible = listed.objects.filter(
|
|
7356
|
+
(object) => isKeyWithinScope(object.key, prefix, excludedPrefixes)
|
|
7357
|
+
);
|
|
7358
|
+
const seen = new Set(visible.map((object) => object.key));
|
|
7359
|
+
let frontier = listed;
|
|
7360
|
+
let frontierKey = listed.objects.at(-1)?.key;
|
|
7361
|
+
for (const excluded of excludedPrefixes.filter((candidate) => candidate.startsWith(listPrefix)).sort()) {
|
|
7362
|
+
if (!frontier.truncated || visible.length >= limit) break;
|
|
7363
|
+
const excludedEnd = `${excluded}\u{10FFFF}`;
|
|
7364
|
+
const after = await bucket.list(
|
|
7365
|
+
withMetadata({
|
|
7366
|
+
prefix: listPrefix,
|
|
7367
|
+
startAfter: frontierKey && frontierKey > excludedEnd ? frontierKey : excludedEnd,
|
|
7368
|
+
limit: limit - visible.length
|
|
7369
|
+
})
|
|
7370
|
+
);
|
|
7371
|
+
for (const object of after.objects) {
|
|
7372
|
+
if (!seen.has(object.key) && isKeyWithinScope(object.key, prefix, excludedPrefixes)) {
|
|
7373
|
+
seen.add(object.key);
|
|
7374
|
+
visible.push(object);
|
|
7375
|
+
}
|
|
7376
|
+
}
|
|
7377
|
+
frontierKey = after.objects.at(-1)?.key ?? frontierKey;
|
|
7378
|
+
frontier = after;
|
|
7379
|
+
}
|
|
7380
|
+
const files = visible.slice(0, limit).map((obj) => {
|
|
6609
7381
|
const fileUrl = new URL(`/api/files/${encodeKeyPath(obj.key)}`, url.origin);
|
|
6610
7382
|
fileUrl.searchParams.set("scope", scope);
|
|
6611
7383
|
return {
|
|
6612
7384
|
key: obj.key,
|
|
7385
|
+
relativeKey: scopeRelativeKey(obj.key, prefix),
|
|
6613
7386
|
size: obj.size,
|
|
6614
7387
|
uploaded: obj.uploaded.toISOString(),
|
|
6615
7388
|
url: fileUrl.toString(),
|
|
@@ -6617,32 +7390,46 @@ async function handleList(bucket, prefix, url, scope, auth) {
|
|
|
6617
7390
|
};
|
|
6618
7391
|
});
|
|
6619
7392
|
let storage;
|
|
6620
|
-
if (auth.storage) {
|
|
6621
|
-
const usedBytes = await
|
|
7393
|
+
if (auth.storage && auth.includeStorageUsage) {
|
|
7394
|
+
const usedBytes = await storageUsage(bucket, auth.storage);
|
|
6622
7395
|
if (usedBytes !== null) storage = { usedBytes };
|
|
6623
7396
|
const limitBytes = await auth.storage.limitBytes();
|
|
6624
7397
|
if (storage && limitBytes !== null) storage.limitBytes = limitBytes;
|
|
6625
7398
|
}
|
|
6626
7399
|
return Response.json(
|
|
6627
|
-
{
|
|
6628
|
-
|
|
7400
|
+
{
|
|
7401
|
+
files,
|
|
7402
|
+
truncated: frontier.truncated,
|
|
7403
|
+
...frontier.truncated ? { cursor: frontier.cursor } : {},
|
|
7404
|
+
...storage ? { storage } : {}
|
|
7405
|
+
},
|
|
7406
|
+
{ headers: CORS_HEADERS4 }
|
|
6629
7407
|
);
|
|
6630
7408
|
}
|
|
6631
|
-
async function handleDownload(bucket, key, scope) {
|
|
7409
|
+
async function handleDownload(request, bucket, key, scope) {
|
|
6632
7410
|
const object = await bucket.get(key);
|
|
6633
7411
|
if (!object) {
|
|
6634
|
-
return Response.json({ error: "File not found" }, { status: 404, headers:
|
|
7412
|
+
return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS4 });
|
|
6635
7413
|
}
|
|
6636
7414
|
const headers = new Headers();
|
|
6637
|
-
|
|
7415
|
+
const storedType = resolveMimeType(object.httpMetadata?.contentType ?? "");
|
|
7416
|
+
const mustDownload = DANGEROUS_MIME_TYPES.has(storedType);
|
|
7417
|
+
headers.set("Content-Type", mustDownload ? "application/octet-stream" : storedType);
|
|
6638
7418
|
headers.set("X-Content-Type-Options", "nosniff");
|
|
7419
|
+
headers.set("ETag", object.httpEtag);
|
|
6639
7420
|
headers.set(
|
|
6640
7421
|
"Cache-Control",
|
|
6641
|
-
scope === "
|
|
7422
|
+
scope === "self" ? "private, no-store" : "public, max-age=0, must-revalidate"
|
|
6642
7423
|
);
|
|
6643
7424
|
headers.set("Access-Control-Allow-Origin", "*");
|
|
6644
|
-
|
|
6645
|
-
|
|
7425
|
+
const originalName = object.customMetadata?.originalName ?? key.split("/").pop() ?? "download";
|
|
7426
|
+
headers.set(
|
|
7427
|
+
"Content-Disposition",
|
|
7428
|
+
contentDisposition(originalName, mustDownload ? "attachment" : "inline")
|
|
7429
|
+
);
|
|
7430
|
+
if (mustDownload) headers.set("Content-Security-Policy", "sandbox; default-src 'none'");
|
|
7431
|
+
if (scope === "app" && request.headers.get("if-none-match") === object.httpEtag) {
|
|
7432
|
+
return new Response(null, { status: 304, headers });
|
|
6646
7433
|
}
|
|
6647
7434
|
return new Response(object.body, { headers });
|
|
6648
7435
|
}
|
|
@@ -7915,7 +8702,8 @@ function searchDocumentationCorpus(corpus, query, limit = 6) {
|
|
|
7915
8702
|
title: chunk.title,
|
|
7916
8703
|
...chunk.heading ? { heading: chunk.heading } : {},
|
|
7917
8704
|
excerpt: excerpt(chunk.text, terms),
|
|
7918
|
-
score
|
|
8705
|
+
score,
|
|
8706
|
+
termCoverage: matchedTerms / terms.length
|
|
7919
8707
|
};
|
|
7920
8708
|
}).filter((result) => result.score > 0).sort((left, right) => right.score - left.score || left.route.localeCompare(right.route)).slice(0, Math.max(1, Math.min(limit, 10)));
|
|
7921
8709
|
}
|
|
@@ -8035,6 +8823,25 @@ function excerpt(text, terms) {
|
|
|
8035
8823
|
return `${start > 0 ? "\u2026" : ""}${text.slice(start, end).trim()}${end < text.length ? "\u2026" : ""}`;
|
|
8036
8824
|
}
|
|
8037
8825
|
|
|
8826
|
+
// src/documentation/text.ts
|
|
8827
|
+
function slugify(value) {
|
|
8828
|
+
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
8829
|
+
}
|
|
8830
|
+
function documentationSubject(name) {
|
|
8831
|
+
const normalized = name.trim();
|
|
8832
|
+
if (!normalized || /^documentation$/i.test(normalized)) return "documentation";
|
|
8833
|
+
return /(?:\bdocs?|documentation)$/i.test(normalized) ? normalized : `${normalized} documentation`;
|
|
8834
|
+
}
|
|
8835
|
+
function createSlugger(fallback = "section") {
|
|
8836
|
+
const seen = /* @__PURE__ */ new Map();
|
|
8837
|
+
return (value) => {
|
|
8838
|
+
const base = slugify(value) || fallback;
|
|
8839
|
+
const count = seen.get(base) ?? 0;
|
|
8840
|
+
seen.set(base, count + 1);
|
|
8841
|
+
return count === 0 ? base : `${base}-${count + 1}`;
|
|
8842
|
+
};
|
|
8843
|
+
}
|
|
8844
|
+
|
|
8038
8845
|
// src/documentation/worker/published-corpus.ts
|
|
8039
8846
|
var cachedCorpus;
|
|
8040
8847
|
async function loadDocumentationPublishedManifest(env, requestUrl = "https://assets.local/", assetBasePath = "/_documentation") {
|
|
@@ -8092,7 +8899,7 @@ async function resolveDocumentationPublishedCorpus(env, sourceHash, requestUrl =
|
|
|
8092
8899
|
}
|
|
8093
8900
|
async function readDocumentationPublishedPage(env, corpus, requestedRoute, requestUrl = "https://assets.local/", assetBasePath = "/_documentation") {
|
|
8094
8901
|
const route = collapseRoute(requestedRoute.split("#", 1)[0] ?? "/");
|
|
8095
|
-
const firstChunk = corpus.find((chunk) => chunk.route === route);
|
|
8902
|
+
const firstChunk = corpus.find((chunk) => (chunk.route.split("#", 1)[0] ?? "") === route);
|
|
8096
8903
|
if (!firstChunk) return null;
|
|
8097
8904
|
const pathname = route === "/" ? `${assetBasePath}/index.md` : `${assetBasePath}${route}.md`;
|
|
8098
8905
|
const response = await env.ASSETS.fetch(new Request(new URL(pathname, requestUrl)));
|
|
@@ -8101,6 +8908,36 @@ async function readDocumentationPublishedPage(env, corpus, requestedRoute, reque
|
|
|
8101
8908
|
if (markdown.length > 5e5) throw new Error("Documentation page exceeds 500000 characters");
|
|
8102
8909
|
return { route, title: firstChunk.title, markdown };
|
|
8103
8910
|
}
|
|
8911
|
+
function documentationPageSection(markdown, fragment) {
|
|
8912
|
+
const target = slugify(fragment);
|
|
8913
|
+
if (!target) return null;
|
|
8914
|
+
const slug = createSlugger();
|
|
8915
|
+
const lines = markdown.split("\n");
|
|
8916
|
+
let fence = null;
|
|
8917
|
+
let start = -1;
|
|
8918
|
+
let startDepth = 0;
|
|
8919
|
+
for (let index = 0; index < lines.length; index++) {
|
|
8920
|
+
const line = lines[index] ?? "";
|
|
8921
|
+
const fenceMarker = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1];
|
|
8922
|
+
if (fenceMarker && !fence) {
|
|
8923
|
+
fence = fenceMarker;
|
|
8924
|
+
continue;
|
|
8925
|
+
}
|
|
8926
|
+
if (fence) {
|
|
8927
|
+
if (fenceMarker?.startsWith(fence)) fence = null;
|
|
8928
|
+
continue;
|
|
8929
|
+
}
|
|
8930
|
+
const heading = line.match(/^ {0,3}(#{1,6})\s+(.+)$/);
|
|
8931
|
+
if (!heading) continue;
|
|
8932
|
+
const depth = (heading[1] ?? "").length;
|
|
8933
|
+
if (start !== -1 && depth <= startDepth) return lines.slice(start, index).join("\n").trim();
|
|
8934
|
+
if (start === -1 && slug(heading[2] ?? "") === target) {
|
|
8935
|
+
start = index;
|
|
8936
|
+
startDepth = depth;
|
|
8937
|
+
}
|
|
8938
|
+
}
|
|
8939
|
+
return start === -1 ? null : lines.slice(start).join("\n").trim();
|
|
8940
|
+
}
|
|
8104
8941
|
function parseAssistantPolicy(value) {
|
|
8105
8942
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
8106
8943
|
const policy = value;
|
|
@@ -8350,6 +9187,17 @@ function registerDocumentationStaticRoutes(app, publicBasePath = "/docs", assetB
|
|
|
8350
9187
|
status = 404;
|
|
8351
9188
|
}
|
|
8352
9189
|
if (status === 404) {
|
|
9190
|
+
if (pathname.endsWith(".md")) {
|
|
9191
|
+
return secureDocumentationResponse(
|
|
9192
|
+
new Response(
|
|
9193
|
+
`No documentation page at ${pathname}. See ${publicBasePath}/llms.txt for every published page.
|
|
9194
|
+
`,
|
|
9195
|
+
{ status: 404, headers: { "Content-Type": "text/plain; charset=utf-8" } }
|
|
9196
|
+
),
|
|
9197
|
+
404,
|
|
9198
|
+
pathname
|
|
9199
|
+
);
|
|
9200
|
+
}
|
|
8353
9201
|
const url = new URL(c.req.url);
|
|
8354
9202
|
url.pathname = `${assetBasePath}/404.html`;
|
|
8355
9203
|
response = await fetchDocumentationAsset(c.env, c.req.raw, url, assetBasePath);
|
|
@@ -8407,13 +9255,6 @@ function secureDocumentationResponse(response, status, pathname) {
|
|
|
8407
9255
|
return new Response(response.body, { status, headers });
|
|
8408
9256
|
}
|
|
8409
9257
|
|
|
8410
|
-
// src/documentation/text.ts
|
|
8411
|
-
function documentationSubject(name) {
|
|
8412
|
-
const normalized = name.trim();
|
|
8413
|
-
if (!normalized || /^documentation$/i.test(normalized)) return "documentation";
|
|
8414
|
-
return /(?:\bdocs?|documentation)$/i.test(normalized) ? normalized : `${normalized} documentation`;
|
|
8415
|
-
}
|
|
8416
|
-
|
|
8417
9258
|
// src/documentation/worker/mcp/protocol.ts
|
|
8418
9259
|
var MCP_SERVER_VERSION = "1.0.0";
|
|
8419
9260
|
var CURRENT_PROTOCOL_VERSION = "2025-11-25";
|
|
@@ -8525,7 +9366,7 @@ var DOCUMENTATION_TOOLS = [
|
|
|
8525
9366
|
},
|
|
8526
9367
|
outputSchema: {
|
|
8527
9368
|
type: "object",
|
|
8528
|
-
properties: { results: { type: "array" } },
|
|
9369
|
+
properties: { notice: { type: "string" }, results: { type: "array" } },
|
|
8529
9370
|
required: ["results"]
|
|
8530
9371
|
},
|
|
8531
9372
|
annotations: {
|
|
@@ -8538,11 +9379,11 @@ var DOCUMENTATION_TOOLS = [
|
|
|
8538
9379
|
{
|
|
8539
9380
|
name: "documentation_read",
|
|
8540
9381
|
title: "Read documentation page",
|
|
8541
|
-
description: "Read one
|
|
9382
|
+
description: "Read one published documentation page as Markdown. The route may omit the leading slash, and a #fragment (as returned by documentation_search) narrows the result to that heading section; an unknown fragment returns the whole page with a notice.",
|
|
8542
9383
|
inputSchema: {
|
|
8543
9384
|
type: "object",
|
|
8544
9385
|
properties: {
|
|
8545
|
-
route: { type: "string", minLength: 1, maxLength: 500
|
|
9386
|
+
route: { type: "string", minLength: 1, maxLength: 500 }
|
|
8546
9387
|
},
|
|
8547
9388
|
required: ["route"],
|
|
8548
9389
|
additionalProperties: false
|
|
@@ -8550,6 +9391,7 @@ var DOCUMENTATION_TOOLS = [
|
|
|
8550
9391
|
outputSchema: {
|
|
8551
9392
|
type: "object",
|
|
8552
9393
|
properties: {
|
|
9394
|
+
notice: { type: "string" },
|
|
8553
9395
|
route: { type: "string" },
|
|
8554
9396
|
title: { type: "string" },
|
|
8555
9397
|
url: { type: "string" },
|
|
@@ -8576,19 +9418,24 @@ async function callDocumentationTool(rawRequest, request, corpus, env, basePath
|
|
|
8576
9418
|
if (query.length < 2 || query.length > 300 || !Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 10) {
|
|
8577
9419
|
return mcpError(request.id, -32602, "Invalid documentation_search arguments");
|
|
8578
9420
|
}
|
|
8579
|
-
const
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
8585
|
-
|
|
9421
|
+
const results = searchDocumentationCorpus(corpus, query, Number(limit)).map((result) => ({
|
|
9422
|
+
...result,
|
|
9423
|
+
url: new URL(documentationPublicPath(basePath, result.route), origin).toString()
|
|
9424
|
+
}));
|
|
9425
|
+
return toolResult(request.id, {
|
|
9426
|
+
...results[0]?.termCoverage === 1 ? {} : {
|
|
9427
|
+
notice: "No section matches every search term \u2014 the topic may be absent or spread across sections."
|
|
9428
|
+
},
|
|
9429
|
+
results
|
|
9430
|
+
});
|
|
8586
9431
|
}
|
|
8587
9432
|
if (name === "documentation_read") {
|
|
8588
9433
|
const route = typeof args.route === "string" ? args.route.trim() : "";
|
|
8589
|
-
if (!route
|
|
9434
|
+
if (!route || route.length > 500) {
|
|
8590
9435
|
return mcpError(request.id, -32602, "Invalid documentation_read arguments");
|
|
8591
9436
|
}
|
|
9437
|
+
const hash = route.indexOf("#");
|
|
9438
|
+
const fragment = hash === -1 ? "" : route.slice(hash + 1);
|
|
8592
9439
|
try {
|
|
8593
9440
|
const page = await readDocumentationPublishedPage(
|
|
8594
9441
|
env,
|
|
@@ -8598,8 +9445,11 @@ async function callDocumentationTool(rawRequest, request, corpus, env, basePath
|
|
|
8598
9445
|
assetBasePath
|
|
8599
9446
|
);
|
|
8600
9447
|
if (!page) return toolError(request.id, `No published documentation page exists at ${route}`);
|
|
9448
|
+
const section = fragment ? documentationPageSection(page.markdown, fragment) : null;
|
|
8601
9449
|
return toolResult(request.id, {
|
|
9450
|
+
...fragment && section === null ? { notice: `The fragment #${fragment} was not found; returning the whole page.` } : {},
|
|
8602
9451
|
...page,
|
|
9452
|
+
...section === null ? {} : { markdown: section },
|
|
8603
9453
|
url: new URL(documentationPublicPath(basePath, page.route), origin).toString()
|
|
8604
9454
|
});
|
|
8605
9455
|
} catch (error) {
|