diff --git a/apps/chat/package.json b/apps/chat/package.json index c770455..8634787 100644 --- a/apps/chat/package.json +++ b/apps/chat/package.json @@ -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", diff --git a/apps/chat/src/chat.app.ts b/apps/chat/src/chat.app.ts index 496002e..02b41e4 100644 --- a/apps/chat/src/chat.app.ts +++ b/apps/chat/src/chat.app.ts @@ -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, message: ChatMessage): Promise` 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 + 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) { 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 envelope’s', + '`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 sender’s own', '`lastReadMessageId` comes back already at the message just posted. Pushes', @@ -481,9 +532,11 @@ const app = new Hono() // 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() 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) diff --git a/apps/chat/src/message-db.ts b/apps/chat/src/message-db.ts index 0682193..e654efe 100644 --- a/apps/chat/src/message-db.ts +++ b/apps/chat/src/message-db.ts @@ -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. diff --git a/apps/chat/src/openapi.ts b/apps/chat/src/openapi.ts index 25bf1e0..b7f16f4 100644 --- a/apps/chat/src/openapi.ts +++ b/apps/chat/src/openapi.ts @@ -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(' ') ), diff --git a/apps/chat/src/test/integration/api.test.ts b/apps/chat/src/test/integration/api.test.ts index e840dc7..8736a6c 100644 --- a/apps/chat/src/test/integration/api.test.ts +++ b/apps/chat/src/test/integration/api.test.ts @@ -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 envelope’s 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 aren’t 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) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6699190..e8e7a79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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