@edge-base/server 0.2.9 → 0.3.1
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/admin-build/_app/immutable/chunks/{BfhqI0W8.js → BSIzoJ5I.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CI1lKBjB.js → BeYswbvA.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DncT8xH5.js → BkrC_6Ss.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DBniE7GN.js → BunyH6GE.js} +1 -1
- package/admin-build/_app/immutable/chunks/{C5nMidnK.js → C-zXt-cq.js} +1 -1
- package/admin-build/_app/immutable/chunks/{CPLcjNR9.js → C1Mycuvd.js} +1 -1
- package/admin-build/_app/immutable/chunks/{0M5txo3l.js → Cek51lkE.js} +3 -3
- package/admin-build/_app/immutable/chunks/{_xa30BOD.js → D0mNYcxN.js} +1 -1
- package/admin-build/_app/immutable/chunks/D3hdqsfp.js +1 -0
- package/admin-build/_app/immutable/chunks/{39YMyjjq.js → DF40xjpj.js} +1 -1
- package/admin-build/_app/immutable/chunks/{YGUb3X-Q.js → DOOhHO-v.js} +1 -1
- package/admin-build/_app/immutable/chunks/DdjFeYwG.js +1 -0
- package/admin-build/_app/immutable/chunks/{DllVADtt.js → Dvlxm2Iq.js} +1 -1
- package/admin-build/_app/immutable/chunks/{BN-pHKcH.js → Xm1dHMTE.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DUhE3Ynq.js → aPulNooa.js} +1 -1
- package/admin-build/_app/immutable/chunks/{DBkor_jM.js → dZsKGnWZ.js} +1 -1
- package/admin-build/_app/immutable/entry/{app.BW-Ac3jY.js → app.j1WHBlZX.js} +2 -2
- package/admin-build/_app/immutable/entry/start.BgMeGq-q.js +1 -0
- package/admin-build/_app/immutable/nodes/{0.CRSQPtos.js → 0.Oo7ZpbR-.js} +1 -1
- package/admin-build/_app/immutable/nodes/{1.HmStNbZP.js → 1.zgMnKHns.js} +1 -1
- package/admin-build/_app/immutable/nodes/{10.BVlz13nr.js → 10.u5mkRiUe.js} +1 -1
- package/admin-build/_app/immutable/nodes/{11.VwQdbWwH.js → 11.9ynAgaxV.js} +1 -1
- package/admin-build/_app/immutable/nodes/{12.BFAAgi1f.js → 12.M_EqzJ6s.js} +1 -1
- package/admin-build/_app/immutable/nodes/{13.DDdauC84.js → 13.D_NQ9q60.js} +1 -1
- package/admin-build/_app/immutable/nodes/{14.D_rXaafJ.js → 14.-vENIJ2w.js} +1 -1
- package/admin-build/_app/immutable/nodes/{15.rfFjjiZc.js → 15.DoMCZhGv.js} +1 -1
- package/admin-build/_app/immutable/nodes/{16.DDEpGJ4Z.js → 16.6BmnzScq.js} +1 -1
- package/admin-build/_app/immutable/nodes/{17.ChQWNtRN.js → 17.q6QQ9N_J.js} +1 -1
- package/admin-build/_app/immutable/nodes/{18.Rir_JwlF.js → 18.DUhJ6dKj.js} +1 -1
- package/admin-build/_app/immutable/nodes/{19.CF76nxk1.js → 19.CvkvPlGP.js} +1 -1
- package/admin-build/_app/immutable/nodes/{20.JQer3KPM.js → 20.cACSbRIl.js} +1 -1
- package/admin-build/_app/immutable/nodes/21.DZ7G8rMF.js +1 -0
- package/admin-build/_app/immutable/nodes/{22.DUw7X6z_.js → 22.BsQ6xeKr.js} +1 -1
- package/admin-build/_app/immutable/nodes/{23.DS3svA-a.js → 23.CNW3mLz5.js} +1 -1
- package/admin-build/_app/immutable/nodes/{24.BYOsQM6B.js → 24.CReqtEOy.js} +1 -1
- package/admin-build/_app/immutable/nodes/{25.n0pyDBRv.js → 25.BuxVhqlb.js} +1 -1
- package/admin-build/_app/immutable/nodes/{26.C-Oo8sYK.js → 26.C-LzPf62.js} +1 -1
- package/admin-build/_app/immutable/nodes/{27.BfxbFNkk.js → 27.Dl5RL_tB.js} +1 -1
- package/admin-build/_app/immutable/nodes/{28.DyHSN1q7.js → 28.CWfSQgjH.js} +1 -1
- package/admin-build/_app/immutable/nodes/{29.CUAmZbCc.js → 29.wUSfh2eQ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{3.KQm4VJJg.js → 3.D_laBXeK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{30.CQdPre5F.js → 30.DcMhZyQ0.js} +1 -1
- package/admin-build/_app/immutable/nodes/{31.BP6raetq.js → 31.CSwhNsNi.js} +1 -1
- package/admin-build/_app/immutable/nodes/{4.Glf2jJHB.js → 4.Cq28vPI2.js} +1 -1
- package/admin-build/_app/immutable/nodes/{5.BdFtj_-X.js → 5.BTT9B3oI.js} +1 -1
- package/admin-build/_app/immutable/nodes/{6.JfnwWq8s.js → 6.iCmmzXZp.js} +1 -1
- package/admin-build/_app/immutable/nodes/{7.C-rw9qDh.js → 7.qY5v7qqJ.js} +1 -1
- package/admin-build/_app/immutable/nodes/{8.CA7r1Sjs.js → 8.C6p4RvgK.js} +1 -1
- package/admin-build/_app/immutable/nodes/{9.B2jgJDkH.js → 9.f548N7zh.js} +1 -1
- package/admin-build/_app/version.json +1 -1
- package/admin-build/index.html +7 -7
- package/openapi.json +305 -3
- package/package.json +3 -3
- package/src/__tests__/auth-token.test.ts +142 -0
- package/src/__tests__/email-provider.test.ts +82 -0
- package/src/__tests__/functions-context.test.ts +87 -0
- package/src/__tests__/functions-d1-proxy.test.ts +70 -0
- package/src/__tests__/functions-route.test.ts +15 -0
- package/src/__tests__/room-access-policy.test.ts +26 -0
- package/src/__tests__/room-handler-context.test.ts +2 -0
- package/src/__tests__/smoke-skip-report.test.ts +1 -1
- package/src/durable-objects/room-runtime-base.ts +16 -5
- package/src/durable-objects/rooms-do.ts +4 -1
- package/src/lib/auth-d1-service.ts +37 -7
- package/src/lib/auth-token.ts +66 -0
- package/src/lib/email-provider.ts +115 -5
- package/src/lib/functions.ts +91 -1
- package/src/lib/internal-transport.ts +15 -1
- package/src/lib/jwt.ts +2 -0
- package/src/routes/admin.ts +8 -1
- package/src/routes/auth.ts +111 -19
- package/src/routes/room.ts +36 -2
- package/src/routes/storage.ts +446 -62
- package/src/types.ts +10 -0
- package/admin-build/_app/immutable/chunks/B0zJ5_Wk.js +0 -1
- package/admin-build/_app/immutable/chunks/DZo4s3wn.js +0 -1
- package/admin-build/_app/immutable/entry/start.Bcf0SjPV.js +0 -1
- package/admin-build/_app/immutable/nodes/21.CzGYxjpu.js +0 -1
package/src/routes/storage.ts
CHANGED
|
@@ -305,8 +305,34 @@ function checkServiceKey(env: Env, header: string | undefined, scope: string, re
|
|
|
305
305
|
type SignedTokenClaims = {
|
|
306
306
|
expiresAt: number;
|
|
307
307
|
maxBytes: number | null;
|
|
308
|
+
file?: SignedTokenFileMetadata | null;
|
|
308
309
|
};
|
|
309
310
|
|
|
311
|
+
type SignedTokenFileMetadata = {
|
|
312
|
+
size: number;
|
|
313
|
+
contentType?: string;
|
|
314
|
+
etag?: string;
|
|
315
|
+
uploadedAt?: string;
|
|
316
|
+
uploadedBy?: string | null;
|
|
317
|
+
customMetadata?: Record<string, string>;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
type SignedTokenOptions = {
|
|
321
|
+
maxBytes?: number | null;
|
|
322
|
+
file?: SignedTokenFileMetadata | null;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
type TrackedMultipartPart = {
|
|
326
|
+
partNumber: number;
|
|
327
|
+
etag: string;
|
|
328
|
+
size?: number;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
type ParsedDownloadRange =
|
|
332
|
+
| { kind: 'full' }
|
|
333
|
+
| { kind: 'partial'; offset: number; end: number; length: number }
|
|
334
|
+
| { kind: 'unsatisfiable' };
|
|
335
|
+
|
|
310
336
|
function parseByteSize(value?: string): number | null {
|
|
311
337
|
if (!value) return null;
|
|
312
338
|
const trimmed = value.trim();
|
|
@@ -328,18 +354,98 @@ function parseByteSize(value?: string): number | null {
|
|
|
328
354
|
return amount * multiplier;
|
|
329
355
|
}
|
|
330
356
|
|
|
357
|
+
function emptySignedTokenClaims(expiresAt = 0, maxBytes: number | null = null): SignedTokenClaims {
|
|
358
|
+
return { expiresAt, maxBytes, file: null };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function normalizeSignedTokenOptions(options?: number | null | SignedTokenOptions): Required<Pick<SignedTokenOptions, 'maxBytes'>> & Pick<SignedTokenOptions, 'file'> {
|
|
362
|
+
const rawMaxBytes = typeof options === 'object' && options !== null
|
|
363
|
+
? options.maxBytes
|
|
364
|
+
: options;
|
|
365
|
+
const maxBytes = typeof rawMaxBytes === 'number' && Number.isFinite(rawMaxBytes)
|
|
366
|
+
? Math.max(0, Math.trunc(rawMaxBytes))
|
|
367
|
+
: null;
|
|
368
|
+
const file = typeof options === 'object' && options !== null
|
|
369
|
+
? normalizeSignedFileMetadata(options.file)
|
|
370
|
+
: null;
|
|
371
|
+
return { maxBytes, file };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function normalizeSignedFileMetadata(file?: SignedTokenFileMetadata | null): SignedTokenFileMetadata | null {
|
|
375
|
+
if (!file || typeof file.size !== 'number' || !Number.isFinite(file.size) || file.size < 0) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const customMetadata: Record<string, string> = {};
|
|
380
|
+
if (file.customMetadata && typeof file.customMetadata === 'object') {
|
|
381
|
+
for (const [key, value] of Object.entries(file.customMetadata)) {
|
|
382
|
+
if (typeof value === 'string') customMetadata[key] = value;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const normalized: SignedTokenFileMetadata = {
|
|
387
|
+
size: Math.trunc(file.size),
|
|
388
|
+
};
|
|
389
|
+
if (typeof file.contentType === 'string' && file.contentType) normalized.contentType = file.contentType;
|
|
390
|
+
if (typeof file.etag === 'string' && file.etag) normalized.etag = file.etag;
|
|
391
|
+
if (typeof file.uploadedAt === 'string' && file.uploadedAt) normalized.uploadedAt = file.uploadedAt;
|
|
392
|
+
if (typeof file.uploadedBy === 'string' || file.uploadedBy === null) normalized.uploadedBy = file.uploadedBy;
|
|
393
|
+
if (Object.keys(customMetadata).length > 0) normalized.customMetadata = customMetadata;
|
|
394
|
+
return normalized;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function encodeBase64Url(value: string): string {
|
|
398
|
+
const bytes = new TextEncoder().encode(value);
|
|
399
|
+
let binary = '';
|
|
400
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
401
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function decodeBase64Url(value: string): string {
|
|
405
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
|
406
|
+
const binary = atob(padded);
|
|
407
|
+
const bytes = new Uint8Array(binary.length);
|
|
408
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
409
|
+
return new TextDecoder().decode(bytes);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function signedFileMetadataFromObject(obj: R2Object): SignedTokenFileMetadata {
|
|
413
|
+
return {
|
|
414
|
+
size: obj.size,
|
|
415
|
+
contentType: obj.httpMetadata?.contentType || 'application/octet-stream',
|
|
416
|
+
etag: obj.etag,
|
|
417
|
+
uploadedAt: obj.uploaded?.toISOString(),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
331
421
|
/** Create HMAC-based signed URL token. */
|
|
332
422
|
export async function createSignedToken(
|
|
333
423
|
key: string,
|
|
334
424
|
bucket: string,
|
|
335
425
|
expiresAt: number,
|
|
336
426
|
secret: string,
|
|
337
|
-
|
|
427
|
+
options?: number | null | SignedTokenOptions,
|
|
338
428
|
): Promise<string> {
|
|
339
429
|
const encoder = new TextEncoder();
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
430
|
+
const { maxBytes: normalizedMaxBytes, file } = normalizeSignedTokenOptions(options);
|
|
431
|
+
if (file) {
|
|
432
|
+
const payload = encodeBase64Url(JSON.stringify({
|
|
433
|
+
v: 2,
|
|
434
|
+
bucket,
|
|
435
|
+
key,
|
|
436
|
+
exp: Math.trunc(expiresAt),
|
|
437
|
+
max: normalizedMaxBytes,
|
|
438
|
+
file,
|
|
439
|
+
}));
|
|
440
|
+
const data = `v2.${payload}`;
|
|
441
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
442
|
+
'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
|
|
443
|
+
);
|
|
444
|
+
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data));
|
|
445
|
+
const sigHex = Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
446
|
+
return `${data}.${sigHex}`;
|
|
447
|
+
}
|
|
448
|
+
|
|
343
449
|
const data = `${bucket}:${key}:${expiresAt}:${normalizedMaxBytes ?? ''}`;
|
|
344
450
|
const cryptoKey = await crypto.subtle.importKey(
|
|
345
451
|
'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
|
|
@@ -359,18 +465,63 @@ async function verifySignedToken(
|
|
|
359
465
|
secret: string,
|
|
360
466
|
): Promise<{ valid: boolean; claims: SignedTokenClaims }> {
|
|
361
467
|
const parts = token.split('.');
|
|
468
|
+
if (parts.length === 3 && parts[0] === 'v2') {
|
|
469
|
+
const payload = parts[1]!;
|
|
470
|
+
const signature = parts[2]!;
|
|
471
|
+
let claims: SignedTokenClaims = emptySignedTokenClaims();
|
|
472
|
+
try {
|
|
473
|
+
const parsed = JSON.parse(decodeBase64Url(payload)) as {
|
|
474
|
+
v?: unknown;
|
|
475
|
+
bucket?: unknown;
|
|
476
|
+
key?: unknown;
|
|
477
|
+
exp?: unknown;
|
|
478
|
+
max?: unknown;
|
|
479
|
+
file?: unknown;
|
|
480
|
+
};
|
|
481
|
+
const expiresAt = typeof parsed.exp === 'number' ? Math.trunc(parsed.exp) : NaN;
|
|
482
|
+
const maxBytes = typeof parsed.max === 'number' && Number.isFinite(parsed.max)
|
|
483
|
+
? Math.max(0, Math.trunc(parsed.max))
|
|
484
|
+
: null;
|
|
485
|
+
const file = normalizeSignedFileMetadata(parsed.file as SignedTokenFileMetadata | null | undefined);
|
|
486
|
+
claims = { expiresAt, maxBytes, file };
|
|
487
|
+
if (
|
|
488
|
+
parsed.v !== 2
|
|
489
|
+
|| parsed.bucket !== bucket
|
|
490
|
+
|| parsed.key !== key
|
|
491
|
+
|| !Number.isFinite(expiresAt)
|
|
492
|
+
|| Date.now() > expiresAt
|
|
493
|
+
) {
|
|
494
|
+
return { valid: false, claims };
|
|
495
|
+
}
|
|
496
|
+
} catch {
|
|
497
|
+
return { valid: false, claims: emptySignedTokenClaims() };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const encoder = new TextEncoder();
|
|
501
|
+
const data = `v2.${payload}`;
|
|
502
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
503
|
+
'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
|
|
504
|
+
);
|
|
505
|
+
const expected = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data));
|
|
506
|
+
const expectedHex = Array.from(new Uint8Array(expected)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
507
|
+
return {
|
|
508
|
+
valid: timingSafeEqual(signature, expectedHex),
|
|
509
|
+
claims,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
362
513
|
if (parts.length !== 2 && parts.length !== 3) {
|
|
363
|
-
return { valid: false, claims:
|
|
514
|
+
return { valid: false, claims: emptySignedTokenClaims() };
|
|
364
515
|
}
|
|
365
516
|
const expiresAt = parseInt(parts[0]!, 10);
|
|
366
517
|
const maxBytes = parts.length === 3 ? parseInt(parts[1]!, 10) : null;
|
|
367
518
|
const signature = parts[parts.length - 1]!;
|
|
368
519
|
|
|
369
520
|
if (isNaN(expiresAt) || Date.now() > expiresAt) {
|
|
370
|
-
return { valid: false, claims:
|
|
521
|
+
return { valid: false, claims: emptySignedTokenClaims(expiresAt, Number.isFinite(maxBytes ?? NaN) ? maxBytes : null) };
|
|
371
522
|
}
|
|
372
523
|
if (parts.length === 3 && !Number.isFinite(maxBytes)) {
|
|
373
|
-
return { valid: false, claims:
|
|
524
|
+
return { valid: false, claims: emptySignedTokenClaims(expiresAt) };
|
|
374
525
|
}
|
|
375
526
|
|
|
376
527
|
const encoder = new TextEncoder();
|
|
@@ -385,10 +536,66 @@ async function verifySignedToken(
|
|
|
385
536
|
claims: {
|
|
386
537
|
expiresAt,
|
|
387
538
|
maxBytes: parts.length === 3 ? maxBytes : null,
|
|
539
|
+
file: null,
|
|
388
540
|
},
|
|
389
541
|
};
|
|
390
542
|
}
|
|
391
543
|
|
|
544
|
+
async function verifySignedUploadQuery(
|
|
545
|
+
c: Context<HonoEnv>,
|
|
546
|
+
bucketName: string,
|
|
547
|
+
key: string,
|
|
548
|
+
): Promise<SignedTokenClaims | null> {
|
|
549
|
+
const token = c.req.query('token');
|
|
550
|
+
const tokenKey = c.req.query('key');
|
|
551
|
+
if (!token) return null;
|
|
552
|
+
if (tokenKey && tokenKey !== key) {
|
|
553
|
+
throw new EdgeBaseError(400, 'Signed upload key mismatch.', undefined, 'validation-failed');
|
|
554
|
+
}
|
|
555
|
+
const secret = c.env.JWT_USER_SECRET;
|
|
556
|
+
if (!secret) {
|
|
557
|
+
throw new EdgeBaseError(403, 'Signed upload tokens require JWT_USER_SECRET to be configured.', undefined, 'access-denied');
|
|
558
|
+
}
|
|
559
|
+
const verified = await verifySignedToken(token, key, bucketName, secret);
|
|
560
|
+
if (!verified.valid) {
|
|
561
|
+
throw new EdgeBaseError(403, 'Signed upload token is invalid or expired.', undefined, 'access-denied');
|
|
562
|
+
}
|
|
563
|
+
return verified.claims;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function requestContentLength(c: Context<HonoEnv>): number | null {
|
|
567
|
+
const header = c.req.header('content-length');
|
|
568
|
+
if (!header) return null;
|
|
569
|
+
const size = Number(header);
|
|
570
|
+
return Number.isFinite(size) && size >= 0 ? Math.floor(size) : null;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function assertSignedMultipartSizeWithinLimit(
|
|
574
|
+
c: Context<HonoEnv>,
|
|
575
|
+
bucketName: string,
|
|
576
|
+
key: string,
|
|
577
|
+
uploadId: string,
|
|
578
|
+
parts: Array<{ partNumber: number; etag: string }>,
|
|
579
|
+
maxBytes: number,
|
|
580
|
+
) {
|
|
581
|
+
const kvKey = partTrackingKey(bucketName, key, uploadId);
|
|
582
|
+
const existing = await c.env.KV.get(kvKey, 'json') as TrackedMultipartPart[] | null;
|
|
583
|
+
const tracked = new Map(
|
|
584
|
+
(existing ?? []).map((part) => [`${part.partNumber}:${part.etag}`, part] as const),
|
|
585
|
+
);
|
|
586
|
+
let total = 0;
|
|
587
|
+
for (const part of parts) {
|
|
588
|
+
const trackedPart = tracked.get(`${part.partNumber}:${part.etag}`);
|
|
589
|
+
if (!trackedPart || typeof trackedPart.size !== 'number' || !Number.isFinite(trackedPart.size)) {
|
|
590
|
+
throw new EdgeBaseError(400, 'Signed multipart upload is missing tracked part size metadata.', undefined, 'validation-failed');
|
|
591
|
+
}
|
|
592
|
+
total += trackedPart.size;
|
|
593
|
+
if (total > maxBytes) {
|
|
594
|
+
throw new EdgeBaseError(413, `Signed upload exceeds maxFileSize of ${maxBytes} bytes.`, undefined, 'payload-too-large');
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
392
599
|
/** Parse duration string (e.g. '1h', '30m') to milliseconds. Max 7 days. */
|
|
393
600
|
export function parseDuration(str: string): number {
|
|
394
601
|
const match = str.match(/^(\d+)(s|m|h|d)$/);
|
|
@@ -447,6 +654,85 @@ function buildMetadata(obj: R2Object): R2FileMeta {
|
|
|
447
654
|
} as R2FileMeta;
|
|
448
655
|
}
|
|
449
656
|
|
|
657
|
+
function buildMetadataFromSignedFile(key: string, file: SignedTokenFileMetadata): R2FileMeta {
|
|
658
|
+
return {
|
|
659
|
+
key,
|
|
660
|
+
size: file.size,
|
|
661
|
+
contentType: file.contentType || 'application/octet-stream',
|
|
662
|
+
etag: file.etag || '',
|
|
663
|
+
uploadedAt: file.uploadedAt,
|
|
664
|
+
uploadedBy: file.uploadedBy ?? file.customMetadata?.uploadedBy ?? null,
|
|
665
|
+
customMetadata: file.customMetadata || {},
|
|
666
|
+
} as R2FileMeta;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function hasBlockingBeforeDownloadHooks(env: Env, bucketName: string): boolean {
|
|
670
|
+
const pluginHooks = getFunctionsByTrigger('storage', { type: 'storage', event: 'beforeDownload' } as unknown as StorageTrigger);
|
|
671
|
+
if (pluginHooks.length > 0) return true;
|
|
672
|
+
return !!getStorageHooks(env, bucketName)?.beforeDownload;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function parseDownloadRange(header: string | undefined, size: number): ParsedDownloadRange {
|
|
676
|
+
if (!header) return { kind: 'full' };
|
|
677
|
+
const match = header.trim().match(/^bytes=(.+)$/i);
|
|
678
|
+
if (!match) return { kind: 'full' };
|
|
679
|
+
const range = match[1]?.trim() ?? '';
|
|
680
|
+
if (!range || range.includes(',')) return { kind: 'unsatisfiable' };
|
|
681
|
+
|
|
682
|
+
const [rawStart, rawEnd] = range.split('-', 2);
|
|
683
|
+
if (rawStart === undefined || rawEnd === undefined) return { kind: 'unsatisfiable' };
|
|
684
|
+
|
|
685
|
+
let offset: number;
|
|
686
|
+
let end: number;
|
|
687
|
+
|
|
688
|
+
if (rawStart === '') {
|
|
689
|
+
const suffixLength = Number(rawEnd);
|
|
690
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0 || size <= 0) {
|
|
691
|
+
return { kind: 'unsatisfiable' };
|
|
692
|
+
}
|
|
693
|
+
offset = Math.max(size - suffixLength, 0);
|
|
694
|
+
end = size - 1;
|
|
695
|
+
} else {
|
|
696
|
+
const start = Number(rawStart);
|
|
697
|
+
const requestedEnd = rawEnd === '' ? size - 1 : Number(rawEnd);
|
|
698
|
+
if (
|
|
699
|
+
!Number.isSafeInteger(start)
|
|
700
|
+
|| !Number.isSafeInteger(requestedEnd)
|
|
701
|
+
|| start < 0
|
|
702
|
+
|| requestedEnd < start
|
|
703
|
+
|| start >= size
|
|
704
|
+
) {
|
|
705
|
+
return { kind: 'unsatisfiable' };
|
|
706
|
+
}
|
|
707
|
+
offset = start;
|
|
708
|
+
end = Math.min(requestedEnd, size - 1);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return { kind: 'partial', offset, end, length: end - offset + 1 };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function createDownloadHeaders(meta: R2FileMeta, signedClaims?: SignedTokenClaims | null): Headers {
|
|
715
|
+
const headers = new Headers();
|
|
716
|
+
headers.set('Content-Type', meta.contentType || 'application/octet-stream');
|
|
717
|
+
if (meta.etag) {
|
|
718
|
+
headers.set('ETag', meta.etag);
|
|
719
|
+
}
|
|
720
|
+
headers.set('Accept-Ranges', 'bytes');
|
|
721
|
+
if (meta.uploadedAt) {
|
|
722
|
+
const uploadedAt = new Date(meta.uploadedAt);
|
|
723
|
+
if (!Number.isNaN(uploadedAt.getTime())) {
|
|
724
|
+
headers.set('Last-Modified', uploadedAt.toUTCString());
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (signedClaims) {
|
|
728
|
+
const maxAge = Math.max(0, Math.floor((signedClaims.expiresAt - Date.now()) / 1000));
|
|
729
|
+
if (maxAge > 0) {
|
|
730
|
+
headers.set('Cache-Control', `private, max-age=${Math.min(maxAge, 3600)}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return headers;
|
|
734
|
+
}
|
|
735
|
+
|
|
450
736
|
const STORAGE_OFFSET_CURSOR_PREFIX = 'offset:';
|
|
451
737
|
|
|
452
738
|
function parseStorageListInteger(raw: string | undefined, name: string, fallback: number): number {
|
|
@@ -616,12 +902,17 @@ const uploadFile = createRoute({
|
|
|
616
902
|
summary: 'Upload file',
|
|
617
903
|
request: {
|
|
618
904
|
params: z.object({ bucket: z.string() }),
|
|
905
|
+
query: z.object({
|
|
906
|
+
key: z.string().optional().openapi({ description: 'Optional signed upload key echoed from a signed upload URL.' }),
|
|
907
|
+
token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
|
|
908
|
+
}),
|
|
619
909
|
body: { content: { 'multipart/form-data': { schema: z.object({}).passthrough() } }, required: true },
|
|
620
910
|
},
|
|
621
911
|
responses: {
|
|
622
912
|
201: { description: 'File uploaded', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
623
913
|
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
624
914
|
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
915
|
+
413: { description: 'Payload too large', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
625
916
|
},
|
|
626
917
|
});
|
|
627
918
|
|
|
@@ -908,7 +1199,10 @@ const getUploadParts = createRoute({
|
|
|
908
1199
|
summary: 'Get uploaded parts',
|
|
909
1200
|
request: {
|
|
910
1201
|
params: z.object({ bucket: z.string(), uploadId: z.string() }),
|
|
911
|
-
query: z.object({
|
|
1202
|
+
query: z.object({
|
|
1203
|
+
key: z.string(),
|
|
1204
|
+
token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
|
|
1205
|
+
}),
|
|
912
1206
|
},
|
|
913
1207
|
responses: {
|
|
914
1208
|
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
@@ -928,14 +1222,17 @@ storage.openapi(getUploadParts, async (c) => {
|
|
|
928
1222
|
}
|
|
929
1223
|
|
|
930
1224
|
// Security: check write rule (resume upload = write operation)
|
|
931
|
-
const
|
|
932
|
-
|
|
1225
|
+
const signedClaims = await verifySignedUploadQuery(c, bucketName, key);
|
|
1226
|
+
const serviceKeyBypass = signedClaims
|
|
1227
|
+
? false
|
|
1228
|
+
: checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
|
|
1229
|
+
if (!signedClaims && !serviceKeyBypass) {
|
|
933
1230
|
const auth = c.get('auth') as AuthContext | null;
|
|
934
1231
|
checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
|
|
935
1232
|
}
|
|
936
1233
|
|
|
937
1234
|
const kvKey = partTrackingKey(bucketName, key, uploadId);
|
|
938
|
-
const parts = await c.env.KV.get(kvKey, 'json') as
|
|
1235
|
+
const parts = await c.env.KV.get(kvKey, 'json') as TrackedMultipartPart[] | null;
|
|
939
1236
|
|
|
940
1237
|
return c.json({
|
|
941
1238
|
uploadId,
|
|
@@ -955,8 +1252,10 @@ const downloadFile = createRoute({
|
|
|
955
1252
|
},
|
|
956
1253
|
responses: {
|
|
957
1254
|
200: { description: 'File content' },
|
|
1255
|
+
206: { description: 'Partial file content' },
|
|
958
1256
|
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
959
1257
|
404: { description: 'Not found', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1258
|
+
416: { description: 'Range not satisfiable' },
|
|
960
1259
|
},
|
|
961
1260
|
});
|
|
962
1261
|
|
|
@@ -968,6 +1267,7 @@ const handleDownloadFile = async (c: Context<HonoEnv>) => {
|
|
|
968
1267
|
// Check for signed URL token
|
|
969
1268
|
const token = c.req.query('token');
|
|
970
1269
|
let skipRules = false;
|
|
1270
|
+
let signedClaims: SignedTokenClaims | null = null;
|
|
971
1271
|
|
|
972
1272
|
if (token) {
|
|
973
1273
|
// Asymmetric fail-closed (#99): secret 미설정 시 토큰 무시 → 보안 규칙으로 fallback
|
|
@@ -976,14 +1276,31 @@ const handleDownloadFile = async (c: Context<HonoEnv>) => {
|
|
|
976
1276
|
const verified = await verifySignedToken(token, key, bucketName, secret);
|
|
977
1277
|
if (verified.valid) {
|
|
978
1278
|
skipRules = true;
|
|
1279
|
+
signedClaims = verified.claims;
|
|
979
1280
|
}
|
|
980
1281
|
}
|
|
981
1282
|
}
|
|
982
1283
|
|
|
983
1284
|
const fullKey = r2Key(bucketName, key);
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
|
|
1285
|
+
const rangeHeader = c.req.header('Range') ?? c.req.header('range');
|
|
1286
|
+
const isByteRangeRequest = !!rangeHeader && /^bytes=/i.test(rangeHeader.trim());
|
|
1287
|
+
const signedMetadataAllowed = skipRules && isByteRangeRequest && !!signedClaims?.file && !hasBlockingBeforeDownloadHooks(c.env, bucketName);
|
|
1288
|
+
let fileMeta: R2FileMeta | null = signedMetadataAllowed && signedClaims?.file
|
|
1289
|
+
? buildMetadataFromSignedFile(key, signedClaims.file)
|
|
1290
|
+
: null;
|
|
1291
|
+
let bodyObj: R2ObjectBody | null = null;
|
|
1292
|
+
|
|
1293
|
+
if (!fileMeta) {
|
|
1294
|
+
const file = rangeHeader
|
|
1295
|
+
? await c.env.STORAGE.head(fullKey)
|
|
1296
|
+
: await c.env.STORAGE.get(fullKey);
|
|
1297
|
+
if (!file) {
|
|
1298
|
+
throw new EdgeBaseError(404, 'File not found.', undefined, 'not-found');
|
|
1299
|
+
}
|
|
1300
|
+
fileMeta = buildMetadata(file);
|
|
1301
|
+
if (!rangeHeader) {
|
|
1302
|
+
bodyObj = file as R2ObjectBody;
|
|
1303
|
+
}
|
|
987
1304
|
}
|
|
988
1305
|
|
|
989
1306
|
// Security: check read rule
|
|
@@ -991,41 +1308,57 @@ const handleDownloadFile = async (c: Context<HonoEnv>) => {
|
|
|
991
1308
|
const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:read`, c.req);
|
|
992
1309
|
if (!serviceKeyBypass) {
|
|
993
1310
|
const auth = c.get('auth') as AuthContext | null;
|
|
994
|
-
|
|
995
|
-
checkStorageRule(bucketConfig.access?.read, auth, resource, 'read', bucketName, release);
|
|
1311
|
+
checkStorageRule(bucketConfig.access?.read, auth, fileMeta, 'read', bucketName, release);
|
|
996
1312
|
}
|
|
997
1313
|
}
|
|
998
1314
|
|
|
999
1315
|
// Plugin blocking storage hooks (beforeDownload)
|
|
1000
1316
|
{
|
|
1001
1317
|
const dlAuth = c.get('auth') as AuthContext | null;
|
|
1002
|
-
|
|
1003
|
-
await executeBlockingStorageHooks('beforeDownload', { ...dlMeta, bucket: bucketName }, dlAuth, c.env, getWorkerUrl(c.req.url, c.env));
|
|
1318
|
+
await executeBlockingStorageHooks('beforeDownload', { ...fileMeta, bucket: bucketName }, dlAuth, c.env, getWorkerUrl(c.req.url, c.env));
|
|
1004
1319
|
}
|
|
1005
1320
|
|
|
1006
1321
|
// beforeDownload hook — blocking, throw to reject
|
|
1007
1322
|
const hooks = getStorageHooks(c.env, bucketName);
|
|
1008
1323
|
if (hooks?.beforeDownload) {
|
|
1009
1324
|
const auth = c.get('auth') as AuthContext | null;
|
|
1010
|
-
const meta = buildMetadata(obj);
|
|
1011
1325
|
const hookCtx = buildStorageHookCtx(c.env, c.executionCtx, getWorkerUrl(c.req.url, c.env));
|
|
1012
1326
|
try {
|
|
1013
|
-
await hooks.beforeDownload(auth,
|
|
1327
|
+
await hooks.beforeDownload(auth, fileMeta, hookCtx);
|
|
1014
1328
|
} catch (error) {
|
|
1015
1329
|
throw normalizeStorageHookError(error, 'beforeDownload');
|
|
1016
1330
|
}
|
|
1017
1331
|
}
|
|
1018
1332
|
|
|
1019
|
-
|
|
1020
|
-
const headers =
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1333
|
+
const parsedRange = parseDownloadRange(rangeHeader, fileMeta.size);
|
|
1334
|
+
const headers = createDownloadHeaders(fileMeta, skipRules ? signedClaims : null);
|
|
1335
|
+
|
|
1336
|
+
if (parsedRange.kind === 'unsatisfiable') {
|
|
1337
|
+
headers.set('Content-Range', `bytes */${fileMeta.size}`);
|
|
1338
|
+
headers.set('Content-Length', '0');
|
|
1339
|
+
return new Response(null, { status: 416, headers });
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
if (parsedRange.kind === 'partial') {
|
|
1343
|
+
const rangedObj = await c.env.STORAGE.get(fullKey, {
|
|
1344
|
+
range: { offset: parsedRange.offset, length: parsedRange.length },
|
|
1345
|
+
});
|
|
1346
|
+
if (!rangedObj) {
|
|
1347
|
+
throw new EdgeBaseError(404, 'File not found.', undefined, 'not-found');
|
|
1348
|
+
}
|
|
1349
|
+
headers.set('Content-Length', String(parsedRange.length));
|
|
1350
|
+
headers.set('Content-Range', `bytes ${parsedRange.offset}-${parsedRange.end}/${fileMeta.size}`);
|
|
1351
|
+
return new Response(rangedObj.body, { status: 206, headers });
|
|
1026
1352
|
}
|
|
1027
1353
|
|
|
1028
|
-
|
|
1354
|
+
if (!bodyObj) {
|
|
1355
|
+
bodyObj = await c.env.STORAGE.get(fullKey);
|
|
1356
|
+
if (!bodyObj) {
|
|
1357
|
+
throw new EdgeBaseError(404, 'File not found.', undefined, 'not-found');
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
headers.set('Content-Length', String(fileMeta.size));
|
|
1361
|
+
return new Response(bodyObj.body, { headers });
|
|
1029
1362
|
};
|
|
1030
1363
|
storage.openapi(downloadFile, handleDownloadFile);
|
|
1031
1364
|
|
|
@@ -1298,7 +1631,9 @@ storage.openapi(createSignedDownloadUrl, async (c) => {
|
|
|
1298
1631
|
if (!secret) {
|
|
1299
1632
|
throw new EdgeBaseError(500, 'Signed URLs require JWT_USER_SECRET to be configured.', undefined, 'internal-error');
|
|
1300
1633
|
}
|
|
1301
|
-
const token = await createSignedToken(body.key, bucketName, expiresAt, secret
|
|
1634
|
+
const token = await createSignedToken(body.key, bucketName, expiresAt, secret, {
|
|
1635
|
+
file: signedFileMetadataFromObject(obj),
|
|
1636
|
+
});
|
|
1302
1637
|
|
|
1303
1638
|
// Build signed URL
|
|
1304
1639
|
const url = new URL(c.req.url);
|
|
@@ -1362,7 +1697,9 @@ storage.openapi(createSignedDownloadUrls, async (c) => {
|
|
|
1362
1697
|
const obj = await c.env.STORAGE.head(fullKey);
|
|
1363
1698
|
if (!obj) continue; // skip non-existent files
|
|
1364
1699
|
|
|
1365
|
-
const token = await createSignedToken(key, bucketName, expiresAt, secret
|
|
1700
|
+
const token = await createSignedToken(key, bucketName, expiresAt, secret, {
|
|
1701
|
+
file: signedFileMetadataFromObject(obj),
|
|
1702
|
+
});
|
|
1366
1703
|
urls.push({
|
|
1367
1704
|
key,
|
|
1368
1705
|
url: `${url.protocol}//${url.host}/api/storage/${encodeURIComponent(bucketName)}/${encodeURIComponent(key)}?token=${token}`,
|
|
@@ -1445,6 +1782,10 @@ const createMultipartUpload = createRoute({
|
|
|
1445
1782
|
summary: 'Start multipart upload',
|
|
1446
1783
|
request: {
|
|
1447
1784
|
params: z.object({ bucket: z.string() }),
|
|
1785
|
+
query: z.object({
|
|
1786
|
+
key: z.string().optional().openapi({ description: 'Optional signed upload key echoed from a signed upload URL.' }),
|
|
1787
|
+
token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
|
|
1788
|
+
}),
|
|
1448
1789
|
body: { content: { 'application/json': { schema: z.object({ key: z.string(), contentType: z.string().optional(), customMetadata: z.record(z.string(), z.string()).optional() }) } }, required: true },
|
|
1449
1790
|
},
|
|
1450
1791
|
responses: {
|
|
@@ -1458,19 +1799,22 @@ storage.openapi(createMultipartUpload, async (c) => {
|
|
|
1458
1799
|
const bucketName = c.req.param('bucket')!;
|
|
1459
1800
|
const { bucketConfig, release } = getBucketConfig(c.env, bucketName);
|
|
1460
1801
|
|
|
1461
|
-
// Security: check write rule
|
|
1462
|
-
const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
|
|
1463
|
-
if (!serviceKeyBypass) {
|
|
1464
|
-
const auth = c.get('auth') as AuthContext | null;
|
|
1465
|
-
checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
1802
|
const body = await c.req.json<{ key: string; contentType?: string; customMetadata?: Record<string, string> }>();
|
|
1469
1803
|
if (!body.key) {
|
|
1470
1804
|
throw new EdgeBaseError(400, 'Missing required field: key.', undefined, 'validation-failed');
|
|
1471
1805
|
}
|
|
1472
1806
|
validateStorageKey(body.key);
|
|
1473
1807
|
|
|
1808
|
+
// Security: check signed upload token or write rule
|
|
1809
|
+
const signedClaims = await verifySignedUploadQuery(c, bucketName, body.key);
|
|
1810
|
+
const serviceKeyBypass = signedClaims
|
|
1811
|
+
? false
|
|
1812
|
+
: checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
|
|
1813
|
+
if (!signedClaims && !serviceKeyBypass) {
|
|
1814
|
+
const auth = c.get('auth') as AuthContext | null;
|
|
1815
|
+
checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1474
1818
|
const auth = c.get('auth') as AuthContext | null;
|
|
1475
1819
|
const customMetadata = body.customMetadata || {};
|
|
1476
1820
|
if (auth?.id) {
|
|
@@ -1497,12 +1841,19 @@ const uploadPart = createRoute({
|
|
|
1497
1841
|
summary: 'Upload a part',
|
|
1498
1842
|
request: {
|
|
1499
1843
|
params: z.object({ bucket: z.string() }),
|
|
1500
|
-
|
|
1844
|
+
query: z.object({
|
|
1845
|
+
uploadId: z.string(),
|
|
1846
|
+
partNumber: z.string(),
|
|
1847
|
+
key: z.string(),
|
|
1848
|
+
token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
|
|
1849
|
+
}),
|
|
1850
|
+
body: { content: { 'application/octet-stream': { schema: z.string().openapi({ format: 'binary' }) } }, required: true },
|
|
1501
1851
|
},
|
|
1502
1852
|
responses: {
|
|
1503
1853
|
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1504
1854
|
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1505
1855
|
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1856
|
+
413: { description: 'Payload too large', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1506
1857
|
},
|
|
1507
1858
|
});
|
|
1508
1859
|
|
|
@@ -1510,13 +1861,6 @@ storage.openapi(uploadPart, async (c) => {
|
|
|
1510
1861
|
const bucketName = c.req.param('bucket')!;
|
|
1511
1862
|
const { bucketConfig, release } = getBucketConfig(c.env, bucketName);
|
|
1512
1863
|
|
|
1513
|
-
// Security: check write rule
|
|
1514
|
-
const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
|
|
1515
|
-
if (!serviceKeyBypass) {
|
|
1516
|
-
const auth = c.get('auth') as AuthContext | null;
|
|
1517
|
-
checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
1864
|
const uploadId = c.req.query('uploadId');
|
|
1521
1865
|
const partNumber = parseInt(c.req.query('partNumber') || '0', 10);
|
|
1522
1866
|
const key = c.req.query('key');
|
|
@@ -1524,6 +1868,24 @@ storage.openapi(uploadPart, async (c) => {
|
|
|
1524
1868
|
if (!uploadId || !partNumber || !key) {
|
|
1525
1869
|
throw new EdgeBaseError(400, 'Missing required query params: uploadId, partNumber, key.', undefined, 'validation-failed');
|
|
1526
1870
|
}
|
|
1871
|
+
validateStorageKey(key);
|
|
1872
|
+
|
|
1873
|
+
// Security: check signed upload token or write rule
|
|
1874
|
+
const signedClaims = await verifySignedUploadQuery(c, bucketName, key);
|
|
1875
|
+
const serviceKeyBypass = signedClaims
|
|
1876
|
+
? false
|
|
1877
|
+
: checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
|
|
1878
|
+
if (!signedClaims && !serviceKeyBypass) {
|
|
1879
|
+
const auth = c.get('auth') as AuthContext | null;
|
|
1880
|
+
checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
|
|
1881
|
+
}
|
|
1882
|
+
const partSize = requestContentLength(c);
|
|
1883
|
+
if (signedClaims?.maxBytes != null && partSize == null) {
|
|
1884
|
+
throw new EdgeBaseError(400, 'Signed multipart upload parts require Content-Length.', undefined, 'validation-failed');
|
|
1885
|
+
}
|
|
1886
|
+
if (signedClaims?.maxBytes != null && partSize !== null && partSize > signedClaims.maxBytes) {
|
|
1887
|
+
throw new EdgeBaseError(413, `Signed upload exceeds maxFileSize of ${signedClaims.maxBytes} bytes.`, undefined, 'payload-too-large');
|
|
1888
|
+
}
|
|
1527
1889
|
|
|
1528
1890
|
const fullKey = r2Key(bucketName, key);
|
|
1529
1891
|
const multipartUpload = c.env.STORAGE.resumeMultipartUpload(fullKey, uploadId);
|
|
@@ -1532,14 +1894,16 @@ storage.openapi(uploadPart, async (c) => {
|
|
|
1532
1894
|
|
|
1533
1895
|
// M17: Save part info to KV for resume tracking
|
|
1534
1896
|
const kvKey = partTrackingKey(bucketName, key, uploadId);
|
|
1535
|
-
const existing = await c.env.KV.get(kvKey, 'json') as
|
|
1897
|
+
const existing = await c.env.KV.get(kvKey, 'json') as TrackedMultipartPart[] | null;
|
|
1536
1898
|
const parts = existing || [];
|
|
1537
1899
|
// Replace if same partNumber exists (re-upload), otherwise append
|
|
1538
1900
|
const idx = parts.findIndex(p => p.partNumber === part.partNumber);
|
|
1901
|
+
const trackedPart: TrackedMultipartPart = { partNumber: part.partNumber, etag: part.etag };
|
|
1902
|
+
if (partSize !== null) trackedPart.size = partSize;
|
|
1539
1903
|
if (idx >= 0) {
|
|
1540
|
-
parts[idx] =
|
|
1904
|
+
parts[idx] = trackedPart;
|
|
1541
1905
|
} else {
|
|
1542
|
-
parts.push(
|
|
1906
|
+
parts.push(trackedPart);
|
|
1543
1907
|
}
|
|
1544
1908
|
await c.env.KV.put(kvKey, JSON.stringify(parts), { expirationTtl: PART_TRACKING_TTL });
|
|
1545
1909
|
|
|
@@ -1557,12 +1921,17 @@ const completeMultipartUpload = createRoute({
|
|
|
1557
1921
|
summary: 'Complete multipart upload',
|
|
1558
1922
|
request: {
|
|
1559
1923
|
params: z.object({ bucket: z.string() }),
|
|
1924
|
+
query: z.object({
|
|
1925
|
+
key: z.string().optional().openapi({ description: 'Optional signed upload key echoed from a signed upload URL.' }),
|
|
1926
|
+
token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
|
|
1927
|
+
}),
|
|
1560
1928
|
body: { content: { 'application/json': { schema: z.object({ uploadId: z.string(), key: z.string(), parts: z.array(z.object({ partNumber: z.number(), etag: z.string() })) }) } }, required: true },
|
|
1561
1929
|
},
|
|
1562
1930
|
responses: {
|
|
1563
1931
|
200: { description: 'Success', content: { 'application/json': { schema: jsonResponseSchema } } },
|
|
1564
1932
|
400: { description: 'Bad request', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1565
1933
|
403: { description: 'Forbidden', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1934
|
+
413: { description: 'Payload too large', content: { 'application/json': { schema: errorResponseSchema } } },
|
|
1566
1935
|
},
|
|
1567
1936
|
});
|
|
1568
1937
|
|
|
@@ -1570,13 +1939,6 @@ storage.openapi(completeMultipartUpload, async (c) => {
|
|
|
1570
1939
|
const bucketName = c.req.param('bucket')!;
|
|
1571
1940
|
const { bucketConfig, release } = getBucketConfig(c.env, bucketName);
|
|
1572
1941
|
|
|
1573
|
-
// Security: check write rule
|
|
1574
|
-
const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
|
|
1575
|
-
if (!serviceKeyBypass) {
|
|
1576
|
-
const auth = c.get('auth') as AuthContext | null;
|
|
1577
|
-
checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
1942
|
const body = await c.req.json<{
|
|
1581
1943
|
uploadId: string;
|
|
1582
1944
|
key: string;
|
|
@@ -1586,6 +1948,20 @@ storage.openapi(completeMultipartUpload, async (c) => {
|
|
|
1586
1948
|
if (!body.uploadId || !body.key || !body.parts?.length) {
|
|
1587
1949
|
throw new EdgeBaseError(400, 'Missing required fields: uploadId, key, parts.', undefined, 'validation-failed');
|
|
1588
1950
|
}
|
|
1951
|
+
validateStorageKey(body.key);
|
|
1952
|
+
|
|
1953
|
+
// Security: check signed upload token or write rule
|
|
1954
|
+
const signedClaims = await verifySignedUploadQuery(c, bucketName, body.key);
|
|
1955
|
+
const serviceKeyBypass = signedClaims
|
|
1956
|
+
? false
|
|
1957
|
+
: checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
|
|
1958
|
+
if (!signedClaims && !serviceKeyBypass) {
|
|
1959
|
+
const auth = c.get('auth') as AuthContext | null;
|
|
1960
|
+
checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
|
|
1961
|
+
}
|
|
1962
|
+
if (signedClaims?.maxBytes != null) {
|
|
1963
|
+
await assertSignedMultipartSizeWithinLimit(c, bucketName, body.key, body.uploadId, body.parts, signedClaims.maxBytes);
|
|
1964
|
+
}
|
|
1589
1965
|
|
|
1590
1966
|
const fullKey = r2Key(bucketName, body.key);
|
|
1591
1967
|
const multipartUpload = c.env.STORAGE.resumeMultipartUpload(fullKey, body.uploadId);
|
|
@@ -1610,6 +1986,10 @@ const abortMultipartUpload = createRoute({
|
|
|
1610
1986
|
summary: 'Abort multipart upload',
|
|
1611
1987
|
request: {
|
|
1612
1988
|
params: z.object({ bucket: z.string() }),
|
|
1989
|
+
query: z.object({
|
|
1990
|
+
key: z.string().optional().openapi({ description: 'Optional signed upload key echoed from a signed upload URL.' }),
|
|
1991
|
+
token: z.string().optional().openapi({ description: 'Optional signed upload token for write-rule-bypassing upload grants.' }),
|
|
1992
|
+
}),
|
|
1613
1993
|
body: { content: { 'application/json': { schema: z.object({ uploadId: z.string(), key: z.string() }) } }, required: true },
|
|
1614
1994
|
},
|
|
1615
1995
|
responses: {
|
|
@@ -1623,17 +2003,21 @@ storage.openapi(abortMultipartUpload, async (c) => {
|
|
|
1623
2003
|
const bucketName = c.req.param('bucket')!;
|
|
1624
2004
|
const { bucketConfig, release } = getBucketConfig(c.env, bucketName);
|
|
1625
2005
|
|
|
1626
|
-
// Security: check write rule
|
|
1627
|
-
const serviceKeyBypass = checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
|
|
1628
|
-
if (!serviceKeyBypass) {
|
|
1629
|
-
const auth = c.get('auth') as AuthContext | null;
|
|
1630
|
-
checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
2006
|
const body = await c.req.json<{ uploadId: string; key: string }>();
|
|
1634
2007
|
if (!body.uploadId || !body.key) {
|
|
1635
2008
|
throw new EdgeBaseError(400, 'Missing required fields: uploadId, key.', undefined, 'validation-failed');
|
|
1636
2009
|
}
|
|
2010
|
+
validateStorageKey(body.key);
|
|
2011
|
+
|
|
2012
|
+
// Security: check signed upload token or write rule
|
|
2013
|
+
const signedClaims = await verifySignedUploadQuery(c, bucketName, body.key);
|
|
2014
|
+
const serviceKeyBypass = signedClaims
|
|
2015
|
+
? false
|
|
2016
|
+
: checkServiceKey(c.env, c.req.header('X-EdgeBase-Service-Key'), `storage:bucket:${bucketName}:write`, c.req);
|
|
2017
|
+
if (!signedClaims && !serviceKeyBypass) {
|
|
2018
|
+
const auth = c.get('auth') as AuthContext | null;
|
|
2019
|
+
checkStorageRule(bucketConfig.access?.write, auth, null, 'write', bucketName, release);
|
|
2020
|
+
}
|
|
1637
2021
|
|
|
1638
2022
|
const fullKey = r2Key(bucketName, body.key);
|
|
1639
2023
|
const multipartUpload = c.env.STORAGE.resumeMultipartUpload(fullKey, body.uploadId);
|