mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[studio] beta studio (#114)
* rrs stuff * studio service yay * rrs whitelist * moree studiooooooo fellowwww * rec room studio room loading yay * fixes yay * fix 2 because idk yayuh * Update cdn.app.ts * ccontent length fix? * Preserve content-length and upgrade metadata Return a real zero-filled byte array for CDN HEAD responses so the runtime preserves Content-Length while still sending no payload. In notify, re-create the Request with its original headers, set OWNER_HEADER from the validated token, and forward that Request to hub.fetch to avoid losing WebSocket upgrade metadata (which could trigger 500s in Durable Objects). These changes ensure the editor's size checks and WebSocket upgrades remain reliable. * fix x524234 * shirt thing? * [auth] remove grant table as we already have audit_log for now * rename table to generic device_grant * cleanup/lint --------- Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
This commit is contained in:
+7
-1
@@ -17,11 +17,17 @@ its path.
|
||||
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
|
||||
scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/`
|
||||
(generic client uploads), `avatar/` (custom avatar item assetbundles) — and served as `application/octet-stream`; the worker never interprets what it hands back.
|
||||
`/unityasset/{filename}` is the exception: a Rec Room Studio room bundle is stored under
|
||||
`studio-room-bundles/`, and the route looks the filename up in `studio_unity_asset_file`
|
||||
(the studio worker owns that table) before streaming the same way. `HEAD` on that path is
|
||||
200 when exactly one bundle has the name.
|
||||
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 `storage`, which writes the same bucket, and
|
||||
images are served by `img`.
|
||||
images are served by `img`. Studio room bundles are uploaded by the `studio` worker into
|
||||
the same bucket. The worker binds the shared D1 database to resolve those filenames and
|
||||
does not migrate it.
|
||||
|
||||
## API documentation
|
||||
|
||||
|
||||
+123
-2
@@ -353,6 +353,105 @@ const app = new Hono<App>()
|
||||
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
||||
)
|
||||
|
||||
// Rec Room Studio room bundles. The filename is the one a room save or
|
||||
// `GET …/unityasset` advertises. The bytes live under the key the studio upload
|
||||
// wrote (`studio-room-bundles/…`), which is not the URL, so this looks the name
|
||||
// up. Reads stay unauthenticated, same as `/avatar/` and `/room/`. HEAD is the
|
||||
// editor's "already uploaded?" check: 200 exists, 404 does not.
|
||||
.on(
|
||||
'HEAD',
|
||||
'/unityasset/:filename{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Check that a Rec Room Studio room bundle exists',
|
||||
description: [
|
||||
'200 when `filename` names exactly one stored Studio bundle, 404 otherwise. The',
|
||||
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
|
||||
'with GET. No body.',
|
||||
].join(' '),
|
||||
parameters: [keyParam('filename', 'The bundle filename from the room save.', true)],
|
||||
responses: {
|
||||
200: { description: 'The bundle exists (no body)' },
|
||||
400: { description: 'The filename contains `..` (no body)' },
|
||||
404: { description: 'No single stored bundle has this filename' },
|
||||
},
|
||||
}),
|
||||
(c) => studioUnityAssetHead(c.env, c.req.param('filename'))
|
||||
)
|
||||
.get(
|
||||
'/unityasset/:filename{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Serve a Rec Room Studio room bundle',
|
||||
description: [
|
||||
'Streams the Studio asset bundle stored for `filename`. The name comes from a',
|
||||
'room save’s `UnitySubAssets` (or from `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`',
|
||||
'on the rooms worker). The object itself lives at the R2 key the studio upload',
|
||||
'recorded, so this route looks the filename up rather than using it as the key.',
|
||||
'',
|
||||
'A filename that matches more than one stored bundle is a 404, so two rooms that',
|
||||
'uploaded the same basename cannot be served as each other. A missing studio table',
|
||||
'is the same 404. The worker does not interpret the bytes.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
keyParam('filename', 'The bundle filename from the room save.', true),
|
||||
...CONDITIONAL_HEADERS,
|
||||
],
|
||||
responses: assetResponses('The asset bundle'),
|
||||
}),
|
||||
(c) => serveUnityAsset(c)
|
||||
)
|
||||
|
||||
/**
|
||||
* The R2 key for a Studio bundle filename. Null when the name is unknown, ambiguous,
|
||||
* or the studio tables have not been migrated yet.
|
||||
*/
|
||||
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
|
||||
try {
|
||||
// Same lookup as `@repo/domain` `findStudioUnityAssetByFilename`. Inlined so this
|
||||
// worker does not take a dependency on the domain package. More than one row is a
|
||||
// miss: two rooms must not be served each other's bundle.
|
||||
const { results } = await db
|
||||
.prepare(`SELECT r2_key FROM studio_unity_asset_file WHERE filename = ?1`)
|
||||
.bind(filename)
|
||||
.all<{ r2_key: string }>()
|
||||
if (results.length !== 1) return null
|
||||
return results[0]!.r2_key
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.toLowerCase().includes('no such table')) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** GET `/unityasset/:filename` — the bytes, with the same range handling as other blobs. */
|
||||
async function serveUnityAsset(c: Context<App>) {
|
||||
const name = c.req.param('filename') ?? ''
|
||||
if (name.includes('..')) return c.body(null, 400)
|
||||
const key = await unityAssetR2Key(c.env.DB, name)
|
||||
if (!key) return c.notFound()
|
||||
return serveAsset(c, key)
|
||||
}
|
||||
|
||||
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
||||
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
||||
if (name.includes('..')) return new Response(null, { status: 400 })
|
||||
const key = await unityAssetR2Key(env.DB, name)
|
||||
if (!key) return new Response(null, { status: 404 })
|
||||
const head = await env.CDN_ASSETS.head(key)
|
||||
if (!head) return new Response(null, { status: 404 })
|
||||
|
||||
const headers = new Headers()
|
||||
head.writeHttpMetadata(headers)
|
||||
headers.set('etag', head.httpEtag)
|
||||
headers.set('accept-ranges', 'bytes')
|
||||
headers.set('cache-control', CACHE_CONTROL)
|
||||
headers.set('access-control-allow-origin', '*')
|
||||
const response = new Response(null, { status: 200, headers, encodeBody: 'manual' })
|
||||
response.headers.set('content-length', String(head.size))
|
||||
return response
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -376,7 +475,9 @@ app.get(
|
||||
'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.',
|
||||
'authenticated call to another worker. `/unityasset/{filename}` is a Studio room',
|
||||
'bundle: the filename is looked up in `studio_unity_asset_file` and the stored R2',
|
||||
'key is streamed. `HEAD` on that path is 200 when exactly one bundle has the name.',
|
||||
'',
|
||||
'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.',
|
||||
@@ -396,4 +497,24 @@ app.get(
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
/**
|
||||
* What the Studio HEAD needs from the environment. Narrower than `Env` on purpose: the
|
||||
* `mono` worker mounts this app with its own (superset) Env, which has no `ASSETS`
|
||||
* binding, and the wrapper below must stay mountable there.
|
||||
*/
|
||||
type StudioAssetEnv = Pick<Env, 'DB' | 'CDN_ASSETS'>
|
||||
|
||||
export default {
|
||||
fetch(
|
||||
request: Request,
|
||||
env: StudioAssetEnv,
|
||||
executionCtx: ExecutionContext
|
||||
): Response | Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
if (request.method === 'HEAD' && url.pathname.startsWith('/unityasset/')) {
|
||||
const filename = decodeURIComponent(url.pathname.slice('/unityasset/'.length))
|
||||
return studioUnityAssetHead(env, filename)
|
||||
}
|
||||
return app.fetch(request, env, executionCtx)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ export type Env = SharedHonoEnv & {
|
||||
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
|
||||
// signed by `auth` verify here.
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// Shared `recflare` database, read-only here. Studio bundle downloads resolve a
|
||||
// public filename to its R2 key through `studio_unity_asset_file`. The studio
|
||||
// worker owns that table's migration; this binding has no migrations_dir.
|
||||
DB: D1Database
|
||||
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
|
||||
// room build data under `room/<name>`.
|
||||
CDN_ASSETS: R2Bucket
|
||||
|
||||
@@ -286,6 +286,51 @@ describe('cdn endpoints', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET and HEAD /unityasset/:filename stream one stored studio bundle', async () => {
|
||||
await env.DB.prepare('DROP TABLE IF EXISTS studio_unity_asset_file').run()
|
||||
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
|
||||
expect(absent.status).toBe(404)
|
||||
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS studio_unity_asset_file (
|
||||
unity_asset_id TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
sha256 TEXT NOT NULL,
|
||||
byte_length INTEGER NOT NULL,
|
||||
r2_key TEXT NOT NULL,
|
||||
PRIMARY KEY (unity_asset_id, platform, kind)
|
||||
)`
|
||||
).run()
|
||||
const filename = 'abc111.windows.main.assetbundle'
|
||||
const key = `studio-room-bundles/asset-a/windows/main/${filename}`
|
||||
const insert = (unityAssetId: string, r2Key: string) =>
|
||||
env.DB.prepare(
|
||||
`INSERT INTO studio_unity_asset_file
|
||||
(unity_asset_id, platform, kind, filename, sha256, byte_length, r2_key)
|
||||
VALUES (?1, 'windows', 'main', ?2, 'ab', 4, ?3)`
|
||||
).bind(unityAssetId, filename, r2Key)
|
||||
await insert('asset-a', key).run()
|
||||
await env.CDN_ASSETS.put(key, new Uint8Array([1, 2, 3, 4]))
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('application/octet-stream')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||
|
||||
const head = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, { method: 'HEAD' })
|
||||
expect(head.status).toBe(200)
|
||||
expect(head.headers.get('content-length')).toBe('4')
|
||||
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
|
||||
|
||||
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
|
||||
expect((await exports.default.fetch(`${ORIGIN}/unityasset/a..b`)).status).toBe(400)
|
||||
|
||||
await insert('asset-b', `studio-room-bundles/asset-b/windows/main/${filename}`).run()
|
||||
expect((await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)).status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -315,6 +360,8 @@ describe('cdn endpoints', () => {
|
||||
'GET /invention/{dataBlob}',
|
||||
'GET /room/{dataBlob}',
|
||||
'GET /sigs/{sigName}',
|
||||
'GET /unityasset/{filename}',
|
||||
'HEAD /unityasset/{filename}',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
"directory": "./static",
|
||||
"run_worker_first": true
|
||||
},
|
||||
// Shared `recflare` DB, read so `/unityasset/:filename` can resolve a Studio
|
||||
// bundle's public filename to the R2 key the studio worker stored. Studio owns
|
||||
// the migration (`apps/studio/migrations`); this binding deliberately has no
|
||||
// migrations_dir, so deploying cdn does not try to create those tables.
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "recflare",
|
||||
"database_id": "local"
|
||||
}
|
||||
],
|
||||
// CDN binaries (signature blobs + room build data) are stored as R2 objects
|
||||
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
|
||||
"r2_buckets": [
|
||||
|
||||
Reference in New Issue
Block a user