deepspace 0.12.0 → 0.14.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 +162 -0
- package/dist/{chunk-ZJ43EK3N.js → chunk-233GQU2H.js} +492 -365
- package/dist/chunk-233GQU2H.js.map +1 -0
- package/dist/cli.js +536 -172
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.d.ts +2 -2
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +34 -34
- package/dist/documentation-react.d.ts +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +10 -10
- package/dist/documentation-server-core.d.ts +2 -2
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +2 -2
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +28 -3
- package/dist/index.js +93 -8
- package/dist/index.js.map +1 -1
- package/dist/{public-BGRX_Cjd.d.ts → public-BtArwbVM.d.ts} +8 -0
- package/dist/server.d.ts +143 -27
- package/dist/server.js +427 -91
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +140 -24
- package/dist/worker.js +432 -91
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-ZJ43EK3N.js.map +0 -1
package/dist/server.js
CHANGED
|
@@ -175,8 +175,45 @@ var BUILT_IN_TOOLS = [
|
|
|
175
175
|
];
|
|
176
176
|
|
|
177
177
|
// src/shared/app-files.ts
|
|
178
|
-
var MAX_APP_FILE_BYTES =
|
|
178
|
+
var MAX_APP_FILE_BYTES = 1024 * 1024 * 1024;
|
|
179
|
+
var MAX_UPLOAD_REQUEST_BYTES = 25 * 1024 * 1024;
|
|
180
|
+
var UPLOAD_PART_BYTES = 20 * 1024 * 1024;
|
|
181
|
+
var MAX_UPLOAD_PARTS = Math.ceil(MAX_APP_FILE_BYTES / UPLOAD_PART_BYTES);
|
|
182
|
+
var MAX_BASE64_UPLOAD_BYTES = Math.floor(MAX_UPLOAD_REQUEST_BYTES * 3 / 4);
|
|
183
|
+
var APP_STORAGE_LIMIT_BYTES = {
|
|
184
|
+
test: 0,
|
|
185
|
+
free: 128 * 1024 * 1024,
|
|
186
|
+
starter: 512 * 1024 * 1024,
|
|
187
|
+
premium: 2 * 1024 * 1024 * 1024,
|
|
188
|
+
admin: 10 * 1024 * 1024 * 1024
|
|
189
|
+
};
|
|
190
|
+
function storageLimitForTier(tier) {
|
|
191
|
+
if (tier && Object.hasOwn(APP_STORAGE_LIMIT_BYTES, tier)) {
|
|
192
|
+
return APP_STORAGE_LIMIT_BYTES[tier];
|
|
193
|
+
}
|
|
194
|
+
return APP_STORAGE_LIMIT_BYTES.free;
|
|
195
|
+
}
|
|
196
|
+
function storageQuotaMessage(incomingBytes, usedBytes, limitBytes) {
|
|
197
|
+
const free = Math.max(0, limitBytes - usedBytes);
|
|
198
|
+
return `This upload needs ${formatBytes(incomingBytes)} but the app's file storage has ${formatBytes(free)} free (${formatBytes(usedBytes)} of ${formatBytes(limitBytes)} used). Delete files with \`deepspace app files rm <key>\`, or upgrade the owner's plan.`;
|
|
199
|
+
}
|
|
200
|
+
var MAX_DEPLOY_ASSET_FILE_BYTES = 25 * 1024 * 1024;
|
|
201
|
+
var DANGEROUS_MIME_TYPES = /* @__PURE__ */ new Set([
|
|
202
|
+
"text/html",
|
|
203
|
+
"application/xhtml+xml",
|
|
204
|
+
"image/svg+xml",
|
|
205
|
+
"application/xml",
|
|
206
|
+
"text/xml",
|
|
207
|
+
"application/javascript",
|
|
208
|
+
"text/javascript",
|
|
209
|
+
"application/ecmascript",
|
|
210
|
+
"text/ecmascript"
|
|
211
|
+
]);
|
|
212
|
+
function activeContentMessage(mimeType) {
|
|
213
|
+
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.`;
|
|
214
|
+
}
|
|
179
215
|
function formatBytes(bytes) {
|
|
216
|
+
if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
|
|
180
217
|
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
181
218
|
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KiB`;
|
|
182
219
|
return `${bytes} B`;
|
|
@@ -184,12 +221,24 @@ function formatBytes(bytes) {
|
|
|
184
221
|
function encodeKeyPath(key) {
|
|
185
222
|
return key.split("/").map(encodeURIComponent).join("/");
|
|
186
223
|
}
|
|
187
|
-
function oversizeMessage(bytes) {
|
|
188
|
-
return `That file is ${formatBytes(bytes)}; the limit is ${formatBytes(
|
|
224
|
+
function oversizeMessage(bytes, limit = MAX_APP_FILE_BYTES) {
|
|
225
|
+
return `That file is ${formatBytes(bytes)}; the limit is ${formatBytes(limit)} per file.`;
|
|
226
|
+
}
|
|
227
|
+
function oversizeRequestMessage(bytes) {
|
|
228
|
+
return `This request carries ${formatBytes(bytes)}; one request may carry ${formatBytes(MAX_UPLOAD_REQUEST_BYTES)}. Files above ${formatBytes(UPLOAD_PART_BYTES)} upload in parts.`;
|
|
229
|
+
}
|
|
230
|
+
function oversizePartMessage(bytes) {
|
|
231
|
+
return `This part carries ${formatBytes(bytes)}; a part may carry ${formatBytes(UPLOAD_PART_BYTES)} \u2014 the size the upload advertised.`;
|
|
189
232
|
}
|
|
190
233
|
|
|
191
234
|
// src/server/utils/scoped-r2-files.ts
|
|
192
235
|
var CORS_HEADERS = { "Access-Control-Allow-Origin": "*" };
|
|
236
|
+
function fail(status, error, code, extra) {
|
|
237
|
+
return Response.json(
|
|
238
|
+
{ error, ...code ? { code } : {}, ...extra },
|
|
239
|
+
{ status, headers: CORS_HEADERS }
|
|
240
|
+
);
|
|
241
|
+
}
|
|
193
242
|
function contentDisposition(originalName) {
|
|
194
243
|
const safe = originalName.replace(/[^\x20-\x7e]|["\\]/g, "_") || "download";
|
|
195
244
|
const encoded = encodeURIComponent(originalName).replace(
|
|
@@ -204,29 +253,135 @@ function sanitizeSubpath(raw) {
|
|
|
204
253
|
if (segments.some((s) => s === ".." || s === ".")) return null;
|
|
205
254
|
return raw;
|
|
206
255
|
}
|
|
207
|
-
function
|
|
208
|
-
if (bytes <=
|
|
209
|
-
return
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
);
|
|
256
|
+
function overRequestBound(bytes, slack = 0) {
|
|
257
|
+
if (bytes <= MAX_UPLOAD_REQUEST_BYTES + slack) return null;
|
|
258
|
+
return fail(413, oversizeRequestMessage(bytes), "too_large", {
|
|
259
|
+
maxBytes: MAX_UPLOAD_REQUEST_BYTES,
|
|
260
|
+
partSize: UPLOAD_PART_BYTES
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
function overCeiling(bytes) {
|
|
264
|
+
if (bytes <= MAX_APP_FILE_BYTES) return null;
|
|
265
|
+
return fail(413, oversizeMessage(bytes), "too_large", { maxBytes: MAX_APP_FILE_BYTES });
|
|
266
|
+
}
|
|
267
|
+
function overPartBound(bytes) {
|
|
268
|
+
if (bytes <= UPLOAD_PART_BYTES) return null;
|
|
269
|
+
return fail(413, oversizePartMessage(bytes), "too_large", { maxBytes: UPLOAD_PART_BYTES });
|
|
270
|
+
}
|
|
271
|
+
function declaredLength(request, what) {
|
|
272
|
+
const raw = request.headers.get("content-length");
|
|
273
|
+
const declared = raw === null ? NaN : Number(raw);
|
|
274
|
+
if (!Number.isSafeInteger(declared) || declared < 0) {
|
|
275
|
+
return fail(
|
|
276
|
+
411,
|
|
277
|
+
`${what} must declare a Content-Length. A chunked or length-less body is refused: R2 cannot size a write it has no length for.`,
|
|
278
|
+
"length_required"
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
return declared;
|
|
213
282
|
}
|
|
214
283
|
function isKeyWithinPrefix(key, prefix) {
|
|
215
284
|
return key.startsWith(prefix);
|
|
216
285
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
286
|
+
async function allocationUsage(bucket, prefix) {
|
|
287
|
+
let total = 0;
|
|
288
|
+
let cursor;
|
|
289
|
+
do {
|
|
290
|
+
const page = await bucket.list({ prefix, cursor, limit: 1e3 });
|
|
291
|
+
for (const object of page.objects) total += object.size;
|
|
292
|
+
cursor = page.truncated ? page.cursor : void 0;
|
|
293
|
+
} while (cursor !== void 0);
|
|
294
|
+
return total;
|
|
295
|
+
}
|
|
296
|
+
async function admitStorage(bucket, storage, incomingBytes, replacesKey) {
|
|
297
|
+
if (!storage) return null;
|
|
298
|
+
const limit = await storage.limitBytes();
|
|
299
|
+
if (limit === null) {
|
|
300
|
+
return fail(
|
|
301
|
+
503,
|
|
302
|
+
"The storage limit for this app could not be verified; nothing was uploaded. Retry shortly.",
|
|
303
|
+
"storage_limit_unavailable"
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
const stored = await allocationUsage(bucket, storage.prefix);
|
|
307
|
+
const replaced = replacesKey === null ? 0 : (await bucket.head(replacesKey))?.size ?? 0;
|
|
308
|
+
const used = stored - replaced;
|
|
309
|
+
if (used + incomingBytes <= limit) return null;
|
|
310
|
+
return fail(409, storageQuotaMessage(incomingBytes, used, limit), "storage_quota_exceeded", {
|
|
311
|
+
usedBytes: used,
|
|
312
|
+
limitBytes: limit
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
async function admitAssembledObject(bucket, storage, key, objectBytes) {
|
|
316
|
+
if (!storage) return null;
|
|
317
|
+
const limit = await storage.limitBytes();
|
|
318
|
+
if (limit === null) {
|
|
319
|
+
console.error("[files:quota] limit unavailable at complete; keeping", key);
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
const stored = await allocationUsage(bucket, storage.prefix);
|
|
323
|
+
if (stored <= limit) return null;
|
|
324
|
+
await bucket.delete(key).catch(() => void 0);
|
|
325
|
+
return fail(
|
|
326
|
+
409,
|
|
327
|
+
`${storageQuotaMessage(objectBytes, stored - objectBytes, limit)} The assembled upload was not kept.`,
|
|
328
|
+
"storage_quota_exceeded",
|
|
329
|
+
{ usedBytes: stored - objectBytes, limitBytes: limit }
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
function anchorKey(raw, prefix) {
|
|
333
|
+
const safe = sanitizeSubpath(raw);
|
|
334
|
+
if (safe === null) return fail(400, "Invalid key: traversal not allowed", "invalid_key");
|
|
335
|
+
if (!safe) return fail(400, "Invalid key: empty", "invalid_key");
|
|
336
|
+
return { key: `${prefix}${safe}` };
|
|
337
|
+
}
|
|
338
|
+
var VERB_ROUTES = /* @__PURE__ */ new Set([
|
|
339
|
+
"POST upload",
|
|
340
|
+
"POST multipart",
|
|
341
|
+
"DELETE multipart",
|
|
342
|
+
"PUT multipart/part",
|
|
343
|
+
"POST multipart/complete"
|
|
227
344
|
]);
|
|
228
|
-
function
|
|
229
|
-
return
|
|
345
|
+
function isFilesVerbPath(method, subpath) {
|
|
346
|
+
return VERB_ROUTES.has(`${method} ${subpath}`);
|
|
347
|
+
}
|
|
348
|
+
function resolveMimeType(raw) {
|
|
349
|
+
const mimeType = raw.split(";")[0].trim().toLowerCase() || "application/octet-stream";
|
|
350
|
+
return DANGEROUS_MIME_TYPES.has(mimeType) ? fail(415, activeContentMessage(mimeType), "active_content") : { mimeType };
|
|
351
|
+
}
|
|
352
|
+
function r2ErrorCode(error) {
|
|
353
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
354
|
+
const match = /\((\d{4,6})\)\s*$/.exec(message.trim());
|
|
355
|
+
return match ? Number(match[1]) : null;
|
|
356
|
+
}
|
|
357
|
+
function multipartFailure(error) {
|
|
358
|
+
switch (r2ErrorCode(error)) {
|
|
359
|
+
case 10024:
|
|
360
|
+
return fail(
|
|
361
|
+
404,
|
|
362
|
+
"That upload does not exist \u2014 it was completed, abandoned, or expired. Start a new one.",
|
|
363
|
+
"unknown_upload"
|
|
364
|
+
);
|
|
365
|
+
case 10011:
|
|
366
|
+
// EntityTooSmall
|
|
367
|
+
case 10025:
|
|
368
|
+
// InvalidPart — a named part was never uploaded
|
|
369
|
+
case 10048:
|
|
370
|
+
return fail(
|
|
371
|
+
400,
|
|
372
|
+
"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.",
|
|
373
|
+
"bad_parts"
|
|
374
|
+
);
|
|
375
|
+
case 10012:
|
|
376
|
+
// MetadataTooLarge
|
|
377
|
+
case 10020:
|
|
378
|
+
return fail(400, "The upload names a key or metadata R2 will not accept.", "invalid_key");
|
|
379
|
+
default: {
|
|
380
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
381
|
+
console.error("[files:multipart] R2 error:", message);
|
|
382
|
+
return fail(503, "The files service could not complete that upload. Retry shortly.", "r2_error");
|
|
383
|
+
}
|
|
384
|
+
}
|
|
230
385
|
}
|
|
231
386
|
function createScopedR2Handler(config) {
|
|
232
387
|
const requireAuthForMutations = config.requireAuthForMutations ?? true;
|
|
@@ -258,17 +413,15 @@ function createScopedR2Handler(config) {
|
|
|
258
413
|
return Response.json({ error: errorMsg }, { status, headers: CORS_HEADERS });
|
|
259
414
|
}
|
|
260
415
|
const prefix = result.prefix;
|
|
261
|
-
if (
|
|
416
|
+
if (isFilesVerbPath(request.method, subpath)) {
|
|
262
417
|
if (requireAuthForMutations && !auth.userId) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
{ status: 401, headers: CORS_HEADERS }
|
|
266
|
-
);
|
|
418
|
+
await request.body?.cancel().catch(() => void 0);
|
|
419
|
+
return fail(401, "Authentication required", "unauthorized");
|
|
267
420
|
}
|
|
268
|
-
return handleUpload(request, url, bucket, prefix, auth
|
|
421
|
+
return subpath === "upload" ? handleUpload(request, url, bucket, prefix, auth, scope) : handleMultipart(request, url, bucket, prefix, auth, scope, subpath);
|
|
269
422
|
}
|
|
270
423
|
if (!subpath && request.method === "GET") {
|
|
271
|
-
return handleList(bucket, prefix, url, scope);
|
|
424
|
+
return handleList(bucket, prefix, url, scope, auth);
|
|
272
425
|
}
|
|
273
426
|
if (subpath && request.method === "GET") {
|
|
274
427
|
if (!isKeyWithinPrefix(subpath, prefix)) {
|
|
@@ -292,95 +445,260 @@ function createScopedR2Handler(config) {
|
|
|
292
445
|
{ status: 403, headers: CORS_HEADERS }
|
|
293
446
|
);
|
|
294
447
|
}
|
|
295
|
-
await bucket.
|
|
296
|
-
|
|
448
|
+
const existed = await bucket.head(subpath) !== null;
|
|
449
|
+
if (existed) await bucket.delete(subpath);
|
|
450
|
+
return Response.json(
|
|
451
|
+
{ success: true, deleted: existed ? subpath : null, existed },
|
|
452
|
+
{ headers: CORS_HEADERS }
|
|
453
|
+
);
|
|
297
454
|
}
|
|
298
455
|
return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS });
|
|
299
456
|
};
|
|
300
457
|
}
|
|
301
|
-
async function handleUpload(request, url, bucket, prefix,
|
|
458
|
+
async function handleUpload(request, url, bucket, prefix, auth, scope) {
|
|
302
459
|
try {
|
|
303
|
-
const declared =
|
|
460
|
+
const declared = overRequestBound(Number(request.headers.get("content-length")), 8 * 1024);
|
|
304
461
|
if (declared) return declared;
|
|
305
462
|
const contentType = request.headers.get("content-type") || "";
|
|
306
463
|
let fileData;
|
|
307
464
|
let fileName;
|
|
308
|
-
let
|
|
465
|
+
let rawMimeType;
|
|
309
466
|
if (contentType.includes("multipart/form-data")) {
|
|
310
467
|
const formData = await request.formData();
|
|
311
468
|
const file = formData.get("file");
|
|
312
|
-
if (!file)
|
|
313
|
-
|
|
314
|
-
}
|
|
315
|
-
const tooBig = oversize(file.size);
|
|
469
|
+
if (!file) return fail(400, "No file provided", "bad_request");
|
|
470
|
+
const tooBig = overRequestBound(file.size);
|
|
316
471
|
if (tooBig) return tooBig;
|
|
317
472
|
fileData = await file.arrayBuffer();
|
|
318
473
|
fileName = formData.get("name")?.toString() || file.name;
|
|
319
|
-
|
|
474
|
+
rawMimeType = file.type || "application/octet-stream";
|
|
320
475
|
} else {
|
|
321
476
|
const body = await request.json();
|
|
322
|
-
if (!body.data || !body.name)
|
|
323
|
-
return Response.json(
|
|
324
|
-
{ error: "Missing data or name" },
|
|
325
|
-
{ status: 400, headers: CORS_HEADERS }
|
|
326
|
-
);
|
|
327
|
-
}
|
|
477
|
+
if (!body.data || !body.name) return fail(400, "Missing data or name", "bad_request");
|
|
328
478
|
const base64Data = body.data.replace(/^data:[^;]+;base64,/, "");
|
|
329
|
-
const tooBig =
|
|
479
|
+
const tooBig = overRequestBound(Math.floor(base64Data.length * 3 / 4));
|
|
330
480
|
if (tooBig) return tooBig;
|
|
331
481
|
fileData = Uint8Array.from(atob(base64Data), (c) => c.charCodeAt(0)).buffer;
|
|
332
482
|
fileName = body.name;
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const safe = sanitizeSubpath(requestedKey);
|
|
346
|
-
if (safe === null) {
|
|
347
|
-
return Response.json(
|
|
348
|
-
{ error: "Invalid key: traversal not allowed" },
|
|
349
|
-
{ status: 400, headers: CORS_HEADERS }
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
if (!safe) {
|
|
353
|
-
return Response.json(
|
|
354
|
-
{ error: "Invalid key: empty" },
|
|
355
|
-
{ status: 400, headers: CORS_HEADERS }
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
key = `${prefix}${safe}`;
|
|
359
|
-
} else {
|
|
360
|
-
const fileKey = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`;
|
|
361
|
-
key = `${prefix}${fileKey}`;
|
|
362
|
-
}
|
|
363
|
-
await bucket.put(key, fileData, {
|
|
364
|
-
httpMetadata: { contentType: mimeType },
|
|
365
|
-
customMetadata: {
|
|
366
|
-
originalName: fileName,
|
|
367
|
-
...userId ? { uploadedBy: userId } : {},
|
|
368
|
-
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
369
|
-
}
|
|
483
|
+
rawMimeType = body.mimeType || "application/octet-stream";
|
|
484
|
+
}
|
|
485
|
+
const mime = resolveMimeType(rawMimeType);
|
|
486
|
+
if (mime instanceof Response) return mime;
|
|
487
|
+
const located = locateUpload(url, prefix, fileName);
|
|
488
|
+
if (located instanceof Response) return located;
|
|
489
|
+
const replacesKey = url.searchParams.get("key") === null ? null : located.key;
|
|
490
|
+
const overQuota = await admitStorage(bucket, auth.storage, fileData.byteLength, replacesKey);
|
|
491
|
+
if (overQuota) return overQuota;
|
|
492
|
+
await bucket.put(located.key, fileData, {
|
|
493
|
+
httpMetadata: { contentType: mime.mimeType },
|
|
494
|
+
customMetadata: fileMetadata(fileName, auth.userId)
|
|
370
495
|
});
|
|
371
|
-
|
|
372
|
-
fileUrl.searchParams.set("scope", scope);
|
|
373
|
-
return Response.json(
|
|
374
|
-
{ success: true, key, url: fileUrl.toString(), name: fileName },
|
|
375
|
-
{ headers: CORS_HEADERS }
|
|
376
|
-
);
|
|
496
|
+
return storedFileJson(located.key, fileName, url, scope);
|
|
377
497
|
} catch (err) {
|
|
378
498
|
const msg = err instanceof Error ? err.message : "Upload failed";
|
|
379
499
|
console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
|
|
380
|
-
return
|
|
500
|
+
return fail(500, msg, "upload_failed");
|
|
381
501
|
}
|
|
382
502
|
}
|
|
383
|
-
|
|
503
|
+
function locateUpload(url, prefix, fileName) {
|
|
504
|
+
const requested = url.searchParams.get("key");
|
|
505
|
+
const subkey = requested ?? `${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${fileName}`;
|
|
506
|
+
const anchored = anchorKey(subkey, prefix);
|
|
507
|
+
return anchored instanceof Response ? anchored : { key: anchored.key, subkey };
|
|
508
|
+
}
|
|
509
|
+
function fileMetadata(fileName, userId) {
|
|
510
|
+
return {
|
|
511
|
+
originalName: fileName,
|
|
512
|
+
...userId ? { uploadedBy: userId } : {},
|
|
513
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function storedFileJson(key, name, url, scope) {
|
|
517
|
+
const fileUrl = new URL(`/api/files/${encodeKeyPath(key)}`, url.origin);
|
|
518
|
+
fileUrl.searchParams.set("scope", scope);
|
|
519
|
+
return Response.json(
|
|
520
|
+
{ success: true, key, url: fileUrl.toString(), name },
|
|
521
|
+
{ headers: CORS_HEADERS }
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
var MAX_CONTROL_BODY_BYTES = 64 * 1024;
|
|
525
|
+
async function readControlJson(request) {
|
|
526
|
+
const declared = declaredLength(request, "This request");
|
|
527
|
+
if (declared instanceof Response) return declared;
|
|
528
|
+
if (declared > MAX_CONTROL_BODY_BYTES) {
|
|
529
|
+
return fail(413, `Request body exceeds ${MAX_CONTROL_BODY_BYTES} bytes`, "too_large");
|
|
530
|
+
}
|
|
531
|
+
let parsed;
|
|
532
|
+
try {
|
|
533
|
+
parsed = await request.json();
|
|
534
|
+
} catch {
|
|
535
|
+
return fail(400, "Body must be JSON", "bad_request");
|
|
536
|
+
}
|
|
537
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fail(400, "Body must be a JSON object", "bad_request");
|
|
538
|
+
}
|
|
539
|
+
function resolveSession(url, prefix) {
|
|
540
|
+
const uploadKey = url.searchParams.get("uploadKey");
|
|
541
|
+
const uploadId = url.searchParams.get("uploadId");
|
|
542
|
+
if (!uploadKey || !uploadId) {
|
|
543
|
+
return fail(400, "uploadKey and uploadId are required", "bad_request");
|
|
544
|
+
}
|
|
545
|
+
const anchored = anchorKey(uploadKey, prefix);
|
|
546
|
+
return anchored instanceof Response ? anchored : { key: anchored.key, uploadId };
|
|
547
|
+
}
|
|
548
|
+
async function streamPart(upload, partNumber, body, expectedBytes) {
|
|
549
|
+
let observedBytes = 0;
|
|
550
|
+
const sized = new FixedLengthStream(expectedBytes);
|
|
551
|
+
const pumped = body.pipeThrough(
|
|
552
|
+
new TransformStream({
|
|
553
|
+
transform(chunk, controller) {
|
|
554
|
+
observedBytes += chunk.byteLength;
|
|
555
|
+
controller.enqueue(chunk);
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
).pipeTo(sized.writable);
|
|
559
|
+
const [uploaded, drained] = await Promise.allSettled([
|
|
560
|
+
upload.uploadPart(partNumber, sized.readable),
|
|
561
|
+
pumped
|
|
562
|
+
]);
|
|
563
|
+
if (uploaded.status === "rejected" || drained.status === "rejected") {
|
|
564
|
+
const reason = uploaded.status === "rejected" ? uploaded.reason : drained.reason;
|
|
565
|
+
return { ok: false, lengthMismatch: observedBytes !== expectedBytes, error: reason };
|
|
566
|
+
}
|
|
567
|
+
return { ok: true, part: uploaded.value };
|
|
568
|
+
}
|
|
569
|
+
async function handleMultipart(request, url, bucket, prefix, auth, scope, subpath) {
|
|
570
|
+
if (subpath === "multipart" && request.method === "POST") {
|
|
571
|
+
return multipartInit(request, url, bucket, prefix, auth);
|
|
572
|
+
}
|
|
573
|
+
if (subpath === "multipart/part") return multipartPart(request, url, bucket, prefix);
|
|
574
|
+
if (subpath === "multipart/complete") {
|
|
575
|
+
return multipartComplete(request, url, bucket, prefix, auth, scope);
|
|
576
|
+
}
|
|
577
|
+
return multipartAbort(url, bucket, prefix);
|
|
578
|
+
}
|
|
579
|
+
async function multipartInit(request, url, bucket, prefix, auth) {
|
|
580
|
+
const body = await readControlJson(request);
|
|
581
|
+
if (body instanceof Response) return body;
|
|
582
|
+
const size = body.size;
|
|
583
|
+
if (!Number.isSafeInteger(size) || size < 0) {
|
|
584
|
+
return fail(400, "size must be a non-negative integer number of bytes", "bad_request");
|
|
585
|
+
}
|
|
586
|
+
const tooBig = overCeiling(size);
|
|
587
|
+
if (tooBig) return tooBig;
|
|
588
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
589
|
+
if (!name) return fail(400, "name is required", "bad_request");
|
|
590
|
+
const mime = resolveMimeType(typeof body.mimeType === "string" ? body.mimeType : "");
|
|
591
|
+
if (mime instanceof Response) return mime;
|
|
592
|
+
const located = locateUpload(url, prefix, name);
|
|
593
|
+
if (located instanceof Response) return located;
|
|
594
|
+
const replacesKey = url.searchParams.get("key") === null ? null : located.key;
|
|
595
|
+
const overQuota = await admitStorage(bucket, auth.storage, size, replacesKey);
|
|
596
|
+
if (overQuota) return overQuota;
|
|
597
|
+
let upload;
|
|
598
|
+
try {
|
|
599
|
+
upload = await bucket.createMultipartUpload(located.key, {
|
|
600
|
+
httpMetadata: { contentType: mime.mimeType },
|
|
601
|
+
customMetadata: fileMetadata(name, auth.userId)
|
|
602
|
+
});
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return multipartFailure(error);
|
|
605
|
+
}
|
|
606
|
+
return Response.json(
|
|
607
|
+
{
|
|
608
|
+
uploadId: upload.uploadId,
|
|
609
|
+
// Echoed back on every later call. Relative to the app, exactly like
|
|
610
|
+
// `?key=`, so the mount that anchors keys anchors this one too.
|
|
611
|
+
uploadKey: located.subkey,
|
|
612
|
+
partSize: UPLOAD_PART_BYTES,
|
|
613
|
+
maxParts: MAX_UPLOAD_PARTS,
|
|
614
|
+
maxBytes: MAX_APP_FILE_BYTES
|
|
615
|
+
},
|
|
616
|
+
{ headers: CORS_HEADERS }
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
async function multipartPart(request, url, bucket, prefix) {
|
|
620
|
+
const refuse = async (response) => {
|
|
621
|
+
await request.body?.cancel().catch(() => void 0);
|
|
622
|
+
return response;
|
|
623
|
+
};
|
|
624
|
+
const declaredBytes = declaredLength(request, "Part uploads");
|
|
625
|
+
if (declaredBytes instanceof Response) return refuse(declaredBytes);
|
|
626
|
+
const tooBig = overPartBound(declaredBytes);
|
|
627
|
+
if (tooBig) return refuse(tooBig);
|
|
628
|
+
const partNumber = Number(url.searchParams.get("partNumber"));
|
|
629
|
+
if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > MAX_UPLOAD_PARTS) {
|
|
630
|
+
return refuse(
|
|
631
|
+
fail(400, `partNumber must be a whole number from 1 to ${MAX_UPLOAD_PARTS}`, "bad_request")
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
const session = resolveSession(url, prefix);
|
|
635
|
+
if (session instanceof Response) return refuse(session);
|
|
636
|
+
if (!request.body) return fail(400, "A part body is required", "bad_request");
|
|
637
|
+
const upload = bucket.resumeMultipartUpload(session.key, session.uploadId);
|
|
638
|
+
const stored = await streamPart(upload, partNumber, request.body, declaredBytes);
|
|
639
|
+
if (!stored.ok) {
|
|
640
|
+
return stored.lengthMismatch ? fail(
|
|
641
|
+
400,
|
|
642
|
+
`Part ${partNumber} declared ${declaredBytes} bytes but its body did not carry them`,
|
|
643
|
+
"bad_request"
|
|
644
|
+
) : multipartFailure(stored.error);
|
|
645
|
+
}
|
|
646
|
+
return Response.json(
|
|
647
|
+
{ partNumber: stored.part.partNumber, etag: stored.part.etag },
|
|
648
|
+
{ headers: CORS_HEADERS }
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
async function multipartComplete(request, url, bucket, prefix, auth, scope) {
|
|
652
|
+
const body = await readControlJson(request);
|
|
653
|
+
if (body instanceof Response) return body;
|
|
654
|
+
const session = resolveSession(url, prefix);
|
|
655
|
+
if (session instanceof Response) return session;
|
|
656
|
+
const raw = body.parts;
|
|
657
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
658
|
+
return fail(400, "parts must be a non-empty array", "bad_request");
|
|
659
|
+
}
|
|
660
|
+
if (raw.length > MAX_UPLOAD_PARTS) {
|
|
661
|
+
return fail(400, `An upload may have at most ${MAX_UPLOAD_PARTS} parts`, "bad_request");
|
|
662
|
+
}
|
|
663
|
+
const parts = [];
|
|
664
|
+
for (let index = 0; index < raw.length; index++) {
|
|
665
|
+
const { partNumber, etag } = raw[index] ?? {};
|
|
666
|
+
if (!Number.isSafeInteger(partNumber) || partNumber < 1) {
|
|
667
|
+
return fail(400, `parts[${index}].partNumber must be a positive integer`, "bad_request");
|
|
668
|
+
}
|
|
669
|
+
if (typeof etag !== "string" || !etag) {
|
|
670
|
+
return fail(400, `parts[${index}].etag must be the string uploadPart returned`, "bad_request");
|
|
671
|
+
}
|
|
672
|
+
parts.push({ partNumber, etag });
|
|
673
|
+
}
|
|
674
|
+
let object;
|
|
675
|
+
try {
|
|
676
|
+
object = await bucket.resumeMultipartUpload(session.key, session.uploadId).complete(parts);
|
|
677
|
+
} catch (error) {
|
|
678
|
+
return multipartFailure(error);
|
|
679
|
+
}
|
|
680
|
+
const tooBig = overCeiling(object.size);
|
|
681
|
+
if (tooBig) {
|
|
682
|
+
await bucket.delete(session.key).catch(() => void 0);
|
|
683
|
+
return tooBig;
|
|
684
|
+
}
|
|
685
|
+
const overQuota = await admitAssembledObject(bucket, auth.storage, session.key, object.size);
|
|
686
|
+
if (overQuota) return overQuota;
|
|
687
|
+
const name = object.customMetadata?.originalName ?? session.key.split("/").pop() ?? "file";
|
|
688
|
+
return storedFileJson(session.key, name, url, scope);
|
|
689
|
+
}
|
|
690
|
+
async function multipartAbort(url, bucket, prefix) {
|
|
691
|
+
const session = resolveSession(url, prefix);
|
|
692
|
+
if (session instanceof Response) return session;
|
|
693
|
+
try {
|
|
694
|
+
await bucket.resumeMultipartUpload(session.key, session.uploadId).abort();
|
|
695
|
+
} catch (error) {
|
|
696
|
+
console.error("[files:multipart] abort:", error instanceof Error ? error.message : error);
|
|
697
|
+
return Response.json({ success: true, aborted: false }, { headers: CORS_HEADERS });
|
|
698
|
+
}
|
|
699
|
+
return Response.json({ success: true, aborted: true }, { headers: CORS_HEADERS });
|
|
700
|
+
}
|
|
701
|
+
async function handleList(bucket, prefix, url, scope, auth) {
|
|
384
702
|
const userPrefix = url.searchParams.get("prefix") || "";
|
|
385
703
|
const listPrefix = `${prefix}${userPrefix}`;
|
|
386
704
|
const limit = parseInt(url.searchParams.get("limit") || "100", 10);
|
|
@@ -396,7 +714,16 @@ async function handleList(bucket, prefix, url, scope) {
|
|
|
396
714
|
...obj.customMetadata
|
|
397
715
|
};
|
|
398
716
|
});
|
|
399
|
-
|
|
717
|
+
let storage;
|
|
718
|
+
if (auth.storage) {
|
|
719
|
+
storage = { usedBytes: await allocationUsage(bucket, auth.storage.prefix) };
|
|
720
|
+
const limitBytes = await auth.storage.limitBytes();
|
|
721
|
+
if (limitBytes !== null) storage.limitBytes = limitBytes;
|
|
722
|
+
}
|
|
723
|
+
return Response.json(
|
|
724
|
+
{ files, truncated: listed.truncated, ...storage ? { storage } : {} },
|
|
725
|
+
{ headers: CORS_HEADERS }
|
|
726
|
+
);
|
|
400
727
|
}
|
|
401
728
|
async function handleDownload(bucket, key, scope) {
|
|
402
729
|
const object = await bucket.get(key);
|
|
@@ -7662,6 +7989,7 @@ export {
|
|
|
7662
7989
|
ALLOWED_BINDING_TYPES,
|
|
7663
7990
|
APP_ID_RE,
|
|
7664
7991
|
APP_NAME_RULES,
|
|
7992
|
+
APP_STORAGE_LIMIT_BYTES,
|
|
7665
7993
|
AUTO_PROVISIONABLE_TYPES,
|
|
7666
7994
|
AUTO_PROVISION_SENTINEL,
|
|
7667
7995
|
BASE_USERS_SCHEMA,
|
|
@@ -7687,6 +8015,9 @@ export {
|
|
|
7687
8015
|
GameRoom,
|
|
7688
8016
|
JobRoom,
|
|
7689
8017
|
MAX_APP_FILE_BYTES,
|
|
8018
|
+
MAX_DEPLOY_ASSET_FILE_BYTES,
|
|
8019
|
+
MAX_UPLOAD_PARTS,
|
|
8020
|
+
MAX_UPLOAD_REQUEST_BYTES,
|
|
7690
8021
|
MESSAGES_SCHEMA,
|
|
7691
8022
|
PresenceRoom,
|
|
7692
8023
|
REACTIONS_SCHEMA,
|
|
@@ -7705,6 +8036,7 @@ export {
|
|
|
7705
8036
|
SubscriptionAuthError,
|
|
7706
8037
|
SubscriptionRequiredError,
|
|
7707
8038
|
TEST_ACCOUNT_EMAIL_SUFFIX,
|
|
8039
|
+
UPLOAD_PART_BYTES,
|
|
7708
8040
|
USERS_COLUMNS,
|
|
7709
8041
|
VOTING_SCHEMAS,
|
|
7710
8042
|
WORKSPACE_SCHEMAS,
|
|
@@ -7747,6 +8079,7 @@ export {
|
|
|
7747
8079
|
deleteRecord,
|
|
7748
8080
|
enqueueJob,
|
|
7749
8081
|
executeQuery,
|
|
8082
|
+
formatBytes,
|
|
7750
8083
|
getAllUsers,
|
|
7751
8084
|
getChat,
|
|
7752
8085
|
getGlobalDOSchemas,
|
|
@@ -7770,6 +8103,7 @@ export {
|
|
|
7770
8103
|
handleYjsJoin,
|
|
7771
8104
|
handleYjsLeave,
|
|
7772
8105
|
isAutoProvision,
|
|
8106
|
+
isFilesVerbPath,
|
|
7773
8107
|
isOwner,
|
|
7774
8108
|
isTestAccountClaims,
|
|
7775
8109
|
isTestAccountEmail,
|
|
@@ -7800,6 +8134,8 @@ export {
|
|
|
7800
8134
|
rowToData,
|
|
7801
8135
|
runMigrations,
|
|
7802
8136
|
saveYjsDoc,
|
|
8137
|
+
storageLimitForTier,
|
|
8138
|
+
storageQuotaMessage,
|
|
7803
8139
|
streamDeepSpaceAgent,
|
|
7804
8140
|
timingSafeEqualHex,
|
|
7805
8141
|
timingSafeEqualStrings,
|