mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
256 lines
10 KiB
TypeScript
256 lines
10 KiB
TypeScript
import { Hono } from 'hono'
|
||
import { describeRoute } from 'hono-openapi'
|
||
|
||
import charadesAprilWords from '../../static/charades-april.json'
|
||
import charadesWords from '../../static/charades.json'
|
||
import communityBoard from '../../static/community-board.json'
|
||
import { authedId, unauthorized } from '../http'
|
||
import {
|
||
AUTHED,
|
||
BareString,
|
||
idParam,
|
||
IsPureResponse,
|
||
json,
|
||
JsonArray,
|
||
jsonBody,
|
||
JsonObject,
|
||
KeepsakeCategories,
|
||
KeepsakeConfig,
|
||
SanitizeRequest,
|
||
stringParam,
|
||
UNAUTHORIZED_RESPONSE,
|
||
} from '../openapi'
|
||
import {
|
||
censorSwears,
|
||
containsSwears,
|
||
DEFAULT_REPLACEMENT_CHAR,
|
||
removeBlockedCharacters,
|
||
} from '../sanitize'
|
||
|
||
import type { Context } from 'hono'
|
||
import type { App } from '../context'
|
||
|
||
/**
|
||
* A sanitize request, as the client posts it:
|
||
*
|
||
* ```json
|
||
* { "Value": "...", "ReplacementChar": "*", "Context": "RoomChat",
|
||
* "Intent": 1, "ruleset": 0, "PreRemoveBlockedCharacters": false }
|
||
* ```
|
||
*
|
||
* Fields are read case-insensitively because the client's own casing isn't consistent —
|
||
* it sends `ruleset` lowercase among otherwise PascalCase keys, and a reader that trusts
|
||
* one spelling silently ignores the other.
|
||
*
|
||
* `Context` ("RoomChat", and whatever else names the surface being checked), `Intent` and
|
||
* `ruleset` are read but not acted on: they select among the reference's several
|
||
* filtering policies and this server has one, so honouring them would mean inventing
|
||
* differences between them. A body that isn't JSON, or carries no `Value`, reads as the
|
||
* empty string — nothing to object to, rather than a bad request.
|
||
*/
|
||
async function sanitizeRequest(
|
||
c: Context<App>
|
||
): Promise<{ value: string; replacementChar: string; preRemoveBlockedCharacters: boolean }> {
|
||
const body = await c.req
|
||
.json<Record<string, unknown>>()
|
||
.catch(() => ({}) as Record<string, unknown>)
|
||
const field = (name: string): unknown => {
|
||
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
|
||
return key === undefined ? undefined : body[key]
|
||
}
|
||
const value = field('Value')
|
||
const replacementChar = field('ReplacementChar')
|
||
return {
|
||
value: typeof value === 'string' ? value : '',
|
||
replacementChar:
|
||
typeof replacementChar === 'string' && replacementChar !== ''
|
||
? replacementChar
|
||
: DEFAULT_REPLACEMENT_CHAR,
|
||
preRemoveBlockedCharacters: field('PreRemoveBlockedCharacters') === true,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The Charades word bank for a given moment: the April Fools list on April 1st, the
|
||
* ordinary list every other day.
|
||
*
|
||
* A REPLACEMENT, not an addition — the joke list stands alone for the day, which is why
|
||
* its ids (1258+) start past the end of the ordinary one rather than overlapping it. The
|
||
* client fetches the bank when the activity starts, so a game already running keeps
|
||
* whichever list it drew.
|
||
*
|
||
* The date is read in UTC, so the swap runs 00:00-23:59 UTC on April 1 for everyone
|
||
* rather than rolling around the world with local midnight. `now` is injectable so tests
|
||
* can pick a day.
|
||
*/
|
||
export function charadesWordsFor(now: Date = new Date()) {
|
||
const isAprilFools = now.getUTCMonth() === 3 && now.getUTCDate() === 1
|
||
return isAprilFools ? charadesAprilWords : charadesWords
|
||
}
|
||
|
||
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
|
||
// sinks the client hits during load.
|
||
export const gameplayRoutes = new Hono<App>({ strict: false })
|
||
// Text sanitization (display names, room names, chat). `v1` masks the swears in the
|
||
// text and hands it back; `isPure` answers the same question as a yes/no.
|
||
.post(
|
||
'/api/sanitize/v1',
|
||
describeRoute({
|
||
tags: ['Gameplay'],
|
||
summary: 'Sanitize a string',
|
||
description:
|
||
'Masks any swear in the posted `Value` and returns the cleaned text as a bare JSON ' +
|
||
'string. Each character of a swear becomes the request’s `ReplacementChar` (`*` when ' +
|
||
'it names none), so the shape of the message survives; text with nothing to object ' +
|
||
'to comes back untouched. `PreRemoveBlockedCharacters` strips control and zero-width ' +
|
||
'characters first — the ones used to break a word up so a filter misses it. ' +
|
||
'`Context`, `Intent` and `ruleset` are accepted and ignored: they pick among the ' +
|
||
'reference’s filtering policies, and this server has one.',
|
||
requestBody: jsonBody(SanitizeRequest, 'The text to clean'),
|
||
responses: { 200: json(BareString, 'The cleaned text (a bare JSON string)') },
|
||
}),
|
||
async (c) => {
|
||
const { value, replacementChar, preRemoveBlockedCharacters } = await sanitizeRequest(c)
|
||
const text = preRemoveBlockedCharacters ? removeBlockedCharacters(value) : value
|
||
return c.json(censorSwears(text, replacementChar))
|
||
}
|
||
)
|
||
// The yes/no form of the filter, and the one that actually filters: the client asks
|
||
// this before it accepts a display name, a room name or an invention title. Auth-gated,
|
||
// as the reference is — the client only ever asks while logged in.
|
||
.post(
|
||
'/api/sanitize/v1/isPure',
|
||
describeRoute({
|
||
tags: ['Gameplay'],
|
||
summary: 'Whether a string is clean',
|
||
description:
|
||
'Reports whether the posted `Value` contains a swear — the check the client runs ' +
|
||
'against a display name, room name or invention title before it accepts one. ' +
|
||
'Matching is word-boundary aware, so ordinary words that contain a swear ' +
|
||
'(`analysis`, `Scunthorpe`, `class`) are pure, while leetspeak (`sh1t`, `a$$hole`) ' +
|
||
'is not. An empty or absent `Value` is pure.',
|
||
security: AUTHED,
|
||
requestBody: jsonBody(SanitizeRequest, 'The text to check'),
|
||
responses: {
|
||
200: json(IsPureResponse, 'Whether the text is clean'),
|
||
401: UNAUTHORIZED_RESPONSE,
|
||
},
|
||
}),
|
||
async (c) => {
|
||
const id = await authedId(c)
|
||
if (id === null) return unauthorized(c)
|
||
const { value } = await sanitizeRequest(c)
|
||
return c.json({ IsPure: !containsSwears(value) })
|
||
}
|
||
)
|
||
|
||
// ---- Activities -----------------------------------------------------------
|
||
// Word bank for the Charades activity. The client requests the list by
|
||
// activity name (`.../words/Charades`); other activities have no data yet.
|
||
// On April 1st (UTC) the joke list replaces it — see `charadesWordsFor`.
|
||
.get(
|
||
'/api/activities/charades/v1/words/:activity',
|
||
describeRoute({
|
||
tags: ['Gameplay'],
|
||
summary: 'An activity’s word bank',
|
||
description:
|
||
'The words the Charades activity draws from. The client asks by activity name ' +
|
||
'(`.../words/Charades`); the name is not matched on, so every activity gets the ' +
|
||
'charades list — no other activity has data yet. On April 1st (UTC) the April ' +
|
||
'Fools word list is served in place of the ordinary one.',
|
||
parameters: [stringParam('activity', 'Activity name, e.g. `Charades`. Not matched on.')],
|
||
responses: { 200: json(JsonArray, 'The word list') },
|
||
}),
|
||
(c) => c.json(charadesWordsFor())
|
||
)
|
||
|
||
// Keepsakes (room mementos). Stubbed empty.
|
||
.get(
|
||
'/api/keepsakes/globalconfig',
|
||
describeRoute({
|
||
tags: ['Gameplay'],
|
||
summary: 'Keepsake feature switches',
|
||
description:
|
||
'Whether keepsakes (room mementos) are on and how many a room may hold. The ' +
|
||
'feature reports as enabled, but nothing stores keepsakes yet.',
|
||
responses: { 200: json(KeepsakeConfig, 'The keepsake config') },
|
||
}),
|
||
(c) =>
|
||
c.json({ KeepsakeFeatureEnabled: true, KeepsakeRoomLimit: 10, SocialXpBoostEnabled: false })
|
||
)
|
||
.get(
|
||
'/api/keepsakes/rooms/:roomId',
|
||
describeRoute({
|
||
tags: ['Gameplay'],
|
||
summary: 'A room’s keepsakes',
|
||
description:
|
||
'No keepsake storage yet. Answers 204 with no body rather than an empty list — ' +
|
||
'that is what the reference does, and the client treats a body here as data.',
|
||
parameters: [idParam('roomId', 'Room id')],
|
||
responses: { 204: { description: 'No keepsakes (empty body)' } },
|
||
}),
|
||
(c) => c.body(null, 204)
|
||
)
|
||
// A counted result set, NOT the bare list the stubs around it serve: the client parses
|
||
// this one as an object and an array fails it outright — "expected:'{', actual:'[', at
|
||
// offset:0", logged as "Failed to get keepsake categories" — which takes the keepsake
|
||
// load down with it. `TotalResults` is the length of `Results`, not a total behind a
|
||
// page; the reference returns `results.Length`.
|
||
.get(
|
||
'/api/keepsakes/categories',
|
||
describeRoute({
|
||
tags: ['Gameplay'],
|
||
summary: 'Keepsake categories',
|
||
description:
|
||
'No keepsake catalog yet, so the result set is empty — but it IS a result set ' +
|
||
'(`{ Results, TotalResults }`), not the empty list the stubs around it serve. ' +
|
||
"The client parses this one as an object and fails on an array (\"expected '{', " +
|
||
"actual '['\"), taking the keepsake load down with it. `TotalResults` counts " +
|
||
'`Results` itself — there is no paging here.',
|
||
responses: { 200: json(KeepsakeCategories, 'An empty result set') },
|
||
}),
|
||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||
)
|
||
|
||
// ---- Objectives / events / rewards ---------------------------------------
|
||
// Objectives live on the `econ` host (`updateobjective` / `myprogress`), which is
|
||
// where the client calls them — they are not served here.
|
||
.get(
|
||
'/api/communityboard/v2/current',
|
||
describeRoute({
|
||
tags: ['Gameplay'],
|
||
summary: 'The current community board',
|
||
description:
|
||
'The rotating community board on the home screen — featured player, featured room ' +
|
||
'group, announcement and image strips. Served verbatim from a static blob.',
|
||
responses: { 200: json(JsonObject, 'The community board') },
|
||
}),
|
||
(c) => c.json(communityBoard)
|
||
)
|
||
// Player events live in their own controller (routes/events.ts) — they're D1-backed
|
||
// now, unlike the stubs around them here.
|
||
.get(
|
||
'/api/announcement/v1/get',
|
||
describeRoute({
|
||
tags: ['Gameplay'],
|
||
summary: 'Announcements',
|
||
description: 'The announcement banners on the home screen. Not hydrated yet.',
|
||
responses: { 200: json(JsonArray, 'An empty list') },
|
||
}),
|
||
(c) => c.json([])
|
||
) // TODO: hydrate from JSON/announcements.json
|
||
|
||
// GameSight attribution/analytics event sink. Accept and ack without persisting.
|
||
.post(
|
||
'/api/gamesight/event',
|
||
describeRoute({
|
||
tags: ['Gameplay'],
|
||
summary: 'Analytics event sink',
|
||
description:
|
||
'The client’s GameSight attribution/analytics events. Accepted and dropped — ' +
|
||
'nothing is persisted. Answers 200 with an empty body.',
|
||
responses: { 200: { description: 'Accepted (empty body)' } },
|
||
}),
|
||
(c) => c.body(null, 200)
|
||
)
|