mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
34171417e3
* [auth][api] accept the 20250424.01 client Version check now answers "current" for a set of builds rather than one: SUPPORTED_GAME_VERSIONS carries 20230414 and 20250424.01. GAME_VERSION is unchanged and still what the server reports for itself (presence, rn.ver). Adds GET /api/versioncheck/islandedversions, always [] — we never island a build off into its own matchmaking pool. The 2025 build POSTs /cachedlogin/forplatformid/:platform/:id with a deviceId/platformAuth/time form body where the 2023 build GETs it, so that route now takes both methods. The body is accepted and ignored for now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * [2025] unstable * 20250718.0 * correct one this time * stubs * more stubs * more stubs * [lists] add worker * [ai] route stubs * [api] player photo setting * [econ] add roomEconConfig route * [infra] update worker generators * [worker] add cards/moderation/platformnotification workers * [lists] updates to some endpoints * [clubs] stub out announcement endpoint, for now * [econ] stub out season endpoints for now * [chat] apps/chat stub out party endpoint not sure the shape yet * [api] stub out statsig and lockeditems * [doc] new services * [lists] stub the bulk endpoint * [datacollection] add placeholder service until we can kill it * [api] set gifting to lvl5 * update lock * [cdn] enable cache * [match] matchmake v2 * [lists] stub some lists * [ai] stubs * [rooms] new subroom save endpoint * [econ] add bulk purchase endpoint * [discovery] update featured creator to 1 for fun * [api] add photo settings flag * [chat] fixup chat permissions (sorta) * [auth] restrictions endpoint * [rooms] contributed endpoint * [api] fix outfit endpoint * [discovery] attempt to fix store * [chat] privacy endpoints * [api] cheered images * [rooms] add xp endpoint (disbaled) * [rooms] add xp endpoint (disabled) * update images-db for cheers * [rooms] add autocomplete endpoint * [cdn/img] increase cache ttl for statics * [api] bulk route for images * [accounts] add banner image * [api] add misc missing endpoints * [discovery] remove AI tab * [platformnotifications] stub some endpoints * [lists] add some more lists * [rooms] additional endpoints * [chat] stub a few privacy endpoints * [econ] stub some endpoints * misc db fixes * [api] tweak shape for images v6 * [rooms] dont show trending RROs --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
374 lines
16 KiB
TypeScript
374 lines
16 KiB
TypeScript
import { Hono } from 'hono'
|
||
import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
|
||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||
|
||
import {
|
||
withCleanSpec,
|
||
withDefaultCors,
|
||
withNotFound,
|
||
withOnError,
|
||
writeContentRange,
|
||
} from '@repo/hono-helpers'
|
||
|
||
import loadingScreenTipData from '../static/loading-screen-tip-data.json'
|
||
import {
|
||
assetResponses,
|
||
CONDITIONAL_HEADERS,
|
||
json,
|
||
JsonValue,
|
||
keyParam,
|
||
LoadingScreenTip,
|
||
ServiceStatus,
|
||
} from './openapi'
|
||
|
||
import type { Context } from 'hono'
|
||
import type { App, Env } from './context'
|
||
|
||
/**
|
||
* CDN routes. The `cdn` prefix maps to this worker's subdomain, so method routes
|
||
* are served bare. Everything but the liveness probe and the bundled tip data is
|
||
* streamed out of the shared `recflare-cdn` R2 bucket, keyed by prefix.
|
||
*/
|
||
|
||
/**
|
||
* Stream a binary asset from the CDN R2 bucket as application/octet-stream,
|
||
* honoring Range requests. 404s when the file is missing.
|
||
* Supports conditional GET and byte-range requests (206) — large-file
|
||
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
|
||
* reassembled file (e.g. EAC "Signatures don't match").
|
||
*
|
||
* This is why `cache.enabled` is false in wrangler.jsonc: Workers Caching strips `Range`
|
||
* before the worker is invoked and slices the 206 out of its own cache, which silently
|
||
* degrades to a whole-object 200 whenever the response is not cacheable. The range
|
||
* answer has to be ours to guarantee.
|
||
*/
|
||
async function serveAsset(c: Context<App>, key: string) {
|
||
if (key.includes('..')) return c.body(null, 400)
|
||
|
||
const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '')
|
||
// R2 parses the `Range` header itself when handed the request headers, so there is no
|
||
// grammar to reimplement here. It resolves every form (`bytes=a-b`, `bytes=a-`,
|
||
// `bytes=-n`) to a concrete offset/length, and anything it cannot parse or satisfy to
|
||
// the whole object — see the 206 branch, which is what turns that back into a 200.
|
||
// With no `Range` header present this is an ordinary whole-object read.
|
||
let object
|
||
try {
|
||
object = await (c.env as Env).CDN_ASSETS.get(key, {
|
||
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
|
||
range: c.req.raw.headers,
|
||
})
|
||
} catch (e) {
|
||
// Defensive: R2 documents InvalidRange (10039) for a range it can't satisfy, which
|
||
// is a 416 rather than the 500 the error handler would otherwise turn it into.
|
||
// Locally it never fires — workerd resolves an unsatisfiable range to the whole
|
||
// object instead of throwing — so this covers the service behaving as documented.
|
||
if (e instanceof Error && e.message.includes('(10039)')) return c.body(null, 416)
|
||
throw e
|
||
}
|
||
if (!object) return c.notFound()
|
||
|
||
const headers = new Headers()
|
||
object.writeHttpMetadata(headers)
|
||
headers.set('etag', object.httpEtag)
|
||
headers.set('content-type', 'application/octet-stream')
|
||
headers.set('accept-ranges', 'bytes')
|
||
headers.set('cache-control', CACHE_CONTROL)
|
||
|
||
// Precondition matched (If-None-Match) → R2 returns no body.
|
||
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
||
|
||
// A `bytes=` request is ALWAYS answered 206 with a Content-Range naming the bytes
|
||
// actually enclosed — never a bare 200 carrying the whole object. That is the one
|
||
// answer a chunked downloader cannot survive: it asked for a slice, so it writes
|
||
// whatever comes back at that offset, and a whole-object body silently corrupts the
|
||
// reassembled file (EAC "Signatures don't match"). See writeContentRange().
|
||
if (writeContentRange(headers, c.req.raw.headers, object)) {
|
||
return new Response(object.body, { status: 206, headers })
|
||
}
|
||
|
||
return new Response(object.body, { headers })
|
||
}
|
||
|
||
/**
|
||
* Cache-Control on every file this worker serves — 30 days (86400 × 30). These are big,
|
||
* rarely-changing blobs fetched by key: a room scene, an invention, a signature blob. The
|
||
* keys are content-addressed or date-foldered UUIDs, so a changed asset arrives under a
|
||
* NEW key rather than replacing one that is already cached.
|
||
*
|
||
* Not `immutable`, unlike `img`: the same rule covers `/config/`, whose files ARE
|
||
* republished under their existing names, and telling a browser never to revalidate those
|
||
* would pin a stale config for the whole window.
|
||
*/
|
||
const CACHE_CONTROL = `public, max-age=${86400 * 30}`
|
||
|
||
/**
|
||
* What may reach the ASSETS binding as a config filename: one path segment, no slashes,
|
||
* and `..` rejected outright below. A traversal is then a 404 from this worker rather than
|
||
* a request the asset server has to be trusted to refuse.
|
||
*/
|
||
const CONFIG_NAME = /^[A-Za-z0-9._-]+$/
|
||
|
||
/**
|
||
* Serve a file from `static/config/` through the ASSETS binding, by its own filename —
|
||
* whatever is in that directory, not just the JSON (a config may be an opaque binary blob
|
||
* named by GUID). `null` when nothing is published under that name.
|
||
*
|
||
* A name carrying no extension also resolves against `<name>.json`, because the same file
|
||
* is asked for both ways: the game configs that point at these carry the extension
|
||
* (`Econ.MakerAI.DayPass.Config` is `"SkuConfig_v1.json"`) while the client's older config
|
||
* calls leave it off. The exact name is tried first, so an extension-less FILE always wins
|
||
* over the `.json` guess.
|
||
*
|
||
* The asset response is handed back whole rather than parsed and re-serialized: it already
|
||
* carries a content type and an etag (so `If-None-Match` gets its 304 for free), and these
|
||
* files go out BYTE-FOR-BYTE — `RRPlusConfig_v3.json` opens with a UTF-8 BOM, which is what
|
||
* the real CDN served and what the client's parser expects.
|
||
*/
|
||
async function serveConfig(c: Context<App>, name: string): Promise<Response | null> {
|
||
if (!CONFIG_NAME.test(name) || name.includes('..')) return null
|
||
|
||
const candidates = name.includes('.') ? [name] : [name, `${name}.json`]
|
||
for (const candidate of candidates) {
|
||
// Forwarding the original request keeps its conditional headers; only the URL is
|
||
// rewritten to the asset's path.
|
||
const res = await c.env.ASSETS.fetch(
|
||
new Request(new URL(`/config/${candidate}`, c.req.url), c.req.raw)
|
||
)
|
||
// Rebuilt rather than returned as-is, only to stamp our own Cache-Control over the
|
||
// asset server's: everything else — the body, the status, the content type, the etag
|
||
// a conditional GET matched on — is carried across untouched. A 304 has no body to
|
||
// carry, and `Response` refuses one for that status.
|
||
if (res.ok || res.status === 304) {
|
||
const headers = new Headers(res.headers)
|
||
headers.set('cache-control', CACHE_CONTROL)
|
||
return new Response(res.status === 304 ? null : res.body, { status: res.status, headers })
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
const app = new Hono<App>()
|
||
.use(
|
||
'*',
|
||
// middleware
|
||
(c, next) =>
|
||
useWorkersLogger(c.env.NAME, {
|
||
environment: c.env.ENVIRONMENT,
|
||
release: c.env.SENTRY_RELEASE,
|
||
})(c, next)
|
||
)
|
||
|
||
// The website lets a room's owner download their own scene blobs (see the room page
|
||
// in `www`), which means a browser reading these bytes from another origin — without
|
||
// these headers it can fetch them but not touch the result. `origin: '*'` gives away
|
||
// nothing: every route here is already unauthenticated and public to anyone holding
|
||
// the key, and nothing on this worker reads a cookie or a token, so there is no
|
||
// ambient credential for `*` to expose. The keys are unguessable UUIDs, and that is
|
||
// unchanged by who may read a response they already had to name exactly.
|
||
.use('*', withDefaultCors())
|
||
|
||
.onError(withOnError())
|
||
.notFound(withNotFound())
|
||
|
||
.get(
|
||
'/',
|
||
describeRoute({
|
||
tags: ['Service'],
|
||
summary: 'Service liveness',
|
||
description: 'A fixed `{ service, status }` body. No auth — a plain liveness probe.',
|
||
responses: { 200: json(ServiceStatus, 'Always `{ service: "cdn", status: "ok" }`') },
|
||
}),
|
||
(c) => c.json({ service: 'cdn', status: 'ok' })
|
||
)
|
||
|
||
// Loading-screen tips, bundled here as static JSON.
|
||
.get(
|
||
'/config/LoadingScreenTipData',
|
||
describeRoute({
|
||
tags: ['Config'],
|
||
summary: 'Loading-screen tips',
|
||
description: [
|
||
'The tips the client cycles through on a loading screen. A bundled static file',
|
||
'(`static/loading-screen-tip-data.json`), captured from the real service and served',
|
||
'verbatim — nothing here is editable at runtime, and every client gets the same list',
|
||
'regardless of platform or room. The per-tip `Context`/`Visibility`/`PlatformMask`',
|
||
'fields are the client’s own filters, applied client-side.',
|
||
].join(' '),
|
||
responses: { 200: json(LoadingScreenTip.array(), 'The bundled tips') },
|
||
}),
|
||
(c) => c.json(loadingScreenTipData, 200, { 'Cache-Control': CACHE_CONTROL })
|
||
)
|
||
|
||
// Everything else under `/config/`, served from `static/config/` by filename — JSON and
|
||
// opaque blobs alike. Declared AFTER the tip-data route above, which would otherwise be
|
||
// shadowed by this one: its file is named differently from its path, so it stays a
|
||
// route of its own.
|
||
.get(
|
||
'/config/:name',
|
||
describeRoute({
|
||
tags: ['Config'],
|
||
summary: 'Serve a config file',
|
||
description: [
|
||
'Serves a file out of `static/config/` verbatim — `RRPlusConfig_v3.json` (the Rec Room',
|
||
'Plus benefit lists), `SkuConfig_v1.json` (the Maker AI day-pass store copy) and a',
|
||
'GUID-named binary blob today. `{name}` IS the filename, so publishing a config is',
|
||
'dropping a file in that directory; nothing in the worker enumerates them, and not',
|
||
'everything there is JSON.',
|
||
'',
|
||
'A name with no extension also resolves against `<name>.json`, because the same file',
|
||
'is asked for both ways — the game configs that point at these carry the extension',
|
||
'(`Econ.MakerAI.DayPass.Config` is `"SkuConfig_v1.json"`), the client’s older config',
|
||
'calls leave it off. An extension-less file wins over the `.json` guess.',
|
||
'',
|
||
'These are byte-for-byte copies of what the real CDN served, BOM included, and are',
|
||
'not rewritten or re-serialized on the way out.',
|
||
].join(' '),
|
||
parameters: [
|
||
keyParam('name', 'The config’s filename. The `.json` may be left off.', false),
|
||
...CONDITIONAL_HEADERS.filter((h) => h.name === 'If-None-Match'),
|
||
],
|
||
responses: {
|
||
200: {
|
||
description: 'The config file, as stored',
|
||
content: {
|
||
'application/json': { schema: resolver(JsonValue) },
|
||
'application/octet-stream': { schema: { type: 'string', format: 'binary' } },
|
||
},
|
||
},
|
||
304: { description: '`If-None-Match` matched the file’s etag (no body)' },
|
||
404: { description: 'No config is published under that name' },
|
||
},
|
||
}),
|
||
async (c) => (await serveConfig(c, c.req.param('name'))) ?? c.notFound()
|
||
)
|
||
|
||
// Signature blobs by name. Streamed from R2 under the `sigs/` key prefix;
|
||
// 404 when missing.
|
||
.get(
|
||
'/sigs/:sigName',
|
||
describeRoute({
|
||
tags: ['Assets'],
|
||
summary: 'Serve a signature blob',
|
||
description: [
|
||
'Streams the object stored under `sigs/<sigName>`. These are the anti-cheat signature',
|
||
'blobs the client fetches at startup; nothing here inspects or validates them.',
|
||
].join(' '),
|
||
parameters: [keyParam('sigName', 'The blob name.', false), ...CONDITIONAL_HEADERS],
|
||
responses: assetResponses('The signature blob'),
|
||
}),
|
||
(c) => serveAsset(c, `sigs/${c.req.param('sigName')}`)
|
||
)
|
||
|
||
// Room build data by name. The client fetches this for a SubRoom's DataBlob to
|
||
// load the room. Streamed from R2 under `room/`. The name may contain slashes
|
||
// (uploads are foldered by date, e.g. `2026-02-03/<uuid>`), so match the rest of
|
||
// the path.
|
||
.get(
|
||
'/room/:dataBlob{.+}',
|
||
describeRoute({
|
||
tags: ['Assets'],
|
||
summary: 'Serve room build data',
|
||
description: [
|
||
'Streams the object stored under `room/<dataBlob>` — the saved scene the client',
|
||
'downloads to load a room. The name comes from a subroom’s `DataBlob` (see the `rooms`',
|
||
'worker) and is date-foldered by the upload, e.g. `2026-02-03/<uuid>`, so it contains',
|
||
'slashes.',
|
||
'',
|
||
'A room’s IMAGE also lives under this prefix, stored by its bare `ImageName` — the',
|
||
'same route serves both.',
|
||
].join('\n'),
|
||
parameters: [keyParam('dataBlob', 'The blob name.', true), ...CONDITIONAL_HEADERS],
|
||
responses: assetResponses('The room data'),
|
||
}),
|
||
(c) => serveAsset(c, `room/${c.req.param('dataBlob')}`)
|
||
)
|
||
|
||
// Invention data by name. The client fetches this for an invention's
|
||
// `CurrentVersion.BlobName` to spawn it. Streamed from R2 under `invention/`.
|
||
// Like room blobs the name is date-foldered, and it carries the `.inv` extension
|
||
// the upload stored it under, so the rest of the path is matched as-is.
|
||
.get(
|
||
'/invention/:dataBlob{.+}',
|
||
describeRoute({
|
||
tags: ['Assets'],
|
||
summary: 'Serve invention data',
|
||
description: [
|
||
'Streams the object stored under `invention/<dataBlob>` — the data the client',
|
||
'downloads to spawn an invention. The name comes from an invention’s',
|
||
'`CurrentVersion.BlobName` (see the `api` worker); like room blobs it is date-foldered,',
|
||
'and it keeps the `.inv` extension the upload stored it under.',
|
||
].join(' '),
|
||
parameters: [
|
||
keyParam('dataBlob', 'The blob name, including `.inv`.', true),
|
||
...CONDITIONAL_HEADERS,
|
||
],
|
||
responses: assetResponses('The invention data'),
|
||
}),
|
||
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
|
||
)
|
||
|
||
// Generic client data by name. Anything the client uploads as FileType 2 lands
|
||
// under `data/` (a Holotar recording is the one seen in the wild) and the client
|
||
// fetches it back from this prefix. Date-foldered like the room and invention
|
||
// blobs, so the rest of the path is matched as-is.
|
||
.get(
|
||
'/data/:id{.+}',
|
||
describeRoute({
|
||
tags: ['Assets'],
|
||
summary: 'Serve a client data blob',
|
||
description: [
|
||
'Streams the object stored under `data/<id>` — whatever the client uploaded as',
|
||
'`UploadFileType` 2 (see the `storage` worker), a Holotar recording being the case',
|
||
'observed. Like room and invention blobs the name is date-foldered by the upload,',
|
||
'e.g. `2026-02-03/<uuid>`, so it contains slashes. The worker does not interpret the',
|
||
'bytes — the prefix exists because the client expects to read these back from `/data/`.',
|
||
].join(' '),
|
||
parameters: [keyParam('id', 'The blob name.', true), ...CONDITIONAL_HEADERS],
|
||
responses: assetResponses('The data blob'),
|
||
}),
|
||
(c) => serveAsset(c, `data/${c.req.param('id')}`)
|
||
)
|
||
|
||
// 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 cdn',
|
||
version: '1.0.0',
|
||
description: [
|
||
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
||
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
||
'signatures, saved room scenes, invention data and generic client uploads — out of',
|
||
'the shared `recflare-cdn` R2 bucket, plus the JSON config files the client reads',
|
||
'from `/config/`.',
|
||
'',
|
||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
|
||
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
|
||
'are unauthenticated — a caller needs the exact key, which only comes from an',
|
||
'authenticated call to another worker.',
|
||
'',
|
||
'This worker only READS. Uploads go through the `storage` worker, which writes the',
|
||
'same bucket, and images are served by `img` rather than from here.',
|
||
'',
|
||
'Every asset route supports conditional GETs (`If-None-Match` → 304) and single',
|
||
'byte ranges (`Range` → 206). The ranges matter: large-file downloaders fetch in',
|
||
'chunks, and answering 200 where a 206 is expected corrupts the reassembled file —',
|
||
'which surfaces as an anti-cheat “Signatures don’t match” failure, not a download',
|
||
'error. So a `bytes=` request is never answered with a whole-object 200: the 206',
|
||
'always carries a `Content-Range` stating which bytes the body holds, even where',
|
||
'that turns out to be all of them.',
|
||
].join('\n'),
|
||
},
|
||
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
|
||
},
|
||
})
|
||
)
|
||
)
|
||
|
||
export default app
|