diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 883c661..1b49521 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -9,7 +9,7 @@ import { withNotFound, withOnError, } from '@repo/hono-helpers' -import { validateAndGetAccountId } from '@repo/jwt' +import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt' import { AUTHED, @@ -81,6 +81,13 @@ function maxUploadBytes(value: unknown): number { return configured > 0 ? configured : DEFAULT_MAX_UPLOAD_BYTES } +/** + * Operator-granted elevated roles (the auth worker stamps them from an account's + * isDeveloper/isModerator flags). Same set `rooms` treats as staff. Staff uploads skip + * the size limit: they load first-party content by hand that can outgrow it. + */ +const STAFF_ROLES: ReadonlySet = new Set(['developer', 'moderator']) + function extensionForFileType(fileType: string): string { return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? '' } @@ -125,7 +132,8 @@ const app = new Hono() } ) - // File upload. Auth-gated — any valid account token is allowed (no role check). + // File upload. Auth-gated — any valid account token is allowed (no role check), but + // staff (developer/moderator) skip the size limit. // Multipart form with `FileType` (the client's UploadFileType enum) and a binary // part. Stores the file in the shared CDN R2 bucket under // `//` and returns the generated name @@ -144,7 +152,7 @@ const app = new Hono() '`cdn` worker serves back). The subfolder comes from `FileType`; RoomSave (1) lands', 'under `room/` so the cdn worker’s `GET /room/:dataBlob` finds it, and an Invention', '(5) keeps a `.inv` extension on both the key and the returned name. Auth-gated —', - 'any valid account token is allowed, no role check. A post with no binary part but', + 'any valid account token is allowed, no role check; staff (developer/moderator) skip the size limit. A post with no binary part but', 'an explicit `imageName` / `filename` / `name` just echoes that name back. Mirrors', 'the reference server’s `Upload`.', ].join(' '), @@ -154,11 +162,15 @@ const app = new Hono() 200: json(UploadResponse, 'The stored (or echoed) file name'), 400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'), 401: UNAUTHORIZED_RESPONSE, - 413: json(ErrorResponse, 'The binary file exceeds the configured upload limit'), + 413: json( + ErrorResponse, + 'The binary file exceeds the configured upload limit (staff are exempt)' + ), }, }), async (c) => { - const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get()) + const secret = await c.env.JWT_SECRET.get() + const id = await validateAndGetAccountId(c.req.raw, secret) if (id === null) return c.body(null, 401) const body = await c.req.parseBody().catch(() => ({}) as Record) @@ -168,8 +180,10 @@ const app = new Hono() const file = Object.values(body).find((v): v is File => v instanceof File) if (file) { + const roles = await validateAndGetRoles(c.req.raw, secret) + const isStaff = roles?.some((role) => STAFF_ROLES.has(role)) ?? false const limit = maxUploadBytes(c.env.MAX_UPLOAD_BYTES) - if (file.size > limit) { + if (!isStaff && file.size > limit) { return c.json({ error: `file exceeds the ${limit}-byte upload limit` }, 413) } diff --git a/apps/storage/src/test/integration/api.test.ts b/apps/storage/src/test/integration/api.test.ts index cf3fe66..c82729a 100644 --- a/apps/storage/src/test/integration/api.test.ts +++ b/apps/storage/src/test/integration/api.test.ts @@ -25,10 +25,10 @@ function b64url(input: ArrayBuffer | string): string { return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } -async function bearer(sub = '42'): Promise> { +async function bearer(sub = '42', role: string[] = []): Promise> { const now = Math.floor(Date.now() / 1000) const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( - JSON.stringify({ sub, exp: now + 3600 }) + JSON.stringify({ sub, role, exp: now + 3600 }) )}` const key = await crypto.subtle.importKey( 'raw', @@ -153,6 +153,25 @@ it('POST /upload rejects a binary above the configured size limit without storin } }) +it('POST /upload lets staff upload past the size limit', async () => { + const original = env.MAX_UPLOAD_BYTES + env.MAX_UPLOAD_BYTES = '3' + try { + for (const role of ['developer', 'moderator']) { + const res = await SELF.fetch(`${ORIGIN}/upload`, { + method: 'POST', + headers: await bearer('42', ['gameClient', role]), + body: uploadForm('3', new Uint8Array([1, 2, 3, 4])), + }) + expect(res.status).toBe(200) + const { filename } = (await res.json()) as { filename: string } + expect((await env.CDN_ASSETS.get(`image/${filename}`))?.size).toBe(4) + } + } finally { + env.MAX_UPLOAD_BYTES = original + } +}) + it('POST /upload echoes an explicit name when no binary is posted', async () => { const form = new FormData() form.set('FileType', '3')