mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] support for mitm
This commit is contained in:
@@ -989,6 +989,35 @@ export const UnityAssetWithSourceDto = z.object({
|
||||
hash: z.string().describe('Base64 SHA-256 of `filename`'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /unity_assets/baked/bulk` — form-encoded. `id` is REPEATED, once per unity asset
|
||||
* (`target=0&version=3&id=<guid>&id=<guid>`); a comma-separated single value is accepted too.
|
||||
*/
|
||||
export const BakedUnityAssetBulkRequest = z.object({
|
||||
target: z.string().describe('The build target wanted: 0 Windows, 2 Android/Quest'),
|
||||
version: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The asset version the client wants — preferred when an asset has several builds'),
|
||||
id: z.array(z.string()).describe('The unity asset GUIDs, repeated once per id'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One baked bundle as `POST /unity_assets/baked/bulk` lists it — the client's five-key DTO
|
||||
* in its order, PascalCase (unlike the editor's camelCase `bakedUnityAssets` above). The
|
||||
* client downloads `Filename` from the CDN; an entry without one downloads nothing.
|
||||
*/
|
||||
export const BakedUnityAssetBulkDto = z.object({
|
||||
UnityAssetId: z.string().describe('GUID, as asked for'),
|
||||
Target: z.int().describe('0 Windows, 2 Android/Quest — the target asked for'),
|
||||
Version: z.int().describe('The build’s version, which need not be the one asked for'),
|
||||
Filename: z.string().describe('The storage blob path, `<date>/<guid>`'),
|
||||
Hash: z.string().describe('Base64 SHA-256 of the blob'),
|
||||
})
|
||||
|
||||
/** The bare array `POST /unity_assets/baked/bulk` answers — `[]` when nothing is stored. */
|
||||
export const BakedUnityAssetBulkList = z.array(BakedUnityAssetBulkDto)
|
||||
|
||||
// ---- Session ---------------------------------------------------------------
|
||||
|
||||
/** One entry of the permission table the client applies when it spawns into a room. */
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
deleteSubRoom,
|
||||
findSubRoom,
|
||||
getAccount,
|
||||
getBakedUnityAssets,
|
||||
getBaseRooms,
|
||||
getContributedRooms,
|
||||
getFavoritedRooms,
|
||||
@@ -99,6 +100,8 @@ import { NotificationType } from '../../notify/src/notification-types'
|
||||
import {
|
||||
AccessibilityRequest,
|
||||
AUTHED,
|
||||
BakedUnityAssetBulkList,
|
||||
BakedUnityAssetBulkRequest,
|
||||
bannedPlayerIdParam,
|
||||
BanRequest,
|
||||
BulkRoomsRequest,
|
||||
@@ -4503,6 +4506,59 @@ const app = new Hono<App>()
|
||||
})
|
||||
)
|
||||
|
||||
// The baked asset bundles behind a room load. The client posts the unity asset ids its
|
||||
// saves name, with the target it runs on and the asset version it wants, and gets back
|
||||
// a BARE ARRAY of blob references — `Filename` is what it then downloads from the CDN.
|
||||
// Form-encoded, `id` repeated once per GUID. No auth, like `…/unityasset`: the room
|
||||
// document already names these files, and a 401 here would only make the room load
|
||||
// nothing with no error anywhere — which is also what `[]` does, so an asset with no
|
||||
// stored build is simply left out rather than answered with a blank row.
|
||||
.post(
|
||||
'/unity_assets/baked/bulk',
|
||||
describeRoute({
|
||||
tags: ['Subrooms'],
|
||||
summary: 'The baked bundles for unity assets',
|
||||
description: [
|
||||
'The stored builds of the unity assets named by `id` (repeated once per GUID) on',
|
||||
'`target` (0 Windows, 2 Android/Quest), as a bare array of',
|
||||
'`{ UnityAssetId, Target, Version, Filename, Hash }` — one entry per asset that has a',
|
||||
'build, in the order asked; `[]` when none does. `Filename` is the blob the client',
|
||||
'downloads from the CDN, so an entry is never served without one.',
|
||||
'',
|
||||
'`version` is a preference, not a filter: an asset built more than once answers the',
|
||||
'build at that version when there is one and its newest otherwise. A body naming no',
|
||||
'ids, or a non-integer `target`, answers `[]`.',
|
||||
].join(' '),
|
||||
requestBody: form(BakedUnityAssetBulkRequest, 'The assets wanted, and for which target'),
|
||||
responses: {
|
||||
200: json(BakedUnityAssetBulkList, 'The baked bundles, one per asset found'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req
|
||||
.parseBody({ all: true })
|
||||
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
|
||||
const first = (v: unknown): string => {
|
||||
const one = Array.isArray(v) ? v[0] : v
|
||||
return typeof one === 'string' ? one.trim() : ''
|
||||
}
|
||||
const target = Number.parseInt(first(body.target), 10)
|
||||
if (!Number.isInteger(target)) return c.json([])
|
||||
// Absent or unparseable, the version prefers nothing and the newest build is served.
|
||||
const version = Number.parseInt(first(body.version), 10)
|
||||
// `all: true` keeps the repeated field a list; a single value arrives as a string.
|
||||
const ids = [body.id, body.ids]
|
||||
.flat()
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.flatMap((v) => v.split(','))
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v !== '')
|
||||
return c.json(
|
||||
await getBakedUnityAssets(c.env.DB, ids, target, Number.isInteger(version) ? version : -1)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Photon access token + room permissions the client needs to spawn into a room.
|
||||
.get(
|
||||
'/photon_access_token',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
NOTIFICATION_SCHEMA_DDL,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
publicStudioBundleFilename,
|
||||
putUnityAsset,
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
ROOM_INVITE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
sha256HexToBase64,
|
||||
STUDIO_UNITY_ASSET_SCHEMA_DDL,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||
@@ -128,6 +130,8 @@ beforeAll(async () => {
|
||||
// Inventions (owned by the api worker) — a room save writes each one's room count.
|
||||
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
|
||||
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Seed each room and split its subrooms into the subroom table (mirrors 0007's backfill).
|
||||
for (const r of importRooms) await seedRoomWithSubRooms(env.DB, r as Record<string, unknown>)
|
||||
|
||||
@@ -5469,6 +5473,120 @@ describe('rooms endpoints', () => {
|
||||
expect((await get(detail, '999')).status).toBe(403)
|
||||
})
|
||||
|
||||
describe('POST /unity_assets/baked/bulk', () => {
|
||||
const URL = `${ORIGIN}/unity_assets/baked/bulk`
|
||||
const post = (body: string) =>
|
||||
SELF.fetch(URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const A = '4f0c1e4a-2b77-4b16-9a3e-5dbcfd7f2865'
|
||||
const B = '9a3e5dbc-fd7f-2865-8f0c-1e4a2b774b16'
|
||||
const C = '22555d14-2918-43f3-94d4-da0c893d96c4'
|
||||
|
||||
beforeAll(async () => {
|
||||
await putUnityAsset(env.DB, {
|
||||
UnityAssetId: A,
|
||||
Target: 0,
|
||||
Version: 227,
|
||||
Filename: '2026-10-05/a0d83019-675d-41c0-8924-c238c5a12ee8',
|
||||
Hash: 'R0SVMvrAW4n1TIF2lxaOAQZ5XXVJh7prEP6GpiLCrQo=',
|
||||
})
|
||||
await putUnityAsset(env.DB, {
|
||||
UnityAssetId: A,
|
||||
Target: 2,
|
||||
Version: 227,
|
||||
Filename: '2026-10-05/quest-a',
|
||||
Hash: 'quest-a-hash',
|
||||
})
|
||||
// B was built twice for Windows.
|
||||
await putUnityAsset(env.DB, {
|
||||
UnityAssetId: B,
|
||||
Target: 0,
|
||||
Version: 3,
|
||||
Filename: '2026-10-05/b-v3',
|
||||
Hash: 'b-v3-hash',
|
||||
})
|
||||
await putUnityAsset(env.DB, {
|
||||
UnityAssetId: B,
|
||||
Target: 0,
|
||||
Version: 5,
|
||||
Filename: '2026-10-05/b-v5',
|
||||
Hash: 'b-v5-hash',
|
||||
})
|
||||
})
|
||||
|
||||
it('answers a bare array of five-key entries for the ids asked, in order', async () => {
|
||||
const res = await post(`target=0&version=227&id=${B}&id=${A}`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Array<Record<string, unknown>>
|
||||
expect(body.map((e) => e.UnityAssetId)).toEqual([B, A])
|
||||
expect(Object.keys(body[1]!)).toEqual([
|
||||
'UnityAssetId',
|
||||
'Target',
|
||||
'Version',
|
||||
'Filename',
|
||||
'Hash',
|
||||
])
|
||||
expect(body[1]).toEqual({
|
||||
UnityAssetId: A,
|
||||
Target: 0,
|
||||
Version: 227,
|
||||
Filename: '2026-10-05/a0d83019-675d-41c0-8924-c238c5a12ee8',
|
||||
Hash: 'R0SVMvrAW4n1TIF2lxaOAQZ5XXVJh7prEP6GpiLCrQo=',
|
||||
})
|
||||
})
|
||||
|
||||
it('serves the build for the target asked, and leaves out assets with none', async () => {
|
||||
// C has no build at all, so no blank row: the client downloads nothing for it either
|
||||
// way, and a row with no Filename is the quieter failure.
|
||||
const quest = (await (
|
||||
await post(`target=2&version=227&id=${A}&id=${C}&id=${B}`)
|
||||
).json()) as Array<Record<string, unknown>>
|
||||
expect(quest).toEqual([
|
||||
{
|
||||
UnityAssetId: A,
|
||||
Target: 2,
|
||||
Version: 227,
|
||||
Filename: '2026-10-05/quest-a',
|
||||
Hash: 'quest-a-hash',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('prefers the build at the requested version and falls back to the newest', async () => {
|
||||
const exact = (await (await post(`target=0&version=3&id=${B}`)).json()) as Array<{
|
||||
Version: number
|
||||
}>
|
||||
expect(exact.map((e) => e.Version)).toEqual([3])
|
||||
// A version nothing was built at still answers the asset — its newest build.
|
||||
const newest = (await (await post(`target=0&version=4&id=${B}`)).json()) as Array<{
|
||||
Version: number
|
||||
}>
|
||||
expect(newest.map((e) => e.Version)).toEqual([5])
|
||||
// No version at all: the newest.
|
||||
const none = (await (await post(`target=0&id=${B}`)).json()) as Array<{ Version: number }>
|
||||
expect(none.map((e) => e.Version)).toEqual([5])
|
||||
})
|
||||
|
||||
it('matches ids case-insensitively and accepts a comma-separated list', async () => {
|
||||
const body = (await (
|
||||
await post(`target=0&version=227&id=${A.toUpperCase()},${B}`)
|
||||
).json()) as Array<{
|
||||
UnityAssetId: string
|
||||
}>
|
||||
expect(body.map((e) => e.UnityAssetId)).toEqual([A, B])
|
||||
})
|
||||
|
||||
it('answers [] for no ids, an unknown id, or a non-integer target', async () => {
|
||||
expect(await (await post('target=0&version=227')).json()).toEqual([])
|
||||
expect(await (await post(`target=0&version=227&id=${C}`)).json()).toEqual([])
|
||||
expect(await (await post(`target=win&version=227&id=${A}`)).json()).toEqual([])
|
||||
expect(await (await post('')).json()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('GET /openapi.json documents every route', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -5544,6 +5662,7 @@ describe('rooms endpoints', () => {
|
||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/data',
|
||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/move',
|
||||
'POST /rooms/{roomId}/subrooms/{subRoomId}/publish_save',
|
||||
'POST /unity_assets/baked/bulk',
|
||||
'PUT /rooms/{roomId}/accessibility',
|
||||
'PUT /rooms/{roomId}/cloning',
|
||||
'PUT /rooms/{roomId}/creator',
|
||||
|
||||
Reference in New Issue
Block a user