mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[www] add basic room list
This commit is contained in:
@@ -50,12 +50,19 @@ import {
|
||||
unbanPlayerFromRoom,
|
||||
updateRoomFields,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import {
|
||||
intVar,
|
||||
logger,
|
||||
withCleanSpec,
|
||||
withDefaultCors,
|
||||
withNotFound,
|
||||
withOnError,
|
||||
} from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
|
||||
import {
|
||||
AccessibilityRequest,
|
||||
AUTHED,
|
||||
@@ -85,8 +92,8 @@ import {
|
||||
PublishSaveRequest,
|
||||
RestrictionsRequest,
|
||||
RoleRequest,
|
||||
RoomBanEnvelope,
|
||||
RoomBanEntryDto,
|
||||
RoomBanEnvelope,
|
||||
RoomDto,
|
||||
RoomEnvelope,
|
||||
roomIdParam,
|
||||
@@ -546,6 +553,15 @@ const app = new Hono<App>()
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// The website (`www`) is a browser origin calling these endpoints directly, the way
|
||||
// rec.net's own site called the game's API — its "My rooms" list is this worker's
|
||||
// `GET /rooms/ownedby/me` — so the responses need CORS headers or the browser
|
||||
// discards them. `origin: '*'` is deliberate and safe HERE because these endpoints
|
||||
// authenticate with a bearer token in the `Authorization` header, never a cookie: a
|
||||
// hostile page can't read another origin's stored token, so there is no ambient
|
||||
// credential for `*` to expose. Do not add cookie auth without narrowing it.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
|
||||
@@ -181,6 +181,32 @@ describe('rooms endpoints', () => {
|
||||
expect(other).toEqual([])
|
||||
})
|
||||
|
||||
// The website's "My rooms" list is a browser calling this worker from another origin,
|
||||
// so a response without CORS headers is one the browser throws away — and the page
|
||||
// can't tell that apart from the server being down. Pinned on the preflight too: the
|
||||
// SPA sends `Authorization`, which makes even the GET a preflighted request.
|
||||
it('answers CORS so the website can read a room list from the browser', async () => {
|
||||
const preflight = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://www.example.com',
|
||||
'access-control-request-method': 'GET',
|
||||
'access-control-request-headers': 'authorization',
|
||||
},
|
||||
})
|
||||
expect(preflight.status).toBe(204)
|
||||
expect(preflight.headers.get('access-control-allow-origin')).toBe('*')
|
||||
expect(preflight.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
|
||||
'authorization'
|
||||
)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
|
||||
headers: { ...(await bearer('1')), origin: 'https://www.example.com' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
})
|
||||
|
||||
it('GET /rooms/ownedby|createdby/me lists the caller’s UNPUBLISHED rooms too', async () => {
|
||||
// "My Rooms" is the owner's own list, not a catalog: it must show a room that
|
||||
// isn't public yet, or a freshly created room (which starts Private — see
|
||||
@@ -214,9 +240,9 @@ describe('rooms endpoints', () => {
|
||||
}
|
||||
|
||||
// The same room is absent from the account's PUBLIC profile list.
|
||||
const publicList = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/ownedby/804`)
|
||||
).json()) as Array<{ Name: string }>
|
||||
const publicList = (await (await SELF.fetch(`${ORIGIN}/rooms/ownedby/804`)).json()) as Array<{
|
||||
Name: string
|
||||
}>
|
||||
expect(publicList.some((r) => r.Name === 'MyUnpublishedRoom')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -725,8 +751,7 @@ describe('rooms endpoints', () => {
|
||||
|
||||
const namesIn = async (path: string) => {
|
||||
const body = (await (await SELF.fetch(`${ORIGIN}${path}`)).json()) as
|
||||
| { Results: Array<{ Name: string }> }
|
||||
| Array<{ Name: string }>
|
||||
{ Results: Array<{ Name: string }> } | Array<{ Name: string }>
|
||||
return (Array.isArray(body) ? body : body.Results).map((r) => r.Name)
|
||||
}
|
||||
expect(await namesIn('/rooms/hot?take=200')).not.toContain('ParkCloneUnpublished')
|
||||
|
||||
@@ -32,6 +32,10 @@ const AUTH_MESSAGES: Record<string, string> = {
|
||||
'no linked account for this platform identity':
|
||||
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
||||
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
||||
// Deliberately says nothing about when it lifts: auth sends one fixed description for
|
||||
// every ban (see its BANNED_DESCRIPTION), permanent or timed, so there is no expiry
|
||||
// here to quote.
|
||||
'this account is banned': 'This account is banned and cannot be signed in to.',
|
||||
}
|
||||
|
||||
/** Fallbacks when nothing above matched, so a player never reads an OAuth code. */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Accessibility } from '@repo/domain/src/enums'
|
||||
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import { authFailure, authUnreachable } from '../auth-messages'
|
||||
import {
|
||||
@@ -30,6 +32,7 @@ interface Hosts {
|
||||
api: string
|
||||
img: string
|
||||
notify: string
|
||||
rooms: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +59,42 @@ interface SelfAccount {
|
||||
availableUsernameChanges?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One room from `rooms` (`GET /rooms/ownedby/me`), narrowed to what this page draws. The
|
||||
* worker serves the stored room blob verbatim — dozens of fields the game needs and this
|
||||
* list doesn't — so only the handful read here is declared.
|
||||
*/
|
||||
interface OwnedRoom {
|
||||
RoomId: number
|
||||
Name: string
|
||||
Description: string
|
||||
/** A key on the `img` worker; a room with no image of its own gets the fallback. */
|
||||
ImageName: string
|
||||
/** The `Accessibility` ordinal, NOT the enum name — see ACCESSIBILITY_LABEL. */
|
||||
Accessibility: number
|
||||
CreatedAt: string
|
||||
/** Always present: the worker folds the live counters in on every read. */
|
||||
Stats: {
|
||||
CheerCount: number
|
||||
FavoriteCount: number
|
||||
VisitorCount: number
|
||||
VisitCount: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a room's `Accessibility` ordinal is called on screen. The two dev values are
|
||||
* reachable — the game sets them — so they're named rather than left to fall through
|
||||
* to the unknown case in RoomCard.
|
||||
*/
|
||||
const ACCESSIBILITY_LABEL: Record<number, string> = {
|
||||
[Accessibility.Private]: 'Private',
|
||||
[Accessibility.Public]: 'Public',
|
||||
[Accessibility.Unlisted]: 'Unlisted',
|
||||
[Accessibility.Dev_only]: 'Dev only',
|
||||
[Accessibility.Dev_Unlisted]: 'Dev unlisted',
|
||||
}
|
||||
|
||||
/**
|
||||
* RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in.
|
||||
* NOT passed on signup: create_account treats an asserted platform as one to verify
|
||||
@@ -197,6 +236,28 @@ async function call<T = Record<string, unknown>>(url: string, opts: CallOptions
|
||||
const fetchMe = (): Promise<SelfAccount> =>
|
||||
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
|
||||
|
||||
/**
|
||||
* The caller's own rooms, from the `rooms` worker — the same list the game's "My Rooms"
|
||||
* loads. `ownedby/me` rather than `createdby/me`: the dorm is auto-provisioned, not a
|
||||
* room the player made, and it's the one room they can't do anything with from here.
|
||||
*
|
||||
* The worker deliberately does NOT filter on accessibility for this list, so a room that
|
||||
* has never been published shows up — which is the point, since that's the one its owner
|
||||
* is most likely to be looking for.
|
||||
*
|
||||
* Sorted newest-first here rather than upstream: the query has no ORDER BY (D1 hands
|
||||
* back insertion order, which is not a promise), and the room someone just made is the
|
||||
* one they came to see.
|
||||
*/
|
||||
async function fetchMyRooms(): Promise<OwnedRoom[]> {
|
||||
const rooms = await call<OwnedRoom[]>(`${where().rooms}/rooms/ownedby/me`, { authed: true })
|
||||
// A bare array is the contract; anything else is treated as "no rooms" rather than
|
||||
// thrown, since `.sort` on a non-array would surface as an unreadable TypeError.
|
||||
if (!Array.isArray(rooms)) return []
|
||||
// ISO-8601 timestamps, so lexical order IS chronological order.
|
||||
return [...rooms].sort((a, b) => (a.CreatedAt < b.CreatedAt ? 1 : -1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1099,6 +1160,9 @@ function Dashboard({
|
||||
// The dashboard sections, shown one at a time via the left tab rail. Admin-only
|
||||
// sections are appended when the session carries an admin role.
|
||||
const sections = [
|
||||
// First, so a player who just signed in lands on what they made rather than on a
|
||||
// settings form they opened the page to avoid.
|
||||
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms /> },
|
||||
{
|
||||
id: 'username',
|
||||
label: 'Username',
|
||||
@@ -1147,6 +1211,101 @@ function Dashboard({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rooms the signed-in player owns.
|
||||
*
|
||||
* Read-only on purpose: rooms are made and edited in game, and there is nothing here a
|
||||
* player could change that the game doesn't already own. What the web is better at is
|
||||
* the overview — everything you've made in one place, including the rooms you never
|
||||
* published, which are invisible everywhere else.
|
||||
*/
|
||||
function MyRooms() {
|
||||
const [rooms, setRooms] = useState<OwnedRoom[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
void fetchMyRooms()
|
||||
.then(setRooms)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>My rooms</h2>
|
||||
<p className="muted">
|
||||
Every room you've made, newest first — unpublished ones included. Your dorm isn't
|
||||
here: it was made for you rather than by you.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="error">{error}</p>
|
||||
) : rooms === null ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="muted">
|
||||
You haven't made a room yet. Rooms are created in game — clone one you like, or start
|
||||
from a blank one in the Rec Center.
|
||||
</p>
|
||||
) : (
|
||||
// `where()` THROWS when the config never landed, and a throw in render takes the
|
||||
// page down (see useSlideshow). It can't here: this branch is only reached once
|
||||
// the fetch above resolved, and that fetch went through `where()` itself.
|
||||
<ul className="rooms">
|
||||
{rooms.map((room) => (
|
||||
<RoomCard key={room.RoomId} room={room} imgHost={where().img} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's
|
||||
* doing.
|
||||
*
|
||||
* The thumbnail is asked for at 256px wide — one of the img worker's four allowed sizes,
|
||||
* so it's a cached variant rather than the full-size upload. A room with no image of its
|
||||
* own still answers 200 there (the worker serves its fallback), so there's no broken
|
||||
* frame to handle.
|
||||
*/
|
||||
function RoomCard({ room, imgHost }: { room: OwnedRoom; imgHost: string }) {
|
||||
// Unknown ordinals shouldn't happen, but the label is the only thing telling an owner
|
||||
// whether a room is visible — so show the raw value rather than nothing at all.
|
||||
const visibility =
|
||||
ACCESSIBILITY_LABEL[room.Accessibility] ?? `Accessibility ${room.Accessibility}`
|
||||
const created = new Date(room.CreatedAt)
|
||||
|
||||
return (
|
||||
<li className="room">
|
||||
<img
|
||||
className="room-thumb"
|
||||
src={`${imgHost}/${room.ImageName}?width=256`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="room-body">
|
||||
<div className="room-head">
|
||||
{/* The caret is how the game writes a room name, so it reads as the thing you
|
||||
type to get there rather than as a title someone wrote. */}
|
||||
<span className="room-name">^{room.Name}</span>
|
||||
<span className={`badge ${room.Accessibility === Accessibility.Public ? 'live' : ''}`}>
|
||||
{visibility}
|
||||
</span>
|
||||
</div>
|
||||
{room.Description && <p className="room-desc">{room.Description}</p>}
|
||||
<p className="room-stats">
|
||||
{room.Stats.VisitCount.toLocaleString()} visit
|
||||
{room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '}
|
||||
favourite
|
||||
{room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '}
|
||||
cheer{room.Stats.CheerCount === 1 ? '' : 's'}
|
||||
{!Number.isNaN(created.getTime()) && ` · made ${created.toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** Admin-only: send a coach/system message to every online player. */
|
||||
function CoachMessageForm() {
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
@@ -605,6 +605,108 @@ h2 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- My rooms ----------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* The owner's own room list. Rows rather than a grid of tiles: a room is identified by
|
||||
* its name, and the counts underneath are the reason to look — both read left-to-right,
|
||||
* which a tile would stack into a column of tiny type.
|
||||
*/
|
||||
.rooms {
|
||||
list-style: none;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.room {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* The first row sits directly under the intro copy, which already separates it. */
|
||||
.room:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Fixed 3:2 frame — room thumbnails are screenshots and arrive at any ratio, and a
|
||||
per-room height would leave the names in a ragged column. */
|
||||
.room-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.room-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.room-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Room names have no spaces to break at, so let a long one wrap anywhere rather than
|
||||
widen the row past the panel. */
|
||||
.room-name {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Whether the room is visible to anyone else — the one piece of state an owner can't
|
||||
see anywhere but here. Public gets the same green "healthy" reading as the server
|
||||
status; every other value stays neutral, since Private isn't a fault. */
|
||||
.badge {
|
||||
flex: none;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge.live {
|
||||
color: var(--live);
|
||||
border-color: color-mix(in srgb, var(--live) 45%, transparent);
|
||||
}
|
||||
|
||||
.room-desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted);
|
||||
/* Two lines: enough to tell rooms apart, not enough for one to own the list. */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.room-stats {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- Forms -------------------------------------------------------------- */
|
||||
|
||||
label {
|
||||
|
||||
@@ -32,7 +32,7 @@ beforeAll(async () => {
|
||||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||
// the pass path can't be tested here (it would call Cloudflare's siteverify for real).
|
||||
//
|
||||
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify
|
||||
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify/rooms
|
||||
// DIRECTLY (as rec.net's site did), and this is the only place it learns where they are.
|
||||
// A build with them missing can't sign anyone in.
|
||||
it('advertises signup and where the other workers live', async () => {
|
||||
@@ -48,6 +48,7 @@ it('advertises signup and where the other workers live', async () => {
|
||||
api: 'https://api.rec.example.com',
|
||||
img: 'https://img.rec.example.com',
|
||||
notify: 'https://notify.rec.example.com',
|
||||
rooms: 'https://rooms.rec.example.com',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ export const accountsBase = (env: Env): string => `https://accounts.${env.DOMAIN
|
||||
export const notifyBase = (env: Env): string => `https://notify.${env.DOMAIN}`
|
||||
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}`
|
||||
|
||||
/**
|
||||
* POST a form body to the `auth` worker, carrying the browser's real IP across.
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
notifyBase,
|
||||
postAuthForm,
|
||||
readAuthError,
|
||||
roomsBase,
|
||||
} from './upstream'
|
||||
|
||||
import type { App } from './context'
|
||||
@@ -67,6 +68,7 @@ const app = new Hono<App>()
|
||||
api: apiBase(c.env),
|
||||
img: imgBase(c.env),
|
||||
notify: notifyBase(c.env),
|
||||
rooms: roomsBase(c.env),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user