mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
Fix #13 add basic admin panel for now
This commit is contained in:
@@ -36,9 +36,25 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saved-image categories from the C# `SavedImageType` enum — the value of a stored
|
||||||
|
* image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives here in
|
||||||
|
* the image data layer so both the upload route and the slideshow query share one
|
||||||
|
* definition.
|
||||||
|
*/
|
||||||
|
export const SavedImageType = {
|
||||||
|
None: 0,
|
||||||
|
ShareCamera: 1,
|
||||||
|
OutfitThumbnail: 2,
|
||||||
|
RoomThumbnail: 3,
|
||||||
|
ProfileThumbnail: 4,
|
||||||
|
InventionThumbnail: 5,
|
||||||
|
} as const
|
||||||
|
|
||||||
/** A stored image record (the client-facing SavedImage shape). */
|
/** A stored image record (the client-facing SavedImage shape). */
|
||||||
export interface SavedImage {
|
export interface SavedImage {
|
||||||
Id: number
|
Id: number
|
||||||
|
/** A {@link SavedImageType} value. */
|
||||||
Type: number
|
Type: number
|
||||||
Accessibility: number
|
Accessibility: number
|
||||||
AccessibilityLocked: boolean
|
AccessibilityLocked: boolean
|
||||||
@@ -275,11 +291,12 @@ async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The global slideshow feed — the most recent publicly-listable images across all
|
* The global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||||
* rooms (Accessibility 0 or 1), newest first, capped at `limit`. Each row is joined
|
* across all rooms (Accessibility 0 or 1, Type 1), newest first, capped at `limit`.
|
||||||
* to its creator's username and (if any) its room's name. Returns the projected
|
* Only ShareCamera images are surfaced (not room/profile/invention thumbnails). Each
|
||||||
* SlideshowImage shape. Usernames/room names are resolved in two batched lookups to
|
* row is joined to its creator's username and (if any) its room's name. Returns the
|
||||||
* avoid an N+1 across the (at most `limit`) images.
|
* projected SlideshowImage shape. Usernames/room names are resolved in two batched
|
||||||
|
* lookups to avoid an N+1 across the (at most `limit`) images.
|
||||||
*/
|
*/
|
||||||
export async function getSlideshowImages(
|
export async function getSlideshowImages(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -289,9 +306,10 @@ export async function getSlideshowImages(
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT data FROM image
|
`SELECT data FROM image
|
||||||
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
|
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
|
||||||
ORDER BY id DESC LIMIT ?1`
|
AND json_extract(data, '$.Type') = ?1
|
||||||
|
ORDER BY id DESC LIMIT ?2`
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(SavedImageType.ShareCamera, limit)
|
||||||
.all<ImageRow>()
|
.all<ImageRow>()
|
||||||
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
|
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
|
||||||
|
|
||||||
|
|||||||
@@ -8,22 +8,13 @@ import {
|
|||||||
getImagesByRoom,
|
getImagesByRoom,
|
||||||
getPlayerFeed,
|
getPlayerFeed,
|
||||||
getSlideshowImages,
|
getSlideshowImages,
|
||||||
|
SavedImageType,
|
||||||
setImageCheer,
|
setImageCheer,
|
||||||
} from '../images-db'
|
} from '../images-db'
|
||||||
import { authedId, unauthorized } from '../http'
|
import { authedId, unauthorized } from '../http'
|
||||||
|
|
||||||
import type { App } from '../context'
|
import type { App } from '../context'
|
||||||
|
|
||||||
/** Saved-image categories from the C# `SavedImageType` enum (`imgMeta.savedImageType`). */
|
|
||||||
const SavedImageType = {
|
|
||||||
None: 0,
|
|
||||||
ShareCamera: 1,
|
|
||||||
OutfitThumbnail: 2,
|
|
||||||
RoomThumbnail: 3,
|
|
||||||
ProfileThumbnail: 4,
|
|
||||||
InventionThumbnail: 5,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */
|
/** Bucket folder each SavedImageType is stored under; unknown types fall back to `none`. */
|
||||||
const typeFolder: Record<number, string> = {
|
const typeFolder: Record<number, string> = {
|
||||||
[SavedImageType.None]: 'none',
|
[SavedImageType.None]: 'none',
|
||||||
@@ -150,13 +141,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
|||||||
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
|
return c.json(await getPlayerFeed(c.env.DB, playerId, skip, take))
|
||||||
})
|
})
|
||||||
|
|
||||||
// Global slideshow feed — the most recent publicly-listable images (Accessibility
|
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||||
// 0 or 1) across all rooms, newest first, each joined to its creator's username
|
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its
|
||||||
// and room name. Auth-gated. Returns `{ Images, ValidTill }`, where ValidTill is a
|
// creator's username and room name. Public (no auth): it only surfaces already-public
|
||||||
// short (2-minute) cache hint the client refreshes against.
|
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
|
||||||
|
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
|
||||||
.get('/api/images/v1/slideshow', async (c) => {
|
.get('/api/images/v1/slideshow', async (c) => {
|
||||||
const id = await authedId(c)
|
|
||||||
if (id === null) return unauthorized(c)
|
|
||||||
const Images = await getSlideshowImages(c.env.DB)
|
const Images = await getSlideshowImages(c.env.DB)
|
||||||
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||||
return c.json({ Images, ValidTill })
|
return c.json({ Images, ValidTill })
|
||||||
|
|||||||
@@ -1065,6 +1065,7 @@ describe('images', () => {
|
|||||||
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
|
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
|
||||||
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
|
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
|
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera
|
||||||
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
|
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
|
||||||
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||||
@@ -1074,8 +1075,9 @@ describe('images', () => {
|
|||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||||
|
// Keyed by <type>/<date>/<uuid>.<ext> (the type folder mirrors the CDN layout).
|
||||||
expect(ImageName).toMatch(
|
expect(ImageName).toMatch(
|
||||||
/^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/
|
/^sharecamera\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.png$/
|
||||||
)
|
)
|
||||||
|
|
||||||
// The object is in the shared bucket under that key.
|
// The object is in the shared bucket under that key.
|
||||||
@@ -1093,10 +1095,7 @@ describe('images', () => {
|
|||||||
expect(meta.CheerCount).toBe(0)
|
expect(meta.CheerCount).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/images/v1/slideshow is auth-gated and joins username + room name', async () => {
|
test('GET /api/images/v1/slideshow is public and joins username + room name', async () => {
|
||||||
// No token → 401.
|
|
||||||
expect((await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)).status).toBe(401)
|
|
||||||
|
|
||||||
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
|
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
|
||||||
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||||
.bind(
|
.bind(
|
||||||
@@ -1118,9 +1117,8 @@ describe('images', () => {
|
|||||||
)
|
)
|
||||||
.run()
|
.run()
|
||||||
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`, {
|
// No token — the slideshow is public.
|
||||||
headers: await bearer(),
|
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow`)
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
Images: Array<Record<string, unknown>>
|
Images: Array<Record<string, unknown>>
|
||||||
@@ -1287,8 +1285,9 @@ describe('images', () => {
|
|||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||||
|
// Type 4 → the `profile/` type folder, then <date>/<uuid>.<ext>.
|
||||||
expect(ImageName).toMatch(
|
expect(ImageName).toMatch(
|
||||||
/^\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/
|
/^profile\/\d{4}-\d{2}-\d{2}\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jpg$/
|
||||||
)
|
)
|
||||||
|
|
||||||
// The account row now points its profileImage at the uploaded key.
|
// The account row now points its profileImage at the uploaded key.
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ instance holds the shared hub state (one process across all connections).
|
|||||||
- `GET /hub/v1` (WebSocket upgrade) — the hub. Forwarded to the Durable Object.
|
- `GET /hub/v1` (WebSocket upgrade) — the hub. Forwarded to the Durable Object.
|
||||||
Non-upgrade requests get `426`.
|
Non-upgrade requests get `426`.
|
||||||
- `POST /internal/notify` — `{ playerId, notificationType, data? }`. Send to a
|
- `POST /internal/notify` — `{ playerId, notificationType, data? }`. Send to a
|
||||||
player's connections, queueing if they're offline. **Unauthenticated; internal
|
player's connections, queueing if they're offline. **Admin-gated**: requires a
|
||||||
use only (TODO: protect).**
|
Bearer token carrying an admin role (`developer`/`moderator`) in its `role` claim.
|
||||||
- `POST /internal/broadcast` — `{ notificationType, data? }`. Send to every
|
- `POST /internal/broadcast` — `{ notificationType, data? }`. Send to every
|
||||||
connected client.
|
connected client. Same admin-role gate as `/internal/notify`.
|
||||||
|
- `POST /internal/coach-message-all` — `{ messageContent }`. Broadcast a coach/system
|
||||||
|
`MessageReceived` to every connected client (online-only; not persisted). Same
|
||||||
|
admin-role gate.
|
||||||
|
|
||||||
## Hub protocol
|
## Hub protocol
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { DurableObject } from 'cloudflare:workers'
|
import { DurableObject } from 'cloudflare:workers'
|
||||||
|
|
||||||
|
import { NotificationType } from './notification-types'
|
||||||
|
|
||||||
import type { Env } from './context'
|
import type { Env } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +36,12 @@ interface HubMessage {
|
|||||||
arguments?: unknown[]
|
arguments?: unknown[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The Coach system account — the `FromPlayerId` on a coach message (see coachMessageAll). */
|
||||||
|
const COACH_PLAYER_ID = 1
|
||||||
|
|
||||||
|
/** The Message `Type` a coach/system message carries (a Message-model enum, not a NotificationType). */
|
||||||
|
const COACH_MESSAGE_TYPE = 100
|
||||||
|
|
||||||
export class NotificationsHub extends DurableObject<Env> {
|
export class NotificationsHub extends DurableObject<Env> {
|
||||||
constructor(ctx: DurableObjectState, env: Env) {
|
constructor(ctx: DurableObjectState, env: Env) {
|
||||||
super(ctx, env)
|
super(ctx, env)
|
||||||
@@ -227,7 +235,52 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
data?: Record<string, unknown>
|
data?: Record<string, unknown>
|
||||||
): Promise<{ delivered: number; queued: boolean }> {
|
): Promise<{ delivered: number; queued: boolean }> {
|
||||||
const payload = this.buildNotificationPayload(notificationType, data)
|
const payload = this.buildNotificationPayload(notificationType, data)
|
||||||
|
const delivered = this.deliverToPlayer(playerId, payload)
|
||||||
|
|
||||||
|
if (delivered === 0) {
|
||||||
|
this.ctx.storage.sql.exec(
|
||||||
|
'INSERT INTO pending (playerId, payload) VALUES (?, ?)',
|
||||||
|
playerId,
|
||||||
|
payload
|
||||||
|
)
|
||||||
|
return { delivered: 0, queued: true }
|
||||||
|
}
|
||||||
|
return { delivered, queued: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a "coach" message to every connected client (mirrors the reference
|
||||||
|
* `SendCoachMessageAll`, using the hub's live connections as the online set): each
|
||||||
|
* handshaken socket gets a `MessageReceived` notification carrying a Message from
|
||||||
|
* the Coach account (player 1). Online-only — nothing is queued or persisted, and
|
||||||
|
* it's a broadcast, so the Message has no per-recipient `ToPlayerId`. Returns how
|
||||||
|
* many connected clients were messaged.
|
||||||
|
*/
|
||||||
|
async coachMessageAll(content: string): Promise<{ sent: number }> {
|
||||||
|
const payload = this.buildNotificationPayload(NotificationType.MessageReceived, {
|
||||||
|
FromPlayerId: COACH_PLAYER_ID,
|
||||||
|
Type: COACH_MESSAGE_TYPE,
|
||||||
|
Data: content,
|
||||||
|
})
|
||||||
|
return { sent: this.broadcastToConnected(payload) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broadcast a notification to every connected (handshaken) client. */
|
||||||
|
async broadcast(
|
||||||
|
notificationType: string | number,
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
): Promise<{ delivered: number }> {
|
||||||
|
return { delivered: this.broadcastToConnected(this.buildNotificationPayload(notificationType, data)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an already-built `Notification` payload to every live socket of a player's
|
||||||
|
* subscribed connections; returns how many sockets received it (0 = offline). The
|
||||||
|
* shared send path for {@link notifyPlayer} and {@link coachMessageAll}.
|
||||||
|
*/
|
||||||
|
private deliverToPlayer(playerId: number, payload: string): number {
|
||||||
const connectionIds = this.ctx.storage.sql
|
const connectionIds = this.ctx.storage.sql
|
||||||
.exec<{ connectionId: string }>(
|
.exec<{ connectionId: string }>(
|
||||||
'SELECT DISTINCT connectionId FROM subscriptions WHERE playerId = ?',
|
'SELECT DISTINCT connectionId FROM subscriptions WHERE playerId = ?',
|
||||||
@@ -243,24 +296,15 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
delivered++
|
delivered++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return delivered
|
||||||
if (delivered === 0) {
|
|
||||||
this.ctx.storage.sql.exec(
|
|
||||||
'INSERT INTO pending (playerId, payload) VALUES (?, ?)',
|
|
||||||
playerId,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
return { delivered: 0, queued: true }
|
|
||||||
}
|
|
||||||
return { delivered, queued: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Broadcast a notification to every connected (handshaken) client. */
|
/**
|
||||||
async broadcast(
|
* Send an already-built `Notification` payload to every connected (handshaken)
|
||||||
notificationType: string | number,
|
* socket; returns how many received it. Shared by {@link broadcast} and
|
||||||
data?: Record<string, unknown>
|
* {@link coachMessageAll}.
|
||||||
): Promise<{ delivered: number }> {
|
*/
|
||||||
const payload = this.buildNotificationPayload(notificationType, data)
|
private broadcastToConnected(payload: string): number {
|
||||||
let delivered = 0
|
let delivered = 0
|
||||||
for (const ws of this.ctx.getWebSockets()) {
|
for (const ws of this.ctx.getWebSockets()) {
|
||||||
const state = ws.deserializeAttachment() as SocketState | null
|
const state = ws.deserializeAttachment() as SocketState | null
|
||||||
@@ -268,11 +312,9 @@ export class NotificationsHub extends DurableObject<Env> {
|
|||||||
ws.send(this.invocation('Notification', [payload]))
|
ws.send(this.invocation('Notification', [payload]))
|
||||||
delivered++
|
delivered++
|
||||||
}
|
}
|
||||||
return { delivered }
|
return delivered
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Helpers -------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the `Notification` argument: a JSON string `{ Id, Msg }`
|
* Build the `Notification` argument: a JSON string `{ Id, Msg }`
|
||||||
* (null values are dropped from `Msg`). `Id` is a client-defined tag — a
|
* (null values are dropped from `Msg`). `Id` is a client-defined tag — a
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Hono } from 'hono'
|
|||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetRoles } from '@repo/jwt'
|
||||||
|
|
||||||
import { NotificationsHub } from './notifications-hub'
|
import { NotificationsHub } from './notifications-hub'
|
||||||
|
|
||||||
@@ -29,21 +29,23 @@ function isNotificationType(value: unknown): value is string | number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Account ids allowed to call the internal send/broadcast endpoints. Temporary
|
* Roles allowed to call the internal send/broadcast endpoints. These are the
|
||||||
* lockdown until these are properly gated — for now only these admins can push
|
* operator-granted elevated roles (see the auth worker's `role` claim, set from an
|
||||||
* notifications through the shared hub.
|
* account's isDeveloper/isModerator flags via the admin CLI) — so a staffer grants
|
||||||
|
* themselves the role and can then push notifications through the shared hub, e.g.
|
||||||
|
* from the accounts web UI's maintenance control.
|
||||||
*/
|
*/
|
||||||
const ADMIN_ACCOUNT_IDS = new Set([1, 2])
|
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gates the `/internal/*` endpoints on a valid Bearer token whose `sub` is an
|
* Gates the `/internal/*` endpoints on a valid Bearer token that carries one of the
|
||||||
* allowed admin account. 401 for a missing/invalid token, 403 for a valid token
|
* {@link ADMIN_ROLES} in its `role` claim. 401 for a missing/invalid token, 403 for a
|
||||||
* that isn't an admin.
|
* valid token that lacks an admin role.
|
||||||
*/
|
*/
|
||||||
const requireAdmin: MiddlewareHandler<App> = async (c, next) => {
|
const requireAdmin: MiddlewareHandler<App> = async (c, next) => {
|
||||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
const roles = await validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||||
if (accountId === null) return c.json({ error: 'Unauthorized' }, 401)
|
if (roles === null) return c.json({ error: 'Unauthorized' }, 401)
|
||||||
if (!ADMIN_ACCOUNT_IDS.has(accountId)) return c.json({ error: 'Forbidden' }, 403)
|
if (!roles.some((role) => ADMIN_ROLES.has(role))) return c.json({ error: 'Forbidden' }, 403)
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,5 +124,15 @@ const app = new Hono<App>()
|
|||||||
return c.json({ success: true, ...result })
|
return c.json({ success: true, ...result })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Send a coach/system direct message to every currently-online player.
|
||||||
|
.post('/internal/coach-message-all', async (c) => {
|
||||||
|
const body = await c.req.json<{ messageContent?: string }>().catch(() => null)
|
||||||
|
const content = typeof body?.messageContent === 'string' ? body.messageContent.trim() : ''
|
||||||
|
if (content === '') return c.json({ error: 'messageContent is required' }, 400)
|
||||||
|
const result =
|
||||||
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).coachMessageAll(content)
|
||||||
|
return c.json({ success: true, ...result })
|
||||||
|
})
|
||||||
|
|
||||||
export { NotificationsHub }
|
export { NotificationsHub }
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ function b64url(input: ArrayBuffer | string): string {
|
|||||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
}
|
}
|
||||||
async function bearer(sub: string): Promise<Record<string, string>> {
|
async function bearer(sub: string, roles?: string[]): Promise<Record<string, string>> {
|
||||||
const now = Math.floor(Date.now() / 1000)
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
|
||||||
|
if (roles) claims.role = roles
|
||||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||||
JSON.stringify({ sub, exp: now + 3600 })
|
JSON.stringify(claims)
|
||||||
)}`
|
)}`
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -104,14 +106,14 @@ async function connect(
|
|||||||
|
|
||||||
const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS)
|
const send = (ws: WebSocket, msg: HubRecord) => ws.send(JSON.stringify(msg) + RS)
|
||||||
|
|
||||||
// The /internal/* endpoints are admin-gated, so default to an admin (account 1)
|
// The /internal/* endpoints are admin-gated (a token carrying an admin role), so
|
||||||
// Bearer token; pass `auth` to override (e.g. to test the 401/403 paths).
|
// default to a moderator token; pass `auth` to override (e.g. the 401/403 paths).
|
||||||
const post = async (path: string, body: unknown, auth?: Record<string, string>) =>
|
const post = async (path: string, body: unknown, auth?: Record<string, string>) =>
|
||||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(auth ?? (await bearer('1'))),
|
...(auth ?? (await bearer('1', ['gameClient', 'moderator']))),
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
@@ -150,14 +152,18 @@ describe('internal endpoint auth', () => {
|
|||||||
expect(res.status).toBe(401)
|
expect(res.status).toBe(401)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('403 for a valid token that is not an admin', async () => {
|
test('403 for a valid token without an admin role', async () => {
|
||||||
const res = await post('/internal/notify', body, await bearer('3'))
|
const res = await post('/internal/notify', body, await bearer('3', ['gameClient']))
|
||||||
expect(res.status).toBe(403)
|
expect(res.status).toBe(403)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('admin accounts 1 and 2 are allowed', async () => {
|
test('tokens carrying an admin role are allowed', async () => {
|
||||||
for (const sub of ['1', '2']) {
|
for (const role of ['developer', 'moderator']) {
|
||||||
const res = await post('/internal/broadcast', { notificationType: 1 }, await bearer(sub))
|
const res = await post(
|
||||||
|
'/internal/broadcast',
|
||||||
|
{ notificationType: 1 },
|
||||||
|
await bearer('3', ['gameClient', role])
|
||||||
|
)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -251,6 +257,35 @@ describe('notification delivery', () => {
|
|||||||
ws.close()
|
ws.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('coach-message-all messages every connected client', async () => {
|
||||||
|
const a = await connect('coach-a')
|
||||||
|
const b = await connect('coach-b')
|
||||||
|
|
||||||
|
const res = await post('/internal/coach-message-all', { messageContent: 'hello all' })
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(((await res.json()) as { sent: number }).sent).toBeGreaterThanOrEqual(2)
|
||||||
|
|
||||||
|
const noteA = await a.waitFor((r) => r.type === 1 && r.target === 'Notification')
|
||||||
|
const payloadA = JSON.parse((noteA.arguments as string[])[0]) as {
|
||||||
|
Id: string
|
||||||
|
Msg: Record<string, unknown>
|
||||||
|
}
|
||||||
|
expect(payloadA.Id).toBe('2') // MessageReceived
|
||||||
|
expect(payloadA.Msg).toMatchObject({ FromPlayerId: 1, Type: 100, Data: 'hello all' })
|
||||||
|
|
||||||
|
const noteB = await b.waitFor((r) => r.type === 1 && r.target === 'Notification')
|
||||||
|
const payloadB = JSON.parse((noteB.arguments as string[])[0]) as { Msg: Record<string, unknown> }
|
||||||
|
expect(payloadB.Msg).toMatchObject({ Data: 'hello all' })
|
||||||
|
|
||||||
|
a.ws.close()
|
||||||
|
b.ws.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('coach-message-all 400s on an empty message', async () => {
|
||||||
|
const res = await post('/internal/coach-message-all', { messageContent: ' ' })
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
test('emits a numeric notificationType as a string Id', async () => {
|
test('emits a numeric notificationType as a string Id', async () => {
|
||||||
// The client dispatches on a string Id, so numeric codes (e.g. econ's
|
// The client dispatches on a string Id, so numeric codes (e.g. econ's
|
||||||
// NotificationType enum) must be serialized as strings or they're dropped.
|
// NotificationType enum) must be serialized as strings or they're dropped.
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Recflare Accounts</title>
|
<title>RecFlare</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+355
-86
@@ -1,11 +1,13 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
/** The self-account shape returned by the accounts worker (`GET /account/me`). */
|
/** The self-account shape returned by the www BFF (`/api/me`, `/api/login`, …). */
|
||||||
interface SelfAccount {
|
interface SelfAccount {
|
||||||
accountId: number
|
accountId: number
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
email: string | null
|
email: string | null
|
||||||
|
/** Whether this session may use admin controls (from the token's role claim). */
|
||||||
|
isAdmin?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,12 +32,59 @@ async function api<T = unknown>(path: string, body?: unknown): Promise<T> {
|
|||||||
return data as T
|
return data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimal history-based router: current pathname + a navigate() that pushes state. */
|
||||||
|
function useRouter() {
|
||||||
|
const [path, setPath] = useState(() => window.location.pathname)
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = () => setPath(window.location.pathname)
|
||||||
|
window.addEventListener('popstate', onPop)
|
||||||
|
return () => window.removeEventListener('popstate', onPop)
|
||||||
|
}, [])
|
||||||
|
const navigate = useCallback((to: string) => {
|
||||||
|
if (to !== window.location.pathname) {
|
||||||
|
window.history.pushState(null, '', to)
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}
|
||||||
|
setPath(to)
|
||||||
|
}, [])
|
||||||
|
return { path, navigate }
|
||||||
|
}
|
||||||
|
|
||||||
|
type Navigate = (to: string) => void
|
||||||
|
|
||||||
|
/** An in-app link that routes client-side instead of doing a full page load. */
|
||||||
|
function Link({
|
||||||
|
to,
|
||||||
|
navigate,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
to: string
|
||||||
|
navigate: Navigate
|
||||||
|
className?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={to}
|
||||||
|
className={className}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
navigate(to)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
// undefined = still checking the session; null = signed out.
|
// undefined = still checking the session; null = signed out.
|
||||||
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
||||||
|
const { path, navigate } = useRouter()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api<{ accountId: number } & SelfAccount>('/api/me')
|
api<SelfAccount>('/api/me')
|
||||||
.then((me) => setAccount(me))
|
.then((me) => setAccount(me))
|
||||||
.catch(() => setAccount(null))
|
.catch(() => setAccount(null))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -43,18 +92,183 @@ export function App() {
|
|||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
await api('/api/logout', {})
|
await api('/api/logout', {})
|
||||||
setAccount(null)
|
setAccount(null)
|
||||||
|
navigate('/')
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NavBar account={account} path={path} navigate={navigate} onLogout={logout} />
|
||||||
|
{path === '/login' ? (
|
||||||
|
<LoginPage account={account} navigate={navigate} onAuthed={setAccount} />
|
||||||
|
) : path === '/account' ? (
|
||||||
|
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
||||||
|
) : (
|
||||||
|
<HomePage />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top nav: brand → home, plus a sign-in / my-account link for the session. */
|
||||||
|
function NavBar({
|
||||||
|
account,
|
||||||
|
path,
|
||||||
|
navigate,
|
||||||
|
onLogout,
|
||||||
|
}: {
|
||||||
|
account: SelfAccount | null | undefined
|
||||||
|
path: string
|
||||||
|
navigate: Navigate
|
||||||
|
onLogout: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="nav">
|
||||||
|
<Link to="/" navigate={navigate} className="brand">
|
||||||
|
RecFlare
|
||||||
|
</Link>
|
||||||
|
<nav className="nav-links">
|
||||||
|
{account === undefined ? null : account ? (
|
||||||
|
<>
|
||||||
|
<Link to="/account" navigate={navigate} className={path === '/account' ? 'active' : ''}>
|
||||||
|
My account
|
||||||
|
</Link>
|
||||||
|
<button className="linkish" onClick={onLogout}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link to="/login" navigate={navigate} className={path === '/login' ? 'active' : ''}>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public homepage: a slideshow of recent public photos. */
|
||||||
|
function HomePage() {
|
||||||
|
return (
|
||||||
|
<main className="shell wide">
|
||||||
|
<Slideshow />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A recent public image plus who took it and where. */
|
||||||
|
interface Slide {
|
||||||
|
url: string
|
||||||
|
username: string
|
||||||
|
roomName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Slideshow() {
|
||||||
|
const [slides, setSlides] = useState<Slide[] | null>(null)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [idx, setIdx] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api<{ images: Slide[] }>('/api/slideshow')
|
||||||
|
.then((d) => setSlides(d.images))
|
||||||
|
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slides || slides.length < 2) return
|
||||||
|
const t = setInterval(() => setIdx((i) => (i + 1) % slides.length), 5000)
|
||||||
|
return () => clearInterval(t)
|
||||||
|
}, [slides])
|
||||||
|
|
||||||
|
if (error) return <p className="error">Couldn’t load the slideshow: {error}</p>
|
||||||
|
if (!slides) return <p className="muted">Loading…</p>
|
||||||
|
if (slides.length === 0) return <p className="muted">No photos yet.</p>
|
||||||
|
|
||||||
|
const slide = slides[idx]
|
||||||
|
const step = (delta: number) => setIdx((i) => (i + delta + slides.length) % slides.length)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="slideshow">
|
||||||
|
<div className="slide-stage">
|
||||||
|
<img src={slide.url} alt={`Photo by ${slide.username}`} />
|
||||||
|
{slides.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button className="slide-nav prev" onClick={() => step(-1)} aria-label="Previous photo">
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<button className="slide-nav next" onClick={() => step(1)} aria-label="Next photo">
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="slide-meta">
|
||||||
|
<div>
|
||||||
|
<span className="big">@{slide.username}</span>
|
||||||
|
{slide.roomName && <span className="muted"> · {slide.roomName}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="muted">
|
||||||
|
{idx + 1} / {slides.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sign-in page. Redirects to the account page once a session exists. */
|
||||||
|
function LoginPage({
|
||||||
|
account,
|
||||||
|
navigate,
|
||||||
|
onAuthed,
|
||||||
|
}: {
|
||||||
|
account: SelfAccount | null | undefined
|
||||||
|
navigate: Navigate
|
||||||
|
onAuthed: (a: SelfAccount) => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (account) navigate('/account')
|
||||||
|
}, [account, navigate])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
<h1>Recflare Accounts</h1>
|
<section className="card">
|
||||||
{account === undefined ? (
|
<h2>Sign in</h2>
|
||||||
<p className="muted">Loading…</p>
|
<LoginForm
|
||||||
) : account ? (
|
onAuthed={(a) => {
|
||||||
<Dashboard account={account} onChange={setAccount} onLogout={logout} />
|
onAuthed(a)
|
||||||
) : (
|
navigate('/account')
|
||||||
<AuthForms onAuthed={setAccount} />
|
}}
|
||||||
)}
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The signed-in account page. Redirects to sign-in when there's no session. */
|
||||||
|
function AccountPage({
|
||||||
|
account,
|
||||||
|
navigate,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
account: SelfAccount | null | undefined
|
||||||
|
navigate: Navigate
|
||||||
|
onChange: (a: SelfAccount) => void
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (account === null) navigate('/login')
|
||||||
|
}, [account, navigate])
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<main className="shell">
|
||||||
|
<p className="muted">{account === undefined ? 'Loading…' : 'Redirecting…'}</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="shell wide">
|
||||||
|
<h1>My account</h1>
|
||||||
|
<Dashboard account={account} onChange={onChange} />
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -81,61 +295,11 @@ function useAction() {
|
|||||||
return { pending, error, done, run }
|
return { pending, error, done, run }
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthForms({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
// Manual web signups are disabled for now, so only sign-in is exposed (accounts are
|
||||||
const [tab, setTab] = useState<'signup' | 'login'>('signup')
|
// created via the game/platform, not the website). To bring signups back, restore a
|
||||||
return (
|
// SignupForm calling POST /api/signup and re-enable that endpoint in www.app.ts.
|
||||||
<section className="card">
|
|
||||||
<div className="tabs">
|
|
||||||
<button className={tab === 'signup' ? 'active' : ''} onClick={() => setTab('signup')}>
|
|
||||||
Create account
|
|
||||||
</button>
|
|
||||||
<button className={tab === 'login' ? 'active' : ''} onClick={() => setTab('login')}>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{tab === 'signup' ? <SignupForm onAuthed={onAuthed} /> : <LoginForm onAuthed={onAuthed} />}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SignupForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
|
||||||
const [password, setPassword] = useState('')
|
|
||||||
const { pending, error, run } = useAction()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
void run(async () => {
|
|
||||||
const { account } = await api<{ account: SelfAccount }>('/api/signup', { password })
|
|
||||||
onAuthed(account)
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="muted">
|
|
||||||
A new account id is assigned automatically. Choose a password to sign in later.
|
|
||||||
</p>
|
|
||||||
<label>
|
|
||||||
Password
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
autoComplete="new-password"
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{error && <p className="error">{error}</p>}
|
|
||||||
<button type="submit" disabled={pending}>
|
|
||||||
{pending ? 'Creating…' : 'Create account'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
||||||
const [accountId, setAccountId] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
const { pending, error, run } = useAction()
|
const { pending, error, run } = useAction()
|
||||||
|
|
||||||
@@ -145,7 +309,7 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void run(async () => {
|
void run(async () => {
|
||||||
const { account } = await api<{ account: SelfAccount }>('/api/login', {
|
const { account } = await api<{ account: SelfAccount }>('/api/login', {
|
||||||
accountId,
|
username,
|
||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
onAuthed(account)
|
onAuthed(account)
|
||||||
@@ -154,13 +318,12 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<label>
|
<label>
|
||||||
Account id
|
Username
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
value={username}
|
||||||
value={accountId}
|
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
onChange={(e) => setAccountId(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -185,35 +348,141 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
|||||||
function Dashboard({
|
function Dashboard({
|
||||||
account,
|
account,
|
||||||
onChange,
|
onChange,
|
||||||
onLogout,
|
|
||||||
}: {
|
}: {
|
||||||
account: SelfAccount
|
account: SelfAccount
|
||||||
onChange: (a: SelfAccount) => void
|
onChange: (a: SelfAccount) => void
|
||||||
onLogout: () => void
|
|
||||||
}) {
|
}) {
|
||||||
|
// 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 = [
|
||||||
|
{ id: 'email', label: 'Email', render: () => <EmailForm account={account} onChange={onChange} /> },
|
||||||
|
{ id: 'password', label: 'Password', render: () => <PasswordForm /> },
|
||||||
|
...(account.isAdmin
|
||||||
|
? [
|
||||||
|
{ id: 'maintenance', label: 'Server maintenance', render: () => <MaintenanceForm /> },
|
||||||
|
{ id: 'coach', label: 'Broadcast message', render: () => <CoachMessageForm /> },
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
const [active, setActive] = useState(sections[0].id)
|
||||||
|
const current = sections.find((s) => s.id === active) ?? sections[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="row">
|
<div className="muted">Signed in as</div>
|
||||||
<div>
|
<div className="big">
|
||||||
<div className="muted">Signed in as</div>
|
{account.displayName || account.username}{' '}
|
||||||
<div className="big">
|
<span className="muted">#{account.accountId}</span>
|
||||||
{account.displayName || account.username}{' '}
|
|
||||||
<span className="muted">#{account.accountId}</span>
|
|
||||||
</div>
|
|
||||||
<div className="muted">{account.email ?? 'no email set'}</div>
|
|
||||||
</div>
|
|
||||||
<button className="ghost" onClick={onLogout}>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="muted">@{account.username}</div>
|
||||||
|
<div className="muted">{account.email ?? 'no email set'}</div>
|
||||||
</section>
|
</section>
|
||||||
<EmailForm account={account} onChange={onChange} />
|
<div className="workspace">
|
||||||
<PasswordForm />
|
<nav className="vtabs">
|
||||||
|
{sections.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
className={s.id === active ? 'active' : ''}
|
||||||
|
onClick={() => setActive(s.id)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="panel">{current.render()}</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin-only: send a coach/system message to every online player. */
|
||||||
|
function CoachMessageForm() {
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const { pending, error, done, run } = useAction()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Broadcast message</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Send a message from the Coach to every connected player. Players who aren't online
|
||||||
|
won't receive it.
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void run(async () => {
|
||||||
|
const { sent } = await api<{ sent?: number }>('/api/coach-message', {
|
||||||
|
messageContent: message,
|
||||||
|
})
|
||||||
|
setMessage('')
|
||||||
|
return `Sent to ${sent ?? 0} online player${sent === 1 ? '' : 's'}.`
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Message
|
||||||
|
<textarea
|
||||||
|
value={message}
|
||||||
|
rows={3}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{done && <p className="ok">{done}</p>}
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Sending…' : 'Send to all online'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin-only: broadcast a server-maintenance countdown to every connected client. */
|
||||||
|
function MaintenanceForm() {
|
||||||
|
const [minutes, setMinutes] = useState('5')
|
||||||
|
const { pending, error, done, run } = useAction()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<h2>Server maintenance</h2>
|
||||||
|
<p className="muted">
|
||||||
|
Broadcast a maintenance countdown to every connected client. Enter how many minutes until
|
||||||
|
maintenance starts (0 = now).
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void run(async () => {
|
||||||
|
const { connections } = await api<{ connections?: number }>('/api/maintenance', {
|
||||||
|
startsInMinutes: Number(minutes),
|
||||||
|
})
|
||||||
|
return `Notified ${connections ?? 0} connected client${connections === 1 ? '' : 's'}.`
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Starts in (minutes)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={minutes}
|
||||||
|
onChange={(e) => setMinutes(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{error && <p className="error">{error}</p>}
|
||||||
|
{done && <p className="ok">{done}</p>}
|
||||||
|
<button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Broadcasting…' : 'Broadcast maintenance'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function EmailForm({
|
function EmailForm({
|
||||||
account,
|
account,
|
||||||
onChange,
|
onChange,
|
||||||
|
|||||||
+184
-43
@@ -1,24 +1,28 @@
|
|||||||
|
/* RecFlare brand: vivid orange (#FE7101) on warm cream (#FCEFDC), sampled from the logo. */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
--bg: #f5f6f8;
|
--bg: #fdf3e7;
|
||||||
--card: #ffffff;
|
--card: #ffffff;
|
||||||
--text: #1a1d21;
|
--text: #33241a;
|
||||||
--muted: #6b7280;
|
--muted: #8a7360;
|
||||||
--border: #e2e5ea;
|
--border: #efe0cc;
|
||||||
--accent: #4f46e5;
|
--accent: #fe7101;
|
||||||
|
--accent-hover: #e86400;
|
||||||
--accent-text: #ffffff;
|
--accent-text: #ffffff;
|
||||||
--error: #dc2626;
|
--error: #dc2626;
|
||||||
--ok: #16a34a;
|
--ok: #15803d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f1115;
|
--bg: #17120d;
|
||||||
--card: #191c22;
|
--card: #221a12;
|
||||||
--text: #e6e8eb;
|
--text: #f4eadb;
|
||||||
--muted: #9aa1ab;
|
--muted: #b49b84;
|
||||||
--border: #2a2e37;
|
--border: #362a1e;
|
||||||
--accent: #6366f1;
|
--accent: #ff8320;
|
||||||
|
--accent-hover: #ff9540;
|
||||||
|
--ok: #22c55e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +45,113 @@ body {
|
|||||||
.shell {
|
.shell {
|
||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 48px 20px 64px;
|
padding: 32px 20px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Widened for the account tab layout and the homepage slideshow. */
|
||||||
|
.shell.wide {
|
||||||
|
max-width: 760px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Top navigation, shown on every page. */
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover,
|
||||||
|
.nav-links a.active {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkish {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkish:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Homepage slideshow. */
|
||||||
|
.slide-stage {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-stage img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-nav {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-nav:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-nav.prev {
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-nav.next {
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slide-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -62,28 +172,64 @@ h2 {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs {
|
/* Signed-in layout: a vertical tab rail on the left, the active section on the right. */
|
||||||
display: flex;
|
.workspace {
|
||||||
gap: 8px;
|
display: grid;
|
||||||
margin-bottom: 16px;
|
grid-template-columns: 190px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs button {
|
.vtabs {
|
||||||
flex: 1;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtabs button {
|
||||||
|
text-align: left;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
padding: 8px;
|
padding: 9px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabs button.active {
|
.vtabs button:hover {
|
||||||
border-color: var(--accent);
|
color: var(--text);
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtabs button.active {
|
||||||
|
background: var(--card);
|
||||||
|
border-color: var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The active section's card already carries its own margin; drop it inside the panel. */
|
||||||
|
.panel .card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stack the rail above the panel on narrow screens. */
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtabs {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vtabs button {
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
@@ -91,7 +237,8 @@ label {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input,
|
||||||
|
textarea {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
@@ -101,48 +248,42 @@ input {
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus {
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
textarea:focus {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 0;
|
outline-offset: 0;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
button[type='submit'],
|
button[type='submit'] {
|
||||||
.ghost {
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
|
||||||
|
|
||||||
button[type='submit'] {
|
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--accent-text);
|
color: var(--accent-text);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button[type='submit']:not(:disabled):hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
button[type='submit']:disabled {
|
button[type='submit']:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ghost {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big {
|
.big {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -7,22 +7,42 @@ it('rejects unauthenticated account reads', async () => {
|
|||||||
expect(await res.json()).toEqual({ error: 'not signed in' })
|
expect(await res.json()).toEqual({ error: 'not signed in' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires a password to sign up', async () => {
|
it('refuses manual signups (disabled)', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/api/signup', {
|
const res = await SELF.fetch('https://example.com/api/signup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({ password: 'whatever' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(403)
|
||||||
expect(await res.json()).toEqual({ error: 'A password is required.' })
|
expect(await res.json()).toEqual({ error: 'Account creation is currently disabled.' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires credentials to log in', async () => {
|
it('requires credentials to log in', async () => {
|
||||||
const res = await SELF.fetch('https://example.com/api/login', {
|
const res = await SELF.fetch('https://example.com/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { 'content-type': 'application/json' },
|
||||||
body: JSON.stringify({ accountId: '1' }),
|
body: JSON.stringify({ username: 'alice' }),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(400)
|
expect(res.status).toBe(400)
|
||||||
expect(await res.json()).toEqual({ error: 'Account id and password are required.' })
|
expect(await res.json()).toEqual({ error: 'Username and password are required.' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unauthenticated maintenance broadcast', async () => {
|
||||||
|
const res = await SELF.fetch('https://example.com/api/maintenance', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ startsInMinutes: 15 }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(await res.json()).toEqual({ error: 'not signed in' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unauthenticated coach message', async () => {
|
||||||
|
const res = await SELF.fetch('https://example.com/api/coach-message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messageContent: 'hello all' }),
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
expect(await res.json()).toEqual({ error: 'not signed in' })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import type { Env } from './context'
|
|||||||
|
|
||||||
export const authBase = (env: Env): string => `https://auth.${env.DOMAIN}`
|
export const authBase = (env: Env): string => `https://auth.${env.DOMAIN}`
|
||||||
export const accountsBase = (env: Env): string => `https://accounts.${env.DOMAIN}`
|
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}`
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST a form-urlencoded body to an upstream worker. The auth/accounts endpoints
|
* POST a form-urlencoded body to an upstream worker. The auth/accounts endpoints
|
||||||
|
|||||||
+126
-26
@@ -4,7 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import { withOnError } from '@repo/hono-helpers'
|
import { withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { accountsBase, authBase, postForm } from './upstream'
|
import { accountsBase, apiBase, authBase, imgBase, notifyBase, postForm } from './upstream'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { CookieOptions } from 'hono/utils/cookie'
|
import type { CookieOptions } from 'hono/utils/cookie'
|
||||||
@@ -22,11 +22,23 @@ import type { App } from './context'
|
|||||||
const SESSION_COOKIE = 'rf_token'
|
const SESSION_COOKIE = 'rf_token'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `create_account` needs a platform; RecNet (4) is the web platform. It's also
|
* RecNet (4) is the web platform, stamped as the token's `platform` claim on login.
|
||||||
* passed on credential logins for parity, though the auth worker ignores it there.
|
* NOT passed on signup: create_account treats an asserted platform as one to verify
|
||||||
|
* against Steam and rejects RecNet — the web signup is the (platform-less) password
|
||||||
|
* account path.
|
||||||
*/
|
*/
|
||||||
const WEB_PLATFORM = '4'
|
const WEB_PLATFORM = '4'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roles that unlock the admin controls in the UI. Mirrors the notify worker's
|
||||||
|
* `ADMIN_ROLES` gate — www only decides whether to *show* the controls; notify does
|
||||||
|
* the real enforcement (it verifies the token) on every call.
|
||||||
|
*/
|
||||||
|
const ADMIN_ROLES = new Set(['developer', 'moderator'])
|
||||||
|
|
||||||
|
/** `NotificationType.ServerMaintenance` in the notify worker's enum. */
|
||||||
|
const SERVER_MAINTENANCE = 25
|
||||||
|
|
||||||
/** Cookie flags for the session token. `secure` is dropped for local http dev. */
|
/** Cookie flags for the session token. `secure` is dropped for local http dev. */
|
||||||
function sessionCookieOptions(c: Context<App>, maxAge: number): CookieOptions {
|
function sessionCookieOptions(c: Context<App>, maxAge: number): CookieOptions {
|
||||||
const local = c.env.ENVIRONMENT === 'development' || c.env.ENVIRONMENT === 'VITEST'
|
const local = c.env.ENVIRONMENT === 'development' || c.env.ENVIRONMENT === 'VITEST'
|
||||||
@@ -44,6 +56,25 @@ function sessionToken(c: Context<App>): string | null {
|
|||||||
return getCookie(c, SESSION_COOKIE) ?? null
|
return getCookie(c, SESSION_COOKIE) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the session token carries an admin role. Decodes the JWT's `role` claim
|
||||||
|
* WITHOUT verifying — www holds no signing key, and this only gates whether admin UI
|
||||||
|
* is shown; the notify worker verifies the token before acting on it. A malformed
|
||||||
|
* token simply reads as "not admin".
|
||||||
|
*/
|
||||||
|
function isAdminToken(token: string): boolean {
|
||||||
|
const payload = token.split('.')[1]
|
||||||
|
if (!payload) return false
|
||||||
|
try {
|
||||||
|
const b64 = payload.replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
|
||||||
|
const claims = JSON.parse(atob(padded)) as { role?: unknown }
|
||||||
|
return Array.isArray(claims.role) && claims.role.some((r) => ADMIN_ROLES.has(r as string))
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Relay an upstream worker's JSON response back to the browser unchanged. */
|
/** Relay an upstream worker's JSON response back to the browser unchanged. */
|
||||||
async function relay(c: Context<App>, res: Response) {
|
async function relay(c: Context<App>, res: Response) {
|
||||||
const body = await res.text()
|
const body = await res.text()
|
||||||
@@ -76,7 +107,8 @@ async function establishSession(c: Context<App>, tokenResponse: Response) {
|
|||||||
headers: { authorization: `Bearer ${token.access_token}` },
|
headers: { authorization: `Bearer ${token.access_token}` },
|
||||||
})
|
})
|
||||||
if (!me.ok) return c.json({ error: 'failed to load account after auth' }, 502)
|
if (!me.ok) return c.json({ error: 'failed to load account after auth' }, 502)
|
||||||
return c.json({ account: await me.json() })
|
const account = (await me.json()) as Record<string, unknown>
|
||||||
|
return c.json({ account: { ...account, isAdmin: isAdminToken(token.access_token) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = new Hono<App>()
|
const app = new Hono<App>()
|
||||||
@@ -94,33 +126,27 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
// ---- BFF API ------------------------------------------------------------
|
// ---- BFF API ------------------------------------------------------------
|
||||||
|
|
||||||
// Create a new account with a login password, then start a session.
|
// Manual web signups are disabled for now — accounts are created via the game /
|
||||||
.post('/api/signup', async (c) => {
|
// platform, not the website. Kept as an explicit closed endpoint (rather than
|
||||||
const { password } = await c.req
|
// removed) so a direct POST is refused too, not just hidden in the UI. To reopen,
|
||||||
.json<{ password?: string }>()
|
// forward a platform-less `grant_type=create_account` to auth and start a session
|
||||||
.catch(() => ({}) as { password?: string })
|
// (see git history), and restore the SignupForm in the client.
|
||||||
if (!password) return c.json({ error: 'A password is required.' }, 400)
|
.post('/api/signup', (c) => c.json({ error: 'Account creation is currently disabled.' }, 403))
|
||||||
|
|
||||||
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
// Log in with a username + password, then start a session. The auth password grant
|
||||||
grant_type: 'create_account',
|
// resolves the account by `username` (case-insensitive) — web players sign in with
|
||||||
platform: WEB_PLATFORM,
|
// their username, not the numeric account id.
|
||||||
password,
|
|
||||||
})
|
|
||||||
return establishSession(c, res)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Log in with an existing account id + password, then start a session.
|
|
||||||
.post('/api/login', async (c) => {
|
.post('/api/login', async (c) => {
|
||||||
const { accountId, password } = await c.req
|
const { username, password } = await c.req
|
||||||
.json<{ accountId?: string; password?: string }>()
|
.json<{ username?: string; password?: string }>()
|
||||||
.catch(() => ({}) as { accountId?: string; password?: string })
|
.catch(() => ({}) as { username?: string; password?: string })
|
||||||
if (!accountId || !password) {
|
if (!username || !password) {
|
||||||
return c.json({ error: 'Account id and password are required.' }, 400)
|
return c.json({ error: 'Username and password are required.' }, 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
const res = await postForm(`${authBase(c.env)}/connect/token`, {
|
||||||
grant_type: 'password',
|
grant_type: 'password',
|
||||||
account_id: String(accountId),
|
username,
|
||||||
platform: WEB_PLATFORM,
|
platform: WEB_PLATFORM,
|
||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
@@ -133,6 +159,24 @@ const app = new Hono<App>()
|
|||||||
return c.json({ success: true })
|
return c.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Public homepage slideshow. Proxies the api worker's (public) slideshow feed and
|
||||||
|
// projects each image to a full img.<domain> URL the browser can load directly, so
|
||||||
|
// the page JS never has to know the upstream hosts. No session required.
|
||||||
|
.get('/api/slideshow', async (c) => {
|
||||||
|
const res = await fetch(`${apiBase(c.env)}/api/images/v1/slideshow`)
|
||||||
|
if (!res.ok) return relay(c, res)
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
Images?: Array<{ ImageName: string; Username: string; RoomName: string | null }>
|
||||||
|
ValidTill?: string
|
||||||
|
}
|
||||||
|
const images = (data.Images ?? []).map((i) => ({
|
||||||
|
url: `${imgBase(c.env)}/${i.ImageName}`,
|
||||||
|
username: i.Username,
|
||||||
|
roomName: i.RoomName,
|
||||||
|
}))
|
||||||
|
return c.json({ images, validTill: data.ValidTill ?? null })
|
||||||
|
})
|
||||||
|
|
||||||
// Current session's self account (used to restore UI state on page load).
|
// Current session's self account (used to restore UI state on page load).
|
||||||
.get('/api/me', async (c) => {
|
.get('/api/me', async (c) => {
|
||||||
const token = sessionToken(c)
|
const token = sessionToken(c)
|
||||||
@@ -146,7 +190,11 @@ const app = new Hono<App>()
|
|||||||
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
deleteCookie(c, SESSION_COOKIE, { path: '/' })
|
||||||
return c.json({ error: 'session expired' }, 401)
|
return c.json({ error: 'session expired' }, 401)
|
||||||
}
|
}
|
||||||
return relay(c, res)
|
if (!res.ok) return relay(c, res)
|
||||||
|
// Augment the self account with whether this session may use admin controls,
|
||||||
|
// read from the token's role claim (see isAdminToken).
|
||||||
|
const account = (await res.json()) as Record<string, unknown>
|
||||||
|
return c.json({ ...account, isAdmin: isAdminToken(token) })
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set the signed-in account's email.
|
// Set the signed-in account's email.
|
||||||
@@ -179,6 +227,58 @@ const app = new Hono<App>()
|
|||||||
return relay(c, res)
|
return relay(c, res)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Broadcast a ServerMaintenance countdown to every connected client. Forwards the
|
||||||
|
// session token to the notify worker, which enforces the admin-role gate — so a
|
||||||
|
// non-admin session is rejected upstream (403) even though www shows no button.
|
||||||
|
// The notification frame carries `Msg: { StartsInMinutes }`, matching the client's
|
||||||
|
// ServerMaintenance handler; the response mirrors the reference maintenance API.
|
||||||
|
.post('/api/maintenance', async (c) => {
|
||||||
|
const token = sessionToken(c)
|
||||||
|
if (!token) return c.json({ error: 'not signed in' }, 401)
|
||||||
|
|
||||||
|
const { startsInMinutes } = await c.req
|
||||||
|
.json<{ startsInMinutes?: number }>()
|
||||||
|
.catch(() => ({}) as { startsInMinutes?: number })
|
||||||
|
const minutes = Number(startsInMinutes)
|
||||||
|
const startsIn = Number.isFinite(minutes) && minutes > 0 ? Math.floor(minutes) : 0
|
||||||
|
|
||||||
|
const res = await fetch(`${notifyBase(c.env)}/internal/broadcast`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
notificationType: SERVER_MAINTENANCE,
|
||||||
|
data: { StartsInMinutes: startsIn },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) return relay(c, res)
|
||||||
|
|
||||||
|
const result = (await res.json()) as { delivered?: number }
|
||||||
|
return c.json({ success: true, starts_in_minutes: startsIn, connections: result.delivered ?? 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// Send a coach/system message to every online player. Like maintenance, this
|
||||||
|
// forwards the session token to notify, which enforces the admin-role gate.
|
||||||
|
.post('/api/coach-message', async (c) => {
|
||||||
|
const token = sessionToken(c)
|
||||||
|
if (!token) return c.json({ error: 'not signed in' }, 401)
|
||||||
|
|
||||||
|
const { messageContent } = await c.req
|
||||||
|
.json<{ messageContent?: string }>()
|
||||||
|
.catch(() => ({}) as { messageContent?: string })
|
||||||
|
const content = typeof messageContent === 'string' ? messageContent.trim() : ''
|
||||||
|
if (content === '') return c.json({ error: 'A message is required.' }, 400)
|
||||||
|
|
||||||
|
const res = await fetch(`${notifyBase(c.env)}/internal/coach-message-all`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify({ messageContent: content }),
|
||||||
|
})
|
||||||
|
if (!res.ok) return relay(c, res)
|
||||||
|
|
||||||
|
const result = (await res.json()) as { sent?: number }
|
||||||
|
return c.json({ success: true, sent: result.sent ?? 0 })
|
||||||
|
})
|
||||||
|
|
||||||
// ---- Static SPA ---------------------------------------------------------
|
// ---- Static SPA ---------------------------------------------------------
|
||||||
// Everything else is served from the built client assets. With
|
// Everything else is served from the built client assets. With
|
||||||
// `not_found_handling: single-page-application`, unknown routes return
|
// `not_found_handling: single-page-application`, unknown routes return
|
||||||
|
|||||||
@@ -1 +1,6 @@
|
|||||||
export { validateAndGetAccountId, generateToken, TOKEN_TTL_SECONDS } from './jwt'
|
export {
|
||||||
|
validateAndGetAccountId,
|
||||||
|
validateAndGetRoles,
|
||||||
|
generateToken,
|
||||||
|
TOKEN_TTL_SECONDS,
|
||||||
|
} from './jwt'
|
||||||
|
|||||||
@@ -51,6 +51,32 @@ export async function validateAndGetAccountId(
|
|||||||
return Number.isNaN(id) ? null : id
|
return Number.isNaN(id) ? null : id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a request's bearer token and return its `role` claim — the array of role
|
||||||
|
* strings stamped by {@link generateToken} (e.g. `['gameClient', 'moderator']`) — or
|
||||||
|
* `null` when the request carries no valid token (missing/malformed/expired). A valid
|
||||||
|
* token with no `role` claim yields `[]`. Callers gate privileged actions on a specific
|
||||||
|
* role being present; the shape mirrors {@link validateAndGetAccountId} so a handler can
|
||||||
|
* ask for the id or the roles the same way.
|
||||||
|
*/
|
||||||
|
export async function validateAndGetRoles(
|
||||||
|
request: Request,
|
||||||
|
secret: string
|
||||||
|
): Promise<string[] | null> {
|
||||||
|
const authHeader = request.headers.get('Authorization')
|
||||||
|
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) return null
|
||||||
|
|
||||||
|
const token = authHeader.slice('bearer '.length)
|
||||||
|
try {
|
||||||
|
const payload = await verify(token, secret, 'HS256') // checks exp/nbf/signature
|
||||||
|
return Array.isArray(payload.role)
|
||||||
|
? payload.role.filter((r): r is string => typeof r === 'string')
|
||||||
|
: []
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Scopes stamped onto every token (as a claim array). */
|
/** Scopes stamped onto every token (as a claim array). */
|
||||||
const TOKEN_SCOPES = [
|
const TOKEN_SCOPES = [
|
||||||
'profile',
|
'profile',
|
||||||
|
|||||||
Reference in New Issue
Block a user