mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
[chat] updates to privacy and filters
This commit is contained in:
@@ -556,7 +556,7 @@ export const SuccessValueEnvelope = z.object({ success: z.boolean(), value: z.nu
|
||||
/** `POST /api/sanitize/v1` JSON body — the text to clean. */
|
||||
export const SanitizeRequest = z.object({ Value: z.string() })
|
||||
|
||||
/** `POST /api/sanitize/v1/isPure` — whether the text is clean (always true here). */
|
||||
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
|
||||
export const IsPureResponse = z.object({ IsPure: z.boolean() })
|
||||
|
||||
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
|
||||
|
||||
@@ -3,7 +3,9 @@ import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import charadesWords from '../../static/charades.json'
|
||||
import communityBoard from '../../static/community-board.json'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareString,
|
||||
idParam,
|
||||
IsPureResponse,
|
||||
@@ -15,10 +17,23 @@ import {
|
||||
KeepsakeConfig,
|
||||
SanitizeRequest,
|
||||
stringParam,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import { containsSwears } 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.
|
||||
*/
|
||||
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 : ''
|
||||
}
|
||||
|
||||
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
|
||||
// sinks the client hits during load.
|
||||
export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
@@ -36,21 +51,34 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
requestBody: jsonBody(SanitizeRequest, 'The text to clean'),
|
||||
responses: { 200: json(BareString, 'The input text, unchanged (a bare JSON string)') },
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.json<{ Value?: unknown }>().catch(() => ({}) as { Value?: unknown })
|
||||
return c.json(typeof body.Value === 'string' ? body.Value : '')
|
||||
}
|
||||
async (c) => c.json(await sanitizeValue(c))
|
||||
)
|
||||
// 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: 'The yes/no form of the filter. Always `true` — nothing is filtered here.',
|
||||
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, 'Always pure') },
|
||||
responses: {
|
||||
200: json(IsPureResponse, 'Whether the text is clean'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
(c) => c.json({ IsPure: true })
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ IsPure: !containsSwears(await sanitizeValue(c)) })
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Activities -----------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Profanity } from '@2toad/profanity'
|
||||
|
||||
/**
|
||||
* The profanity filter behind `POST /api/sanitize/v1/isPure`.
|
||||
*
|
||||
* The word list is `@2toad/profanity`'s rather than one of ours: the hard part of this is
|
||||
* not naming swears, it's not flagging ordinary text — a filter that rejects "Grape
|
||||
* Escape" or "Title Screen" as a room name is worse than no filter, because the player is
|
||||
* told their name is unacceptable and can't see why. It matches whole words, so `grape`,
|
||||
* `analysis`, `assassin`, `class` and `Scunthorpe` come out clean, while leetspeak
|
||||
* (`sh1t`, `a$$hole`) and letters spaced apart (`f u c k`) do not.
|
||||
*
|
||||
* Two knobs below adjust the list for this server; the matching itself is the library's.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Words to filter on top of the library's list — the ones it doesn't carry but a room
|
||||
* full of players will find. Matched as whole words like everything else, so `kys` here
|
||||
* doesn't flag `skyscraper`.
|
||||
*/
|
||||
const EXTRA_WORDS: string[] = ['kys', 'molest']
|
||||
|
||||
/**
|
||||
* Innocent words that the list reads a swear inside of. Empty today — the whole-word
|
||||
* matching means the usual victims (`shiitake`, `Scunthorpe`, `analysis`) already pass —
|
||||
* and this is where one goes if a player ever turns up with a name it gets wrong, rather
|
||||
* than a change to how matching works.
|
||||
*/
|
||||
const ALLOWED_WORDS: string[] = []
|
||||
|
||||
/**
|
||||
* Built once per isolate, not per request: the constructor compiles the word list into a
|
||||
* regex, which is the whole reason a check costs microseconds at request time. Module
|
||||
* scope is where that cost belongs.
|
||||
*/
|
||||
const filter = new Profanity({ wholeWord: true })
|
||||
filter.addWords(EXTRA_WORDS)
|
||||
filter.whitelist.addWords(ALLOWED_WORDS)
|
||||
|
||||
/**
|
||||
* Whether `value` contains a swear. Mirrors the reference server's
|
||||
* `Sanitize.ContainsSwears`, which is the whole of what `isPure` reports.
|
||||
*
|
||||
* An empty value is clean — the client checks a field as it's being typed, and an empty
|
||||
* box is not something to refuse.
|
||||
*/
|
||||
export function containsSwears(value: string): boolean {
|
||||
return value !== '' && filter.exists(value)
|
||||
}
|
||||
@@ -1700,7 +1700,7 @@ describe('public endpoints', () => {
|
||||
expect(await ids(featuredPage)).toEqual([202])
|
||||
})
|
||||
|
||||
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
|
||||
test('POST /api/sanitize/v1 echoes the value', async () => {
|
||||
const san = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1708,10 +1708,65 @@ describe('public endpoints', () => {
|
||||
})
|
||||
expect(san.status).toBe(200)
|
||||
expect(await san.json()).toBe('hello world')
|
||||
})
|
||||
|
||||
const pure = await exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, { method: 'POST' })
|
||||
expect(pure.status).toBe(200)
|
||||
expect(await pure.json()).toEqual({ IsPure: true })
|
||||
describe('POST /api/sanitize/v1/isPure', () => {
|
||||
const isPure = async (Value?: string, authed = true) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(authed ? await bearer('42') : {}),
|
||||
},
|
||||
body: Value === undefined ? undefined : JSON.stringify({ Value }),
|
||||
})
|
||||
|
||||
test('401s without a token', async () => {
|
||||
expect((await isPure('hello', false)).status).toBe(401)
|
||||
})
|
||||
|
||||
test.each([
|
||||
'hello world',
|
||||
'My Cool Room',
|
||||
// The words a substring filter gets wrong. Rejecting these is worse than
|
||||
// missing a swear: the player is told the name is unacceptable and can't
|
||||
// see why.
|
||||
'Grape Escape',
|
||||
'Title Screen',
|
||||
'assassin',
|
||||
'Bass Pro Shop',
|
||||
'analysis of the class',
|
||||
'Scunthorpe United',
|
||||
'shiitake mushrooms',
|
||||
// Nothing to object to in an empty box — the client checks as you type.
|
||||
'',
|
||||
])('%j is pure', async (value) => {
|
||||
const res = await isPure(value)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ IsPure: true })
|
||||
})
|
||||
|
||||
test.each([
|
||||
'fuck this',
|
||||
// Leetspeak and symbol substitution are folded back to letters.
|
||||
'sh1t',
|
||||
'a$$hole',
|
||||
'n1gger',
|
||||
// A swear anywhere in the string, not just on its own.
|
||||
'my totally fucking cool room',
|
||||
// Ours, on top of the dataset — see EXTRA_PATTERNS.
|
||||
'kys',
|
||||
])('%j is not pure', async (value) => {
|
||||
const res = await isPure(value)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ IsPure: false })
|
||||
})
|
||||
|
||||
test('a body with no Value is pure rather than a bad request', async () => {
|
||||
const res = await isPure()
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ IsPure: true })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user