mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[accounts] mirror regex from old client for display name
This commit is contained in:
@@ -505,7 +505,8 @@ const app = new Hono<App>()
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: {
|
||||
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
|
||||
description:
|
||||
'Empty, over 15 characters, outside the display name charset, or profane (empty body)',
|
||||
},
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
displayNameRejection,
|
||||
isValidBio,
|
||||
isValidEmail,
|
||||
MAX_DISPLAY_NAME_LENGTH,
|
||||
@@ -181,12 +182,12 @@ export const CreateAccountRequest = z.object({
|
||||
* 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, rejectionFor: (value: string) => string | null) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.superRefine((value, ctx) => {
|
||||
const rejection = nameRejection(value, label, max)
|
||||
const rejection = rejectionFor(value)
|
||||
if (rejection !== null) {
|
||||
ctx.addIssue({ code: 'custom', message: rejection })
|
||||
} else if (nameContainsSwears(value)) {
|
||||
@@ -197,15 +198,15 @@ const nameCheck = (label: string, max: number) =>
|
||||
})
|
||||
|
||||
export const DisplayNameRequest = z.object({
|
||||
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
||||
displayName: nameCheck('display name', displayNameRejection)
|
||||
.min(1)
|
||||
.describe(
|
||||
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
|
||||
`Trimmed; Latin letters, digits, spaces and .,'!?_&()#@:+- only, max ${MAX_DISPLAY_NAME_LENGTH}, no profanity. Empty or invalid is rejected (400)`
|
||||
),
|
||||
})
|
||||
|
||||
export const UsernameRequest = z.object({
|
||||
username: nameCheck('username', MAX_USERNAME_LENGTH)
|
||||
username: nameCheck('username', (value) => nameRejection(value, 'username', MAX_USERNAME_LENGTH))
|
||||
.min(1, 'You must enter a username.')
|
||||
.describe(
|
||||
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('auth-gated endpoints', () => {
|
||||
...(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
|
||||
// These names carry 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`, {
|
||||
@@ -741,8 +741,9 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The names a player chooses are alphanumeric and length-capped, by the same rule the
|
||||
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
|
||||
// The names a player chooses are charset- and length-capped by the rules in @repo/domain:
|
||||
// a username is alphanumeric (`nameRejection`), a display name takes the wider charset
|
||||
// the client's own box does (`displayNameRejection`). The limits come
|
||||
// from the client's own input boxes rather than a round number, so anything stored is
|
||||
// something the game can render and re-edit.
|
||||
//
|
||||
@@ -787,25 +788,44 @@ describe('name, email and bio validation', () => {
|
||||
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
|
||||
})
|
||||
|
||||
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
|
||||
test('PUT /account/me/displayname holds a name to the client charset, max 15', async () => {
|
||||
const headers = await authed('8802')
|
||||
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
const put = (displayName: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName }),
|
||||
headers,
|
||||
})
|
||||
|
||||
// The Rec Room build's own rule: ^[\p{Latin}0-9 .,'!?_&()#@:+-]+$
|
||||
for (const displayName of ['has space', 'punct!', 'café', "O'Neil_Jr.", '(a)&b#1@c:+-,?']) {
|
||||
expect((await put(displayName)).status, displayName).toBe(200)
|
||||
}
|
||||
|
||||
for (const displayName of [
|
||||
'semi;colon',
|
||||
'star*',
|
||||
'sla/sh',
|
||||
'Привет',
|
||||
'名前',
|
||||
'smile😀',
|
||||
'a'.repeat(16),
|
||||
]) {
|
||||
// An empty 400, matching what this route already answers for an empty name —
|
||||
// it acks with a bare `{ success: true }` and has never sent the client a body
|
||||
// on failure.
|
||||
expect(res.status, displayName).toBe(400)
|
||||
expect((await put(displayName)).status, displayName).toBe(400)
|
||||
}
|
||||
|
||||
// Trimmed before it is checked, so a name of nothing but spaces is an empty one.
|
||||
expect((await put(' ')).status).toBe(400)
|
||||
|
||||
// A swear set off by the punctuation the charset now allows is still a swear.
|
||||
for (const displayName of ['shit lord', 'Shit_Lord', 'fuck.you', 'fuck!']) {
|
||||
expect((await put(displayName)).status, displayName).toBe(400)
|
||||
}
|
||||
|
||||
// 15 is the client's box, so it must fit.
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName: 'a'.repeat(15) }),
|
||||
headers,
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
expect((await put('a'.repeat(15))).status).toBe(200)
|
||||
})
|
||||
|
||||
// Syntax comes from the `isemail` package rather than a pattern written here — this is
|
||||
|
||||
@@ -75,11 +75,13 @@ const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
|
||||
/**
|
||||
* 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.
|
||||
* A username 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. A display name may carry real spaces and punctuation
|
||||
* (`displayNameRejection`), which the filter already breaks words at; the split still
|
||||
* applies to the runs in between.
|
||||
*
|
||||
* 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`,
|
||||
|
||||
@@ -115,6 +115,34 @@ export function nameRejection(value: string, label: string, max: number): string
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The display name charset, taken from the Rec Room build's own check rather than chosen
|
||||
* here: Latin-script letters (accented ones included), digits, spaces and the punctuation
|
||||
* listed. Wider than `NAME_PATTERN` because a display name is a label other players read,
|
||||
* not something typed into a sign-in box — the username stays on the narrow rule.
|
||||
*
|
||||
* Matching the client matters in both directions: a narrower rule refuses a name the
|
||||
* game's own box accepted, and a wider one stores a name the game can't re-enter. The
|
||||
* dash stays last in the class so it reads as a literal rather than a range.
|
||||
*/
|
||||
const DISPLAY_NAME_PATTERN = /^[\p{Script=Latin}0-9 .,'!?_&()#@:+-]+$/u
|
||||
|
||||
/**
|
||||
* Why a player-supplied display name is unacceptable, or `null` when it's fine.
|
||||
*
|
||||
* Callers pass the TRIMMED name. Length is counted in code points, since the charset now
|
||||
* reaches letters outside the basic plane.
|
||||
*/
|
||||
export function displayNameRejection(value: string): string | null {
|
||||
if (glyphLength(value) > MAX_DISPLAY_NAME_LENGTH) {
|
||||
return `Your display name can be at most ${MAX_DISPLAY_NAME_LENGTH} characters.`
|
||||
}
|
||||
if (!DISPLAY_NAME_PATTERN.test(value)) {
|
||||
return "Your display name can only contain letters, numbers, spaces and .,'!?_&()#@:+-"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Letters, digits and underscores — `NAME_PATTERN` plus the one separator a room name is
|
||||
* allowed. A room name is a label other players read in a browse tile rather than
|
||||
|
||||
Reference in New Issue
Block a user