mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[chat] censor
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 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<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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 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)
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user