mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-10 07:31:27 -07:00
[accounts] profanity filter on user/display name
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"test": "run-vitest"
|
"test": "run-vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@2toad/profanity": "3.3.0",
|
||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/hono-helpers": "workspace:*",
|
"@repo/hono-helpers": "workspace:*",
|
||||||
"@repo/jwt": "workspace:*",
|
"@repo/jwt": "workspace:*",
|
||||||
|
|||||||
@@ -448,7 +448,9 @@ const app = new Hono<App>()
|
|||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
responses: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
|
400: {
|
||||||
|
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
|
||||||
|
},
|
||||||
401: UNAUTHORIZED_RESPONSE,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -475,7 +477,8 @@ const app = new Hono<App>()
|
|||||||
tags: ['Profile'],
|
tags: ['Profile'],
|
||||||
summary: 'Change username',
|
summary: 'Change username',
|
||||||
description: [
|
description: [
|
||||||
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
|
'Letters and digits only, at most 50 characters, and free of profanity (the same',
|
||||||
|
'word list as `api`’s `POST /api/sanitize/v1/isPure`). Rejects a name taken by another',
|
||||||
'account and requires a remaining change; on success the name is persisted and',
|
'account and requires a remaining change; on success the name is persisted and',
|
||||||
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
|
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
|
||||||
'(see the UsernameResult envelope).',
|
'(see the UsernameResult envelope).',
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
nameRejection,
|
nameRejection,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
// The profanity filter behind `api`'s `POST /api/sanitize/v1/isPure`, imported rather
|
||||||
|
// than copied so a name is held to the very same word list every other player-typed
|
||||||
|
// string is.
|
||||||
|
import { nameContainsSwears } from '../../api/src/sanitize'
|
||||||
|
|
||||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,26 +163,44 @@ export const CreateAccountRequest = z.object({
|
|||||||
* writes the player-facing sentence and there's no reason to write it twice.
|
* writes the player-facing sentence and there's no reason to write it twice.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Zod check that defers to the shared name rule, message and all. */
|
/**
|
||||||
|
* Zod check that defers to the shared name rule, message and all, and then refuses a name
|
||||||
|
* with a swear in it — the same filter, and the same word list, as `api`'s
|
||||||
|
* `POST /api/sanitize/v1/isPure`.
|
||||||
|
*
|
||||||
|
* Shape first, profanity second: a name that already broke the charset rule gets the one
|
||||||
|
* sentence that explains it rather than two, and the swear check never sees the
|
||||||
|
* punctuation the charset rule has already refused.
|
||||||
|
*/
|
||||||
const nameCheck = (label: string, max: number) =>
|
const nameCheck = (label: string, max: number) =>
|
||||||
z
|
z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
const rejection = nameRejection(value, label, max)
|
const rejection = nameRejection(value, label, max)
|
||||||
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection })
|
if (rejection !== null) {
|
||||||
|
ctx.addIssue({ code: 'custom', message: rejection })
|
||||||
|
} else if (nameContainsSwears(value)) {
|
||||||
|
// Deliberately vague about WHICH word: naming it back to the player prints the
|
||||||
|
// swear in the UI, and the player knows what they typed.
|
||||||
|
ctx.addIssue({ code: 'custom', message: `Your ${label} can't contain that word.` })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export const DisplayNameRequest = z.object({
|
export const DisplayNameRequest = z.object({
|
||||||
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
||||||
.min(1)
|
.min(1)
|
||||||
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
|
.describe(
|
||||||
|
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const UsernameRequest = z.object({
|
export const UsernameRequest = z.object({
|
||||||
username: nameCheck('username', MAX_USERNAME_LENGTH)
|
username: nameCheck('username', MAX_USERNAME_LENGTH)
|
||||||
.min(1, 'You must enter a username.')
|
.min(1, 'You must enter a username.')
|
||||||
.describe('Trimmed; letters and digits only, max 50. Must be unique and changes must remain'),
|
.describe(
|
||||||
|
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
|
||||||
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const EmailRequest = z.object({
|
export const EmailRequest = z.object({
|
||||||
|
|||||||
@@ -223,6 +223,32 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj')
|
expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('PUT /account/me/displayname 400s on a name with a swear in it', async () => {
|
||||||
|
const headers = {
|
||||||
|
...(await bearer('895')),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
}
|
||||||
|
// A name carries no spaces, so the filter has to find the swear at the seam the
|
||||||
|
// player typed instead of one.
|
||||||
|
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||||
|
...form({ displayName }),
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the words that merely contain one still get through — refusing these is worse
|
||||||
|
// than missing a swear, because the player can't see why.
|
||||||
|
for (const displayName of ['Scunthorpe', 'ClassicCar', 'Cumberland']) {
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||||
|
...form({ displayName }),
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('PUT /account/me/username 401s without a token', async () => {
|
test('PUT /account/me/username 401s without a token', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||||
...form({ username: 'whoever' }),
|
...form({ username: 'whoever' }),
|
||||||
@@ -244,6 +270,30 @@ describe('auth-gated endpoints', () => {
|
|||||||
expect(body.value).toBe('')
|
expect(body.value).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('PUT /account/me/username refuses a swear without spending a change', async () => {
|
||||||
|
const headers = {
|
||||||
|
...(await bearer('894')),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
}
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||||
|
...form({ username: 'ShitLord' }),
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as { success: boolean; error: string; value: string }
|
||||||
|
expect(body.success).toBe(false)
|
||||||
|
// Vague on purpose — the message must not print the swear back at the player.
|
||||||
|
expect(body.error).toMatch(/can't contain that word/i)
|
||||||
|
expect(body.value).toBe('')
|
||||||
|
|
||||||
|
// The schema runs before the handler, so a refused name costs none of the account's
|
||||||
|
// rationed changes.
|
||||||
|
const me = (await (
|
||||||
|
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('894') })
|
||||||
|
).json()) as { username: string; availableUsernameChanges: number }
|
||||||
|
expect(me.availableUsernameChanges).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => {
|
test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => {
|
||||||
const headers = {
|
const headers = {
|
||||||
...(await bearer('892')),
|
...(await bearer('892')),
|
||||||
|
|||||||
@@ -60,6 +60,40 @@ export function containsSwears(value: string): boolean {
|
|||||||
return value !== '' && filter.exists(value)
|
return value !== '' && filter.exists(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a space would be in a name if the charset allowed one: a lowercase-to-uppercase
|
||||||
|
* hop, the last capital of a run before a capitalised word, and either side of a run of
|
||||||
|
* digits. Applied in that order, so `ShitLord`, `XXFuckYou` and `Fucker123` each come
|
||||||
|
* apart at the seam a player wrote them with.
|
||||||
|
*/
|
||||||
|
const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
|
||||||
|
[/([a-z0-9])([A-Z])/g, '$1 $2'],
|
||||||
|
[/([A-Z]+)([A-Z][a-z])/g, '$1 $2'],
|
||||||
|
[/([A-Za-z])([0-9])/g, '$1 $2'],
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `value`, read as a NAME, contains a swear.
|
||||||
|
*
|
||||||
|
* A username or display name is letters and digits only (`nameRejection`), so it carries
|
||||||
|
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
|
||||||
|
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
|
||||||
|
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
|
||||||
|
* of a space, and the pieces are checked as words.
|
||||||
|
*
|
||||||
|
* That keeps the library's trade-off rather than reaching for substring matching, which
|
||||||
|
* is the tempting fix and the wrong one: `Scunthorpe`, `assassin`, `Classic`,
|
||||||
|
* `Cumberland` and `Shiitake` all contain a swear as a substring, and refusing someone's
|
||||||
|
* name without being able to say why is worse than missing `Bitchy`.
|
||||||
|
*/
|
||||||
|
export function nameContainsSwears(value: string): boolean {
|
||||||
|
const spaced = NAME_WORD_BOUNDARIES.reduce(
|
||||||
|
(text, [pattern, replacement]) => text.replace(pattern, replacement),
|
||||||
|
value
|
||||||
|
)
|
||||||
|
return containsSwears(spaced)
|
||||||
|
}
|
||||||
|
|
||||||
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
|
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
|
||||||
export const DEFAULT_REPLACEMENT_CHAR = '*'
|
export const DEFAULT_REPLACEMENT_CHAR = '*'
|
||||||
|
|
||||||
|
|||||||
Generated
+3
@@ -53,6 +53,9 @@ importers:
|
|||||||
|
|
||||||
apps/accounts:
|
apps/accounts:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@2toad/profanity':
|
||||||
|
specifier: 3.3.0
|
||||||
|
version: 3.3.0
|
||||||
'@repo/domain':
|
'@repo/domain':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/domain
|
version: link:../../packages/domain
|
||||||
|
|||||||
Reference in New Issue
Block a user