mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
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 {
|
||||
@@ -79,6 +79,11 @@ const app = new Hono<App>()
|
||||
release: c.env.SENTRY_RELEASE,
|
||||
})(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())
|
||||
.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)
|
||||
})
|
||||
|
||||
it('answers the CORS preflight the website’s 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 () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
+235
-4
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Accessibility } from '@repo/domain/src/enums'
|
||||
import { GAME_VERSION } from '@repo/domain/src/presence-db'
|
||||
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import { authFailure, authUnreachable } from '../auth-messages'
|
||||
@@ -34,6 +35,7 @@ interface Hosts {
|
||||
notify: string
|
||||
rooms: string
|
||||
cdn: string
|
||||
storage: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -237,6 +239,12 @@ interface CallOptions {
|
||||
form?: Record<string, string>
|
||||
/** A JSON body — what notify's internal endpoints take instead. */
|
||||
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. */
|
||||
authed?: boolean
|
||||
/**
|
||||
@@ -251,13 +259,16 @@ interface CallOptions {
|
||||
async function call<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (opts.authed && token) headers.authorization = `Bearer ${token}`
|
||||
let body: string | undefined
|
||||
let body: string | FormData | undefined
|
||||
if (opts.form) {
|
||||
headers['content-type'] = 'application/x-www-form-urlencoded'
|
||||
body = new URLSearchParams(opts.form).toString()
|
||||
} else if (opts.json !== undefined) {
|
||||
headers['content-type'] = 'application/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, {
|
||||
@@ -308,6 +319,93 @@ async function fetchMyRooms(): Promise<OwnedRoom[]> {
|
||||
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
|
||||
* account is resolved by `username` (case-insensitive) — web players sign in with their
|
||||
@@ -1021,7 +1119,18 @@ function RoomPage({
|
||||
// they have no business asking.
|
||||
<p className="muted">That isn'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>
|
||||
)
|
||||
@@ -1039,15 +1148,22 @@ function platformList(room: OwnedRoom): string[] {
|
||||
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({
|
||||
room,
|
||||
imgHost,
|
||||
cdnHost,
|
||||
onRoomChange,
|
||||
}: {
|
||||
room: OwnedRoom
|
||||
imgHost: string
|
||||
cdnHost: string
|
||||
onRoomChange: (room: OwnedRoom) => void
|
||||
}) {
|
||||
const created = new Date(room.CreatedAt)
|
||||
const platforms = platformList(room)
|
||||
@@ -1114,7 +1230,14 @@ function RoomDetail({
|
||||
) : (
|
||||
<ul className="subrooms">
|
||||
{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>
|
||||
)}
|
||||
@@ -1126,12 +1249,16 @@ function RoomDetail({
|
||||
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
|
||||
function SubRoomRow({
|
||||
sub,
|
||||
roomId,
|
||||
roomName,
|
||||
cdnHost,
|
||||
onRoomChange,
|
||||
}: {
|
||||
sub: SubRoom
|
||||
roomId: number
|
||||
roomName: string
|
||||
cdnHost: string
|
||||
onRoomChange: (room: OwnedRoom) => void
|
||||
}) {
|
||||
const save = sub.CurrentSave ?? null
|
||||
const saved = save ? new Date(save.CreatedAt) : null
|
||||
@@ -1184,10 +1311,114 @@ function SubRoomRow({
|
||||
cdnHost={cdnHost}
|
||||
/>
|
||||
)}
|
||||
<BlobUpload roomId={roomId} subRoomId={sub.SubRoomId} onRoomChange={onRoomChange} />
|
||||
</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
|
||||
* word character, dot or dash flattened to a dash — a subroom can be called anything,
|
||||
|
||||
@@ -707,6 +707,13 @@ h2 {
|
||||
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 {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.875rem;
|
||||
@@ -873,6 +880,74 @@ h2 {
|
||||
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 -------------------------------------------------------------- */
|
||||
|
||||
label {
|
||||
|
||||
@@ -50,6 +50,7 @@ it('advertises signup and where the other workers live', async () => {
|
||||
notify: 'https://notify.rec.example.com',
|
||||
rooms: 'https://rooms.rec.example.com',
|
||||
cdn: 'https://cdn.rec.example.com',
|
||||
storage: 'https://storage.rec.example.com',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 roomsBase = (env: Env): string => `https://rooms.${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.
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
postAuthForm,
|
||||
readAuthError,
|
||||
roomsBase,
|
||||
storageBase,
|
||||
} from './upstream'
|
||||
|
||||
import type { App } from './context'
|
||||
@@ -71,6 +72,7 @@ const app = new Hono<App>()
|
||||
notify: notifyBase(c.env),
|
||||
rooms: roomsBase(c.env),
|
||||
cdn: cdnBase(c.env),
|
||||
storage: storageBase(c.env),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user