mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -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: {
|
responses: {
|
||||||
200: json(SuccessResponse, 'Updated'),
|
200: json(SuccessResponse, 'Updated'),
|
||||||
400: {
|
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,
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { resolver } from 'hono-openapi'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
displayNameRejection,
|
||||||
isValidBio,
|
isValidBio,
|
||||||
isValidEmail,
|
isValidEmail,
|
||||||
MAX_DISPLAY_NAME_LENGTH,
|
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
|
* sentence that explains it rather than two, and the swear check never sees the
|
||||||
* punctuation the charset rule has already refused.
|
* punctuation the charset rule has already refused.
|
||||||
*/
|
*/
|
||||||
const nameCheck = (label: string, max: number) =>
|
const nameCheck = (label: string, rejectionFor: (value: string) => string | null) =>
|
||||||
z
|
z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.superRefine((value, ctx) => {
|
.superRefine((value, ctx) => {
|
||||||
const rejection = nameRejection(value, label, max)
|
const rejection = rejectionFor(value)
|
||||||
if (rejection !== null) {
|
if (rejection !== null) {
|
||||||
ctx.addIssue({ code: 'custom', message: rejection })
|
ctx.addIssue({ code: 'custom', message: rejection })
|
||||||
} else if (nameContainsSwears(value)) {
|
} else if (nameContainsSwears(value)) {
|
||||||
@@ -197,15 +198,15 @@ const nameCheck = (label: string, max: number) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const DisplayNameRequest = z.object({
|
export const DisplayNameRequest = z.object({
|
||||||
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
displayName: nameCheck('display name', displayNameRejection)
|
||||||
.min(1)
|
.min(1)
|
||||||
.describe(
|
.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({
|
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.')
|
.min(1, 'You must enter a username.')
|
||||||
.describe(
|
.describe(
|
||||||
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
|
'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')),
|
...(await bearer('895')),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'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.
|
// player typed instead of one.
|
||||||
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
|
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
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
|
// The names a player chooses are charset- and length-capped by the rules in @repo/domain:
|
||||||
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
|
// 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
|
// from the client's own input boxes rather than a round number, so anything stored is
|
||||||
// something the game can render and re-edit.
|
// 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)
|
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')
|
const headers = await authed('8802')
|
||||||
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
|
const put = (displayName: string) =>
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||||
...form({ displayName }),
|
...form({ displayName }),
|
||||||
headers,
|
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 —
|
// 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
|
// it acks with a bare `{ success: true }` and has never sent the client a body
|
||||||
// on failure.
|
// 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.
|
// 15 is the client's box, so it must fit.
|
||||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
expect((await put('a'.repeat(15))).status).toBe(200)
|
||||||
...form({ displayName: 'a'.repeat(15) }),
|
|
||||||
headers,
|
|
||||||
})
|
|
||||||
expect(ok.status).toBe(200)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Syntax comes from the `isemail` package rather than a pattern written here — this is
|
// 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.
|
* Whether `value`, read as a NAME, contains a swear.
|
||||||
*
|
*
|
||||||
* A username or display name is letters and digits only (`nameRejection`), so it carries
|
* A username is letters and digits only (`nameRejection`), so it carries no spaces — and
|
||||||
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
|
* the filter matches whole words. Handing one to {@link containsSwears} as-is therefore
|
||||||
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
|
* only refuses a name that IS a swear and nothing else: `Fucker123` and `ShitLord` sail
|
||||||
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
|
* through. So the name is split at the boundaries a player types instead of a space, and
|
||||||
* of a space, and the pieces are checked as words.
|
* 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
|
* 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`,
|
* 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
|
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
|
* 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
|
* allowed. A room name is a label other players read in a browse tile rather than
|
||||||
|
|||||||
Reference in New Issue
Block a user