mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -07:00
[studio] prefix assets
This commit is contained in:
+20
-23
@@ -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.',
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user