[chat] censor

This commit is contained in:
Devin Zuczek
2026-08-19 18:30:01 -04:00
parent e7eba28023
commit f678f87d00
6 changed files with 147 additions and 16 deletions
+1
View File
@@ -16,6 +16,7 @@
"test": "run-vitest"
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
+66 -9
View File
@@ -5,6 +5,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { censorSwears } from '../../api/src/sanitize'
import { NotificationType } from '../../notify/src/notification-types'
import { getThreadMessages } from './message-db'
import {
@@ -238,6 +239,49 @@ async function pushChatMessage(c: Context<App>, message: ChatMessage): Promise<v
}
}
/**
* The message envelope with the player's own words masked, as it will be stored.
*
* The same filter `api`'s `POST /api/sanitize/v1` runs, applied again here because
* nothing obliges the client to have called it: a message posted straight to this
* endpoint would otherwise reach every member of the thread unfiltered.
*
* Only `Data` — the text the player typed — is censored. `Type`, `Version`, the `Blocks`
* array and whatever else the client packs alongside are copied through untouched: the
* rest of the envelope is the client's own business and this server doesn't know what
* most of it means. The
* mask is one character per character, so lengths (and therefore the envelope) survive
* intact, and a Version 2 `Data` keeps its `<=>` prefix — the marker isn't a word, so
* whole-word matching never reaches it.
*
* Contents that aren't a JSON object, or whose `Data` isn't a string, are censored
* whole: a hand-written `messageContents=hi` is plain text with nothing in it to
* preserve. Text with nothing to object to comes back as the very bytes that were sent,
* which is the common case — the envelope is only rebuilt when something was masked.
*
* Blocked characters are deliberately NOT stripped the way `PreRemoveBlockedCharacters`
* strips them: chat carries emoji, and the format characters that rule removes include
* the zero-width joiners holding a multi-person emoji together.
*/
function censorContents(contents: string): string {
let envelope: unknown
try {
envelope = JSON.parse(contents)
} catch {
return censorSwears(contents)
}
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) {
return censorSwears(contents)
}
const fields = envelope as Record<string, unknown>
const data = fields.Data
if (typeof data !== 'string') return contents
const censored = censorSwears(data)
return censored === data ? contents : JSON.stringify({ ...fields, Data: censored })
}
/**
* Send a message to a thread that already exists — every message after the one that
* opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` is
@@ -255,14 +299,19 @@ async function sendToThread(c: Context<App>) {
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
// Stored exactly as sent: the envelope carries its own Type/Version and may hold
// fields we know nothing about (the client sends Version 2 with a `<=>` prefix in
// Data, and a `Blocks` array alongside it), so nothing here parses or rewrites it.
// Stored as sent but for the profanity mask: the envelope carries its own Type/Version
// and may hold fields we know nothing about (the client sends Version 2 with a `<=>`
// prefix in Data, and a `Blocks` array alongside it), so `censorContents` rewrites the
// player's `Data` and nothing else.
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
: await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: id,
contents: censorContents(contents),
})
if (posted !== null) {
await pushChatMessage(c, posted)
// Sending is reading: the reference answers with `lastReadMessageId` already at the
@@ -402,7 +451,9 @@ function sendToThreadRoute(spelling: string) {
description: [
'Every message after the one that opened the conversation. Answers',
'`{ chatResult, chatThread }` — the WHOLE thread with its messages, not just the message',
'that was sent, so the client re-renders the conversation from one response. Blank or',
'that was sent, so the client re-renders the conversation from one response. The envelopes',
'`Data` goes through the same profanity filter `api`s `POST /api/sanitize/v1` runs, masked',
'one `*` per character; every other field is stored as sent. Blank or',
'missing `messageContents` stores nothing and reports invalid-arguments (1), still with',
'the thread attached, rather than an error status. Sending is reading: the senders own',
'`lastReadMessageId` comes back already at the message just posted. Pushes',
@@ -481,9 +532,11 @@ const app = new Hono<App>()
// players already share rather than opening a second one.
//
// `messageContents` is the same envelope a message carries
// (`{"Type":0,"Version":1,"Data":"…"}`) and is stored verbatim, unparsed. The client
// also sends it blank, right after /thread/withmembers: that opens the thread without
// posting an empty message, and reports invalid-arguments the way the reference does.
// (`{"Type":0,"Version":1,"Data":"…"}`), stored as sent but for the profanity mask
// `censorContents` puts over `Data` — the same filter the later messages go through,
// since the first one is no different. The client also sends it blank, right after
// /thread/withmembers: that opens the thread without posting an empty message, and
// reports invalid-arguments the way the reference does.
.post(
'/thread',
describeRoute({
@@ -523,7 +576,11 @@ const app = new Hono<App>()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
: await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: id,
contents: censorContents(contents),
})
if (posted !== null) {
await pushChatMessage(c, posted)
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
+3 -2
View File
@@ -7,8 +7,9 @@
* `contents` is the client's envelope, e.g.
* `{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends."}`,
* where `Type` selects how the client renders `Data` (plain text, an invite, an image
* …) and `Version` versions that encoding. It is stored verbatim and served back
* untouched, so new message types need no schema change here.
* …) and `Version` versions that encoding. It is served back exactly as stored, and the
* writer (`chat.app.ts`) rewrites nothing in it but the profanity mask over `Data`, so
* new message types need no schema change here.
*
* `chatMessageId` is server-assigned and unique across all threads (AUTOINCREMENT), the
* way the client expects to be able to reference a message by id alone.
+7 -5
View File
@@ -62,8 +62,9 @@ export const NOT_A_MEMBER_RESPONSE = {
/**
* A chat message as stored and served (see message-db.ts). `contents` is the client's own
* envelope (`{"Type":0,"Version":1,"Data":"hello"}`) — stored verbatim and served back
* untouched, so new message types need no schema change. A `senderPlayerId` of -5 is the
* envelope (`{"Type":0,"Version":1,"Data":"hello"}`) — served back exactly as it was
* stored, and stored as it was sent but for the profanity mask over `Data`, so new
* message types need no schema change. A `senderPlayerId` of -5 is the
* system pseudo-player the "started a chat" / "left" notices are posted as.
*/
export const ChatMessageDto = z.object({
@@ -214,8 +215,8 @@ export const CreateThreadRequest = z.object({
.optional()
.describe(
[
'The client envelope, stored verbatim and unparsed. Blank/absent opens the thread',
'without posting a message and reports chatResult 1',
'The client envelope, stored as sent but for the profanity mask over its `Data`.',
'Blank/absent opens the thread without posting a message and reports chatResult 1',
].join(' ')
),
})
@@ -238,7 +239,8 @@ export const SendMessageRequest = z.object({
.string()
.describe(
[
'The client envelope (Type/Version/Data), stored verbatim. Blank or missing stores',
'The client envelope (Type/Version/Data). Stored as sent except for `Data`, which',
'comes back with any profanity masked one `*` per character. Blank or missing stores',
'nothing and reports chatResult 1, still with the thread attached',
].join(' ')
),
@@ -793,6 +793,25 @@ describe('POST /thread', () => {
expect(await getThreadMessages(env.DB, body.chatThread.chatThreadId)).toHaveLength(2)
})
// The first message goes through the same profanity filter every later one does.
it('masks profanity in the first message', async () => {
const caller = 884010
const contents = '{"Type":0,"Version":1,"Data":"fuck this"}'
const res = await createViaPost(
caller,
`ids=884011&messageContents=${encodeURIComponent(contents)}`
)
const body = (await res.json()) as {
chatThread: { chatThreadId: number; latestMessage: { contents: string } | null }
chatResult: number
}
expect(body.chatResult).toBe(0)
expect(body.chatThread.latestMessage?.contents).toBe(
'{"Type":0,"Version":1,"Data":"**** this"}'
)
})
// Sending to people you already have a thread with appends to it, rather than
// stranding the message in a second conversation.
it('appends to the existing thread with the same members', async () => {
@@ -1226,6 +1245,54 @@ describe('POST /thread/:id', () => {
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
})
// The same filter api's POST /api/sanitize/v1 runs — the client isn't obliged to have
// called it, so a message posted straight here must not reach the thread unfiltered.
it('masks profanity in the envelopes Data and leaves the rest of it alone', async () => {
const caller = 889012
const chatThreadId = await createThread(env.DB, [caller, 889013], null, caller)
const res = await send(
caller,
`/thread/${chatThreadId}`,
'{"Type":0,"Version":2,"Data":"<=>what the fuck man","Blocks":[]}'
)
const body = (await res.json()) as {
chatResult: number
chatThread: { messages: ChatMessage[] }
}
expect(body.chatResult).toBe(0)
// One `*` per character, so the word keeps its length; Type/Version/Blocks and the
// Version 2 `<=>` marker come through untouched.
expect(body.chatThread.messages[0]!.contents).toBe(
'{"Type":0,"Version":2,"Data":"<=>what the **** man","Blocks":[]}'
)
// Masked in the row too, not just in the response.
expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe(
body.chatThread.messages[0]!.contents
)
})
// Nothing to object to must come back as the very bytes that were sent — the envelope
// is only rebuilt when something was actually masked.
it('stores a clean envelope byte-for-byte', async () => {
const caller = 889014
const chatThreadId = await createThread(env.DB, [caller, 889015], null, caller)
const contents = '{"Type":0,"Version":2,"Data":"Grape Escape","Blocks":[{"Id":"x"}]}'
await send(caller, `/thread/${chatThreadId}`, contents)
expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe(contents)
})
// Contents that aren't an envelope are plain text with nothing in them to preserve.
it('censors contents that arent a JSON envelope whole', async () => {
const caller = 889016
const chatThreadId = await createThread(env.DB, [caller, 889017], null, caller)
await send(caller, `/thread/${chatThreadId}`, 'fuck off')
expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe('**** off')
})
it('is gated on membership and auth', async () => {
const chatThreadId = await createThread(env.DB, [889009, 889010], null, 889009)
expect((await send(889011, `/thread/${chatThreadId}`)).status).toBe(404)
+3
View File
@@ -341,6 +341,9 @@ importers:
apps/chat:
dependencies:
'@2toad/profanity':
specifier: 3.3.0
version: 3.3.0
'@repo/hono-helpers':
specifier: workspace:*
version: link:../../packages/hono-helpers