mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[studio] do not enforce limits for staff
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user