Files
recflare/apps/storage/src/storage.app.ts
T
2026-07-22 11:43:30 -04:00

202 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import {
AUTHED,
ErrorResponse,
json,
text,
UNAUTHORIZED_RESPONSE,
UPLOAD_REQUEST_BODY,
UploadResponse,
} from './openapi'
import type { App } from './context'
/**
* Storage worker. Handles client file uploads (`POST /upload`) into the shared
* CDN R2 bucket, foldered by the posted `FileType` so the `cdn` worker can serve
* them back.
*/
/**
* The client's `UploadFileType` enum → the R2 subfolder uploads of that type are
* stored under. `Unknown` (0) is intentionally absent: like the referencecdn
* server's `makeUploadName`, an unrecognized type has no destination and is
* rejected rather than stored.
*
* RoomSave (1) lands under `room/` (not `roomsave/`) so the `cdn` worker's
* `GET /room/:dataBlob` route serves the blob back — both bind the same
* `recflare-cdn` bucket, so the key prefixes must match.
*/
const UPLOAD_SUBFOLDER: Record<number, string> = {
1: 'room',
2: 'holotar',
3: 'image',
4: 'video',
5: 'invention',
6: 'roommetadata',
}
/** Resolve the storage subfolder for a posted FileType, or `undefined` when unknown. */
function subfolderForFileType(fileType: string): string | undefined {
return UPLOAD_SUBFOLDER[Number.parseInt(fileType, 10)]
}
/**
* The file extension an upload of a given type keeps. Invention data blobs are
* named `<name>.inv` — the client expects the extension on the `BlobName` it later
* gets back from the api worker, so it has to be part of the stored key too, or the
* blob wouldn't be there to download. Other types are stored under a bare name.
*/
const UPLOAD_EXTENSION: Record<number, string> = {
5: '.inv',
}
function extensionForFileType(fileType: string): string {
return UPLOAD_EXTENSION[Number.parseInt(fileType, 10)] ?? ''
}
/** Read a text form field by any of its accepted names, matched case-insensitively. */
function textField(body: Record<string, unknown>, ...names: string[]): string | undefined {
for (const [key, value] of Object.entries(body)) {
if (typeof value === 'string' && names.includes(key.toLowerCase())) return value
}
return undefined
}
const app = new Hono<App>()
.use(
'*',
// middleware
(c, next) =>
useWorkersLogger(c.env.NAME, {
environment: c.env.ENVIRONMENT,
release: c.env.SENTRY_RELEASE,
})(c, next)
)
.onError(withOnError())
.notFound(withNotFound())
.get(
'/',
describeRoute({
tags: ['Meta'],
summary: 'Health check',
description: 'Plain-text liveness probe. No auth.',
responses: { 200: text('Service is up (`hello, world!`)') },
}),
async (c) => {
return c.text('hello, world!')
}
)
// File upload. Auth-gated — any valid account token is allowed (no role check).
// Multipart form with `FileType` (the client's UploadFileType enum) and a binary
// part. Stores the file in the shared CDN R2 bucket under
// `<type-subfolder>/<upload-date>/<random-name>` and returns the generated name
// (the `<upload-date>/<random-name>` part the `cdn` worker serves back) the client
// references it by. Also accepts a name-only post (no binary) that just echoes
// back an explicit `name`/`filename`/`imagename`. Mirrors the reference `Upload`.
.post(
'/upload',
describeRoute({
tags: ['Upload'],
summary: 'Upload a file',
description: [
'Stores the posted file in the shared CDN R2 bucket under',
'`<type-subfolder>/<upload-date>/<random-name>` and returns the',
'`<upload-date>/<random-name>` part the client references it by (the same name the',
'`cdn` worker serves back). The subfolder comes from `FileType`; RoomSave (1) lands',
'under `room/` so the cdn workers `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',
'an explicit `imageName` / `filename` / `name` just echoes that name back. Mirrors',
'the reference servers `Upload`.',
].join(' '),
security: AUTHED,
requestBody: UPLOAD_REQUEST_BODY,
responses: {
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,
},
}),
async (c) => {
const id = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (id === null) return c.body(null, 401)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// The binary part is identified by being a file (filename/content-type),
// not by its field name — matching the reference's part detection.
const file = Object.values(body).find((v): v is File => v instanceof File)
if (file) {
const fileType = textField(body, 'filetype') ?? '0'
const subfolder = subfolderForFileType(fileType)
if (subfolder === undefined) {
// makeUploadName == "" → no destination for an unknown/missing type.
return c.json({ error: 'missing or unknown FileType' }, 400)
}
// Folder each upload under its date (e.g. `room/2026-02-03/<uuid>`) so the
// bucket stays browsable. The date is part of the returned name, so the key
// the `cdn` worker reads back (`<subfolder>/<name>`) still round-trips — as
// does the extension, which is why it goes on the key, not just the name.
const datePrefix = new Date().toISOString().slice(0, 10)
const filename = `${datePrefix}/${crypto.randomUUID()}${extensionForFileType(fileType)}`
await c.env.CDN_ASSETS.put(`${subfolder}/${filename}`, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'application/octet-stream' },
})
return c.json({ filename })
}
// No binary — accept an explicit name and echo it straight back.
const explicitName = textField(body, 'imagename', 'filename', 'name')
if (explicitName) return c.json({ filename: explicitName })
return c.json({ error: 'missing filename or valid upload data' }, 400)
}
)
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
'/openapi.json',
describeRoute({ hide: true }),
withCleanSpec(
openAPIRouteHandler(app, {
documentation: {
info: {
title: 'recflare storage',
version: '1.0.0',
description: [
'File uploads for recflare, a private-server reimplementation of the Rec Room',
'backend. The client posts room saves, holotars, images, videos, inventions and',
'room metadata here; each lands in the shared CDN R2 bucket under a folder chosen',
'by its `FileType`, and the `cdn` worker serves them back from the same bucket.',
].join('\n'),
},
servers: [{ url: 'https://storage.recflare.net', description: 'Production' }],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'An `access_token` from the auth workers `POST /connect/token`.',
},
},
},
},
})
)
)
export default app