[www] beta room uploads (#34)

* WIP room uploads

* add beta warning
This commit is contained in:
devin
2026-08-15 13:14:40 -04:00
committed by GitHub
parent d12806625d
commit 66c09806f9
7 changed files with 337 additions and 5 deletions
+6 -1
View File
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi' import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger' import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers' import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt' import { validateAndGetAccountId } from '@repo/jwt'
import { import {
@@ -79,6 +79,11 @@ const app = new Hono<App>()
release: c.env.SENTRY_RELEASE, release: c.env.SENTRY_RELEASE,
})(c, next) })(c, next)
) )
// The game posts here with no Origin at all, but the website does too — it uploads a
// subroom's scene blob straight from the browser, the same way it calls `rooms` and
// `accounts` directly. An `Authorization` header makes that a preflighted request, so
// without this the OPTIONS gets a 404 and the upload never leaves the page.
.use('*', withDefaultCors())
.onError(withOnError()) .onError(withOnError())
.notFound(withNotFound()) .notFound(withNotFound())
@@ -160,6 +160,23 @@ it('POST /upload 400s when there is neither a file nor a name', async () => {
expect(res.status).toBe(400) expect(res.status).toBe(400)
}) })
it('answers the CORS preflight the websites upload needs', async () => {
// The room management page uploads a subroom's scene blob straight from the browser.
// The bearer token makes that a preflighted request, so a missing OPTIONS handler
// stops the upload before any of the tests above are even reached.
const res = await SELF.fetch(`${ORIGIN}/upload`, {
method: 'OPTIONS',
headers: {
Origin: 'https://www.example.com',
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'authorization',
},
})
expect(res.status).toBe(204)
expect(res.headers.get('access-control-allow-origin')).toBe('*')
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain('authorization')
})
it('GET /openapi.json documents every route', async () => { it('GET /openapi.json documents every route', async () => {
const res = await SELF.fetch(`${ORIGIN}/openapi.json`) const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
+235 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Accessibility } from '@repo/domain/src/enums' import { Accessibility } from '@repo/domain/src/enums'
import { GAME_VERSION } from '@repo/domain/src/presence-db'
import { NotificationType } from '../../../notify/src/notification-types' import { NotificationType } from '../../../notify/src/notification-types'
import { authFailure, authUnreachable } from '../auth-messages' import { authFailure, authUnreachable } from '../auth-messages'
@@ -34,6 +35,7 @@ interface Hosts {
notify: string notify: string
rooms: string rooms: string
cdn: string cdn: string
storage: string
} }
/** /**
@@ -237,6 +239,12 @@ interface CallOptions {
form?: Record<string, string> form?: Record<string, string>
/** A JSON body — what notify's internal endpoints take instead. */ /** A JSON body — what notify's internal endpoints take instead. */
json?: unknown json?: unknown
/**
* A multipart body — what `storage`'s `/upload` takes, since it carries a file. Passed
* to `fetch` as-is: the browser writes the `content-type` itself, because only it
* knows the boundary it generated.
*/
multipart?: FormData
/** Send the session token. */ /** Send the session token. */
authed?: boolean authed?: boolean
/** /**
@@ -251,13 +259,16 @@ interface CallOptions {
async function call<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> { async function call<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> {
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
if (opts.authed && token) headers.authorization = `Bearer ${token}` if (opts.authed && token) headers.authorization = `Bearer ${token}`
let body: string | undefined let body: string | FormData | undefined
if (opts.form) { if (opts.form) {
headers['content-type'] = 'application/x-www-form-urlencoded' headers['content-type'] = 'application/x-www-form-urlencoded'
body = new URLSearchParams(opts.form).toString() body = new URLSearchParams(opts.form).toString()
} else if (opts.json !== undefined) { } else if (opts.json !== undefined) {
headers['content-type'] = 'application/json' headers['content-type'] = 'application/json'
body = JSON.stringify(opts.json) body = JSON.stringify(opts.json)
} else if (opts.multipart) {
// Deliberately no content-type: setting one would omit the boundary.
body = opts.multipart
} }
const res = await fetch(url, { const res = await fetch(url, {
@@ -308,6 +319,93 @@ async function fetchMyRooms(): Promise<OwnedRoom[]> {
return [...rooms].sort((a, b) => (a.CreatedAt < b.CreatedAt ? 1 : -1)) return [...rooms].sort((a, b) => (a.CreatedAt < b.CreatedAt ? 1 : -1))
} }
/**
* The `UploadFileType` a room's scene data is posted under. `storage` maps this to the
* `room/` subfolder of the CDN bucket — the one prefix `cdn`'s `GET /room/:dataBlob`
* reads back, and so the only one a `DataBlob` key can point into.
*/
const FILE_TYPE_ROOM_SAVE = '1'
/**
* The game build this server targets, as `YYYY-MM-DD` — read from the same `GAME_VERSION`
* the auth token and presence carry rather than written out again here, so upgrading the
* client moves this line with it instead of leaving a stale date on the upload form.
*
* It's shown because a scene blob is only loadable by the build that wrote it (or older
* ones that understand it): a save taken out of a room built on a later version can fail
* outright, and nothing between here and the game says why.
*/
const CLIENT_BUILD_DATE = `${GAME_VERSION.slice(0, 4)}-${GAME_VERSION.slice(4, 6)}-${GAME_VERSION.slice(6, 8)}`
/**
* Upload a scene blob to `storage` and return the key it was stored under — the
* `<date>/<uuid>` name every `DataBlob` field holds.
*
* This is the same two-step the game does: the bytes go to `storage` first, and only its
* generated name is handed to `rooms`. Nothing about the file is inspected here — a room
* blob is an opaque Unity payload, and the server doesn't parse it either, so the only
* honest validation available is whether the game can load it afterwards.
*/
async function uploadRoomBlob(file: File): Promise<string> {
const form = new FormData()
form.set('FileType', FILE_TYPE_ROOM_SAVE)
form.set('File', file)
const { filename } = await call<{ filename?: string }>(`${where().storage}/upload`, {
method: 'POST',
multipart: form,
authed: true,
})
if (!filename) throw new Error('The storage worker accepted the file but returned no name.')
return filename
}
/**
* The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's
* `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a
* save whose hash doesn't describe its blob is worse than one carrying none.
*/
async function blobHash(file: File): Promise<string> {
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', await file.arrayBuffer()))
let binary = ''
for (const byte of digest) binary += String.fromCharCode(byte)
return btoa(binary)
}
/**
* Record a room save against one subroom, pointing it at an already-uploaded blob.
*
* `AutoPublish` decides whether players see it now or whether it waits on the room's
* publish step, exactly as it does for the game — the site doesn't get its own rule.
* The envelope answers HTTP 200 either way and puts the refusal in `error`, so success
* has to be read from the body rather than the status. `value.room` is the updated room,
* which the page re-renders from rather than re-fetching the whole list.
*/
async function saveSubRoomBlob(
roomId: number,
subRoomId: number,
input: { filename: string; hash: string; description: string; autoPublish: boolean }
): Promise<OwnedRoom> {
const res = await call<{
success?: boolean
error?: string | null
value?: { room?: OwnedRoom } | null
}>(`${where().rooms}/rooms/${roomId}/subrooms/${subRoomId}/data`, {
method: 'POST',
authed: true,
json: {
SubRoomData: { Filename: input.filename, Hash: input.hash },
Description: input.description,
AutoPublish: input.autoPublish,
},
})
if (res.success !== true) {
throw new Error(res.error || 'The rooms worker refused the save.')
}
const room = res.value?.room
if (!room) throw new Error('The save was recorded but the room came back empty.')
return room
}
/** /**
* Sign in with auth's password grant, posted directly the way the game posts it. The * Sign in with auth's password grant, posted directly the way the game posts it. The
* account is resolved by `username` (case-insensitive) — web players sign in with their * account is resolved by `username` (case-insensitive) — web players sign in with their
@@ -1021,7 +1119,18 @@ function RoomPage({
// they have no business asking. // they have no business asking.
<p className="muted">That isn&apos;t one of your rooms.</p> <p className="muted">That isn&apos;t one of your rooms.</p>
) : ( ) : (
<RoomDetail room={room} imgHost={where().img} cdnHost={where().cdn} /> <RoomDetail
room={room}
imgHost={where().img}
cdnHost={where().cdn}
// A save answers with the whole updated room, so swapping it into the list
// is enough — no re-fetch, and the other rooms keep their place.
onRoomChange={(updated) =>
setRooms((current) =>
(current ?? []).map((r) => (r.RoomId === updated.RoomId ? updated : r))
)
}
/>
)} )}
</main> </main>
) )
@@ -1039,15 +1148,22 @@ function platformList(room: OwnedRoom): string[] {
return on return on
} }
/** A room's settings and its subrooms. Read-only: rooms are edited in game. */ /**
* A room's settings and its subrooms. Its own fields are read-only — rooms are edited in
* game — with one exception: a subroom's scene data can be replaced from here, which is
* the one thing the game gives an owner no way to do (it can only save what it just
* built, never restore a file they kept).
*/
function RoomDetail({ function RoomDetail({
room, room,
imgHost, imgHost,
cdnHost, cdnHost,
onRoomChange,
}: { }: {
room: OwnedRoom room: OwnedRoom
imgHost: string imgHost: string
cdnHost: string cdnHost: string
onRoomChange: (room: OwnedRoom) => void
}) { }) {
const created = new Date(room.CreatedAt) const created = new Date(room.CreatedAt)
const platforms = platformList(room) const platforms = platformList(room)
@@ -1114,7 +1230,14 @@ function RoomDetail({
) : ( ) : (
<ul className="subrooms"> <ul className="subrooms">
{subRooms.map((sub) => ( {subRooms.map((sub) => (
<SubRoomRow key={sub.SubRoomId} sub={sub} roomName={room.Name} cdnHost={cdnHost} /> <SubRoomRow
key={sub.SubRoomId}
sub={sub}
roomId={room.RoomId}
roomName={room.Name}
cdnHost={cdnHost}
onRoomChange={onRoomChange}
/>
))} ))}
</ul> </ul>
)} )}
@@ -1126,12 +1249,16 @@ function RoomDetail({
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */ /** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
function SubRoomRow({ function SubRoomRow({
sub, sub,
roomId,
roomName, roomName,
cdnHost, cdnHost,
onRoomChange,
}: { }: {
sub: SubRoom sub: SubRoom
roomId: number
roomName: string roomName: string
cdnHost: string cdnHost: string
onRoomChange: (room: OwnedRoom) => void
}) { }) {
const save = sub.CurrentSave ?? null const save = sub.CurrentSave ?? null
const saved = save ? new Date(save.CreatedAt) : null const saved = save ? new Date(save.CreatedAt) : null
@@ -1184,10 +1311,114 @@ function SubRoomRow({
cdnHost={cdnHost} cdnHost={cdnHost}
/> />
)} )}
<BlobUpload roomId={roomId} subRoomId={sub.SubRoomId} onRoomChange={onRoomChange} />
</li> </li>
) )
} }
/**
* Replace one subroom's scene data with a file from disk.
*
* The two steps are the game's own: the bytes go to `storage` under the RoomSave type,
* and the key it hands back is posted to the subroom's `…/data` route as
* `SubRoomData.Filename`. So this is a room save like any other — it lands in the
* subroom's history beside the ones the game wrote, and both endpoints are already gated
* on the room's creator (or a co-owner), which is why there is no ownership check here:
* the page only lists rooms that came back from `ownedby/me` in the first place.
*
* Publishing is offered rather than assumed. A save normally only STAGES — players keep
* loading the last published version until the owner publishes — and quietly making an
* uploaded file live would be a bigger step than the game's own save takes. Left on by
* default all the same: someone uploading a blob here is restoring a room, and a restore
* nobody can see isn't one.
*/
function BlobUpload({
roomId,
subRoomId,
onRoomChange,
}: {
roomId: number
subRoomId: number
onRoomChange: (room: OwnedRoom) => void
}) {
const [file, setFile] = useState<File | null>(null)
const [description, setDescription] = useState('')
const [publish, setPublish] = useState(true)
// The file input is uncontrolled — React can't set its value — so clearing the picked
// file after a save takes a handle on the element itself.
const input = useRef<HTMLInputElement>(null)
const { pending, error, done, run } = useAction()
return (
<form
className="blob-upload"
onSubmit={(e) => {
e.preventDefault()
if (!file) return
void run(async () => {
const [filename, hash] = await Promise.all([uploadRoomBlob(file), blobHash(file)])
onRoomChange(
await saveSubRoomBlob(roomId, subRoomId, {
filename,
hash,
description: description.trim(),
autoPublish: publish,
})
)
setFile(null)
setDescription('')
if (input.current) input.current.value = ''
return publish
? 'Uploaded and published — players load this scene now.'
: 'Uploaded and staged. Publish it in game to make it live.'
})
}}
>
{/* Said out loud, on the control itself: this is the newest thing on the site and
the only one that overwrites what players load. Someone about to hand us a file
they can't get back should read that before the file picker, not after. */}
<p className="blob-upload-head">
<span className="blob-upload-title">Replace scene data</span>
<span className="badge beta">Beta</span>
</p>
<p className="muted blob-upload-caveat">
New and lightly tested. Nothing here checks the file the server stores whatever it
is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so
scene data from a room built on anything newer may not load at all. Download the save
above and keep it before replacing it.
</p>
<label className="blob-upload-file">
Scene data file
<input
ref={input}
type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</label>
<label className="blob-upload-note">
Save comment<span className="optional">optional</span>
<input
type="text"
value={description}
placeholder="Uploaded from the website"
maxLength={200}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className="check">
<input type="checkbox" checked={publish} onChange={(e) => setPublish(e.target.checked)} />
Publish it straight away
</label>
{error && <p className="error">{error}</p>}
{done && <p className="ok">{done}</p>}
<button type="submit" disabled={pending || file === null}>
{pending ? 'Uploading…' : 'Upload scene data'}
</button>
</form>
)
}
/** /**
* A download filename built from player-supplied names, with everything that isn't a * A download filename built from player-supplied names, with everything that isn't a
* word character, dot or dash flattened to a dash — a subroom can be called anything, * word character, dot or dash flattened to a dash — a subroom can be called anything,
+75
View File
@@ -707,6 +707,13 @@ h2 {
border-color: color-mix(in srgb, var(--live) 45%, transparent); border-color: color-mix(in srgb, var(--live) 45%, transparent);
} }
/* Same pill in the accent `.warn` uses, for a control that isn't finished: a beta mark
is "worth knowing before you act", like a staged save — not a neutral fact like Private. */
.badge.beta {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
}
.room-desc { .room-desc {
margin: 6px 0 0; margin: 6px 0 0;
font-size: 0.875rem; font-size: 0.875rem;
@@ -873,6 +880,74 @@ h2 {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
/*
* Replacing a subroom's scene data. Boxed off from the download links above it: those
* only read the room, this one overwrites what players load, and the two shouldn't read
* as one row of blob controls.
*/
.blob-upload {
margin-top: 12px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
}
.blob-upload label {
margin-bottom: 10px;
font-size: 0.8rem;
}
.blob-upload-head {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 4px;
}
.blob-upload-title {
font-family: var(--display);
font-weight: 700;
font-size: 0.9rem;
}
/* Tighter than the shared `.muted` paragraph: it's a caveat under a heading, not body
copy, and the file picker should still be the first thing the eye lands on. */
.blob-upload-caveat {
margin: 0 0 10px;
font-size: 0.8rem;
}
/* The file picker draws its own button, so the shared input chrome would frame it a
second time. Padding stays, so the row lines up with the text field under it. */
.blob-upload input[type='file'] {
border: none;
background: none;
padding: 8px 0 0;
font-size: 0.8rem;
}
/* A checkbox is not a text field: the shared `input` rule would stretch it to the card's
full width and break it onto its own line, away from the words it labels. */
.check {
display: flex;
align-items: center;
gap: 8px;
}
.check input[type='checkbox'] {
display: inline-block;
width: auto;
margin: 0;
accent-color: var(--accent);
}
/* Smaller than the account forms' submit — this one sits inside a subroom row, not at
the foot of its own card. */
.blob-upload button[type='submit'] {
padding: 8px 14px;
font-size: 0.85rem;
}
/* ---- Forms -------------------------------------------------------------- */ /* ---- Forms -------------------------------------------------------------- */
label { label {
@@ -50,6 +50,7 @@ it('advertises signup and where the other workers live', async () => {
notify: 'https://notify.rec.example.com', notify: 'https://notify.rec.example.com',
rooms: 'https://rooms.rec.example.com', rooms: 'https://rooms.rec.example.com',
cdn: 'https://cdn.rec.example.com', cdn: 'https://cdn.rec.example.com',
storage: 'https://storage.rec.example.com',
}, },
}) })
}) })
+1
View File
@@ -17,6 +17,7 @@ export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}` export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
export const roomsBase = (env: Env): string => `https://rooms.${env.DOMAIN}` export const roomsBase = (env: Env): string => `https://rooms.${env.DOMAIN}`
export const cdnBase = (env: Env): string => `https://cdn.${env.DOMAIN}` export const cdnBase = (env: Env): string => `https://cdn.${env.DOMAIN}`
export const storageBase = (env: Env): string => `https://storage.${env.DOMAIN}`
/** /**
* POST a form body to the `auth` worker, carrying the browser's real IP across. * POST a form body to the `auth` worker, carrying the browser's real IP across.
+2
View File
@@ -18,6 +18,7 @@ import {
postAuthForm, postAuthForm,
readAuthError, readAuthError,
roomsBase, roomsBase,
storageBase,
} from './upstream' } from './upstream'
import type { App } from './context' import type { App } from './context'
@@ -71,6 +72,7 @@ const app = new Hono<App>()
notify: notifyBase(c.env), notify: notifyBase(c.env),
rooms: roomsBase(c.env), rooms: roomsBase(c.env),
cdn: cdnBase(c.env), cdn: cdnBase(c.env),
storage: storageBase(c.env),
}, },
}) })
}) })