[studio] prefix assets

This commit is contained in:
Devin Zuczek
2026-10-09 01:37:19 -04:00
parent f9beba0dae
commit ef65b505cc
16 changed files with 384 additions and 410 deletions
+20 -23
View File
@@ -354,10 +354,11 @@ const app = new Hono<App>()
)
// 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.
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
// `unity_asset.filename` records it. Only a recorded filename is served, so this
// checks the table before streaming the key. 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{.+}',
@@ -365,7 +366,7 @@ const app = new Hono<App>()
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',
'200 when `filename` names a stored Studio bundle, 404 otherwise. The',
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
'with GET. No body.',
].join(' '),
@@ -386,12 +387,9 @@ const app = new Hono<App>()
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.',
'on the rooms worker) and is the R2 key the studio upload stored the bytes under.',
'Only a filename recorded in `unity_asset` is served: anything else is a 404, as is',
'a missing table. The worker does not interpret the bytes.',
].join(' '),
parameters: [
keyParam('filename', 'The bundle filename from the room save.', true),
@@ -403,20 +401,19 @@ const app = new Hono<App>()
)
/**
* The R2 key for a Studio bundle filename. Null when the name is unknown, ambiguous,
* or the studio tables have not been migrated yet.
* The R2 key for a Studio bundle filename — the filename itself, once `unity_asset` says
* a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it).
*/
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`)
// Inlined rather than imported so this worker does not take a dependency on the
// domain package.
const row = await db
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
.bind(filename)
.all<{ r2_key: string }>()
if (results.length !== 1) return null
return results[0]!.r2_key
.first<{ stored: number }>()
return row ? filename : null
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null
@@ -476,8 +473,8 @@ app.get(
'`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. `/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.',
'bundle: the filename is the R2 key, streamed once `unity_asset` records it.',
'`HEAD` on that path is 200 when a bundle is stored under 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.',
+4 -3
View File
@@ -6,9 +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.
// Shared `recflare` database, read-only here. Studio bundle downloads check that
// `unity_asset` records the requested filename (which is the R2 key) before
// streaming it. The rooms 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>`.
+25 -22
View File
@@ -286,33 +286,35 @@ 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()
test('GET and HEAD /unityasset/:filename stream a stored studio bundle', async () => {
await env.DB.prepare('DROP TABLE IF EXISTS unity_asset').run()
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
expect(absent.status).toBe(404)
// The rooms worker's table (UNITY_ASSET_SCHEMA_DDL in @repo/domain), inlined like
// the route's own lookup is.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS studio_unity_asset_file (
`CREATE TABLE IF NOT EXISTS unity_asset (
unity_asset_id TEXT NOT NULL,
platform TEXT NOT NULL,
kind TEXT NOT NULL,
target INTEGER NOT NULL,
version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
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)
hash TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, target, version, 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]))
// The filename IS the R2 key.
const filename = 'studio/20930/abc111.windows.main.assetbundle'
await env.DB.prepare(
`INSERT INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
VALUES ('asset-a', 0, 1, 'main', ?1, 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo=')`
)
.bind(filename)
.run()
await env.CDN_ASSETS.put(filename, new Uint8Array([1, 2, 3, 4]))
// A key in the bucket that no row records is not served.
await env.CDN_ASSETS.put('studio/20930/unrecorded.assetbundle', new Uint8Array([9]))
const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)
expect(res.status).toBe(200)
@@ -325,10 +327,11 @@ describe('cdn endpoints', () => {
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/studio/20930/unrecorded.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 () => {