diff --git a/apps/storage/src/storage.app.ts b/apps/storage/src/storage.app.ts index 5c84f7e..ad911bd 100644 --- a/apps/storage/src/storage.app.ts +++ b/apps/storage/src/storage.app.ts @@ -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() 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()) diff --git a/apps/storage/src/test/integration/api.test.ts b/apps/storage/src/test/integration/api.test.ts index 56dca01..93d5200 100644 --- a/apps/storage/src/test/integration/api.test.ts +++ b/apps/storage/src/test/integration/api.test.ts @@ -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) diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index 043c6c1..eddf11b 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -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 /** 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>(url: string, opts: CallOptions = {}): Promise { const headers: Record = {} 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 { 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 + * `/` 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 { + 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 { + 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 { + 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.

That isn't one of your rooms.

) : ( - + + setRooms((current) => + (current ?? []).map((r) => (r.RoomId === updated.RoomId ? updated : r)) + ) + } + /> )} ) @@ -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({ ) : (
    {subRooms.map((sub) => ( - + ))}
)} @@ -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} /> )} + ) } +/** + * 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(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(null) + const { pending, error, done, run } = useAction() + + return ( +
{ + 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. */} +

+ Replace scene data + Beta +

+

+ 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. +

+ + + + {error &&

{error}

} + {done &&

{done}

} + +
+ ) +} + /** * 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, diff --git a/apps/www/src/client/styles.css b/apps/www/src/client/styles.css index e149fb1..5cfde50 100644 --- a/apps/www/src/client/styles.css +++ b/apps/www/src/client/styles.css @@ -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 { diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 47142d3..9058b4a 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -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', }, }) }) diff --git a/apps/www/src/upstream.ts b/apps/www/src/upstream.ts index 72c12f2..71eaa0a 100644 --- a/apps/www/src/upstream.ts +++ b/apps/www/src/upstream.ts @@ -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. diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index d72558e..9035f83 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -18,6 +18,7 @@ import { postAuthForm, readAuthError, roomsBase, + storageBase, } from './upstream' import type { App } from './context' @@ -71,6 +72,7 @@ const app = new Hono() notify: notifyBase(c.env), rooms: roomsBase(c.env), cdn: cdnBase(c.env), + storage: storageBase(c.env), }, }) })