mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
[api] 2025 gameconfig swap, implement sanitize
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { isSupportedGameVersion } from '@repo/domain'
|
||||
import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain'
|
||||
import { validateAndGetVersion } from '@repo/jwt'
|
||||
|
||||
import apiConfigV2 from '../../static/api-config-v2.json'
|
||||
import gameConfigsV1All2025 from '../../static/gameconfigs-v1-all-2025.json'
|
||||
import gameConfigsV1All from '../../static/gameconfigs-v1-all.json'
|
||||
import {
|
||||
AmplitudeConfig,
|
||||
@@ -129,15 +131,40 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
// Two catalogs, one per client generation: the 2023 build and the 2025 build read
|
||||
// different keys out of this, and the 2025 one carries entries (`Screens.*`, the
|
||||
// creative-door queries) the older catalog never had.
|
||||
//
|
||||
// Which one a caller gets is decided by the token's `rn.ver` claim — the build the
|
||||
// client posted at login — since the request itself carries no version. Anything NEWER
|
||||
// than `GAME_VERSION` (20230414) gets the 2025 catalog; that build and anything older
|
||||
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718.01`), so they order
|
||||
// as strings, the same comparison `match` makes for cross-build joins.
|
||||
//
|
||||
// A request with no readable token version gets the 2023 catalog, the same body this
|
||||
// route has always served: unauthenticated is not evidence of a newer client, and this
|
||||
// stack targets `GAME_VERSION`. Like the version gate on `featuredrooms`, the claim is
|
||||
// unverified — a client that lies about its build only misconfigures itself.
|
||||
.get(
|
||||
'/api/gameconfigs/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Config'],
|
||||
summary: 'Per-game configuration',
|
||||
description: 'An opaque static catalog of per-game settings, served verbatim.',
|
||||
responses: { 200: json(JsonObject, 'The game config catalog') },
|
||||
description:
|
||||
'An opaque static catalog of per-game settings, served verbatim. There are two: a ' +
|
||||
'build NEWER than `20230414` gets the 2025 catalog, which carries keys the older one ' +
|
||||
'never had; that build and anything older get the 2023 catalog. Builds are ' +
|
||||
'date-stamped, so they compare as strings. The build is read from the token’s ' +
|
||||
'`rn.ver` claim — the request carries no version of its own — so auth is optional ' +
|
||||
'here and only selects the catalog; a request without a readable token version gets ' +
|
||||
'the 2023 one.',
|
||||
responses: { 200: json(JsonObject, 'The game config catalog for the caller’s build') },
|
||||
}),
|
||||
(c) => c.json(gameConfigsV1All)
|
||||
async (c) => {
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const newerThanTarget = version !== null && version > GAME_VERSION
|
||||
return c.json(newerThanTarget ? gameConfigsV1All2025 : gameConfigsV1All)
|
||||
}
|
||||
)
|
||||
|
||||
// The property bag the client would attach to its Statsig user. The reference server
|
||||
|
||||
@@ -19,39 +19,82 @@ import {
|
||||
stringParam,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import { containsSwears } from '../sanitize'
|
||||
import {
|
||||
censorSwears,
|
||||
containsSwears,
|
||||
DEFAULT_REPLACEMENT_CHAR,
|
||||
removeBlockedCharacters,
|
||||
} from '../sanitize'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
|
||||
/**
|
||||
* The text to check, from the JSON body the client posts (`{ "Value": "..." }`). A body
|
||||
* that isn't JSON, or carries no `Value`, reads as the empty string — which every caller
|
||||
* here treats as "nothing to object to" rather than as a bad request.
|
||||
* 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 sanitizeValue(c: Context<App>): Promise<string> {
|
||||
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
|
||||
return typeof body.Value === 'string' ? body.Value : ''
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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` echoes the input
|
||||
// value back; `isPure` reports the text is clean.
|
||||
// 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:
|
||||
'Runs display names, room names and chat through the profanity filter. There is ' +
|
||||
'no filter here — the input `Value` is echoed back verbatim as a bare JSON string ' +
|
||||
'(an empty string if the body has no `Value`).',
|
||||
'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 input text, unchanged (a bare JSON string)') },
|
||||
responses: { 200: json(BareString, 'The cleaned text (a bare JSON string)') },
|
||||
}),
|
||||
async (c) => c.json(await sanitizeValue(c))
|
||||
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,
|
||||
@@ -77,7 +120,8 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ IsPure: !containsSwears(await sanitizeValue(c)) })
|
||||
const { value } = await sanitizeRequest(c)
|
||||
return c.json({ IsPure: !containsSwears(value) })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user