[studio] do not enforce limits for staff

This commit is contained in:
Devin Zuczek
2026-10-15 15:05:21 -04:00
parent 972432d4d3
commit f8a6338eaa
2 changed files with 41 additions and 8 deletions
+20 -6
View File
@@ -9,7 +9,7 @@ import {
withNotFound, withNotFound,
withOnError, withOnError,
} from '@repo/hono-helpers' } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { import {
AUTHED, AUTHED,
@@ -81,6 +81,13 @@ function maxUploadBytes(value: unknown): number {
return configured > 0 ? configured : DEFAULT_MAX_UPLOAD_BYTES 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<string> = new Set(['developer', 'moderator'])
function extensionForFileType(fileType: string): string { function extensionForFileType(fileType: string): string {
return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? '' return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? ''
} }
@@ -125,7 +132,8 @@ const app = new Hono<App>()
} }
) )
// 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 // Multipart form with `FileType` (the client's UploadFileType enum) and a binary
// part. Stores the file in the shared CDN R2 bucket under // part. Stores the file in the shared CDN R2 bucket under
// `<type-subfolder>/<upload-date>/<random-name>` and returns the generated name // `<type-subfolder>/<upload-date>/<random-name>` and returns the generated name
@@ -144,7 +152,7 @@ const app = new Hono<App>()
'`cdn` worker serves back). The subfolder comes from `FileType`; RoomSave (1) lands', '`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', '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 —', '(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', 'an explicit `imageName` / `filename` / `name` just echoes that name back. Mirrors',
'the reference server’s `Upload`.', 'the reference server’s `Upload`.',
].join(' '), ].join(' '),
@@ -154,11 +162,15 @@ const app = new Hono<App>()
200: json(UploadResponse, 'The stored (or echoed) file name'), 200: json(UploadResponse, 'The stored (or echoed) file name'),
400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'), 400: json(ErrorResponse, 'Unknown/missing FileType, or neither a file nor a name'),
401: UNAUTHORIZED_RESPONSE, 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) => { 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) if (id === null) return c.body(null, 401)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>) const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
@@ -168,8 +180,10 @@ const app = new Hono<App>()
const file = Object.values(body).find((v): v is File => v instanceof File) const file = Object.values(body).find((v): v is File => v instanceof File)
if (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) 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) return c.json({ error: `file exceeds the ${limit}-byte upload limit` }, 413)
} }
+21 -2
View File
@@ -25,10 +25,10 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
} }
async function bearer(sub = '42'): Promise<Record<string, string>> { async function bearer(sub = '42', role: string[] = []): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url( 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( const key = await crypto.subtle.importKey(
'raw', '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 () => { it('POST /upload echoes an explicit name when no binary is posted', async () => {
const form = new FormData() const form = new FormData()
form.set('FileType', '3') form.set('FileType', '3')