mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
206 lines
8.7 KiB
TypeScript
206 lines
8.7 KiB
TypeScript
import isEmail from 'isemail'
|
|
|
|
/**
|
|
* Limits on the free text a player can put into their account and their rooms.
|
|
*
|
|
* Shared by `accounts` and `rooms` so one rule can't drift from the other — a username
|
|
* and a room name are held to nearly the same shape (a room name also takes underscores),
|
|
* and both are typed into the same client.
|
|
*
|
|
* These check only what a player SUPPLIES. Names the server generates go around them:
|
|
* a dorm is called `@<username>'s Dorm` (see `rooms-db.ts`), which the name rule below
|
|
* would reject, and auto-assigned usernames (`SwiftFox4821`, `Player42`) happen to
|
|
* satisfy it. So validate at the request handler, never inside the db helpers.
|
|
*
|
|
* Emptiness is deliberately NOT checked here. Every caller already rejects an empty
|
|
* value in its own words, and those sentences reach players through response envelopes
|
|
* the client renders verbatim — see the client-contract notes in CLAUDE.md.
|
|
*/
|
|
|
|
/**
|
|
* Name lengths. All three come from what the CLIENT will accept in the matching input
|
|
* box, not from a round number: accepting more here would store a name the game can't
|
|
* re-enter or edit, so the server matches the box rather than being generous.
|
|
*/
|
|
export const MAX_USERNAME_LENGTH = 50
|
|
export const MAX_DISPLAY_NAME_LENGTH = 15
|
|
export const MAX_ROOM_NAME_LENGTH = 32
|
|
|
|
/**
|
|
* Club and event limits. Longer than the name limits above because these aren't
|
|
* identifiers — a club name and an event name are titles, and both allow the
|
|
* punctuation and spaces a title needs (clubs enforce their own charset rule; events
|
|
* enforce none at all, since an event is called things like "Building a Better Room
|
|
* Using Trigonometry").
|
|
*/
|
|
export const MAX_CLUB_NAME_LENGTH = 40
|
|
export const MAX_CLUB_DESCRIPTION_LENGTH = 512
|
|
export const MAX_EVENT_NAME_LENGTH = 64
|
|
export const MAX_EVENT_DESCRIPTION_LENGTH = 512
|
|
|
|
/**
|
|
* Invention limits. A name is a title a player types into the invention-save box and
|
|
* reads back in a browse tile, so it allows the punctuation a title needs — but
|
|
* nothing else, since it is also what invention search matches on. The minimum is real:
|
|
* one- and two-character names are unsearchable and unreadable in a tile, and the client
|
|
* offers `Untitled` rather than an empty box.
|
|
*/
|
|
export const MIN_INVENTION_NAME_LENGTH = 3
|
|
export const MAX_INVENTION_NAME_LENGTH = 24
|
|
export const MAX_INVENTION_DESCRIPTION_LENGTH = 512
|
|
|
|
/**
|
|
* One invention tag. Short and letters-only because tags are a controlled vocabulary the
|
|
* browse chips are derived from (see `getInventionTagFilters`) — a tag with digits,
|
|
* punctuation or spaces makes a chip nobody else will ever type again. Tags are stored
|
|
* lowercased, so the rule is checked against the normalized form, not what was typed.
|
|
*/
|
|
export const MAX_INVENTION_TAG_LENGTH = 15
|
|
|
|
/**
|
|
* Length in code points rather than UTF-16 units, so an emoji or other astral character
|
|
* counts once instead of twice — the way a player counts what they typed.
|
|
*/
|
|
export const glyphLength = (value: string): number => Array.from(value).length
|
|
|
|
/** Max length of a profile bio. */
|
|
export const MAX_BIO_LENGTH = 255
|
|
|
|
/**
|
|
* Letters and digits only — no spaces, punctuation, or accents.
|
|
*
|
|
* Deliberately narrow: these names are shown to other players, used to search, and (for
|
|
* usernames) typed into a sign-in box, so anything that can be confused for another name
|
|
* is worth refusing. It also rules out the homoglyph and right-to-left tricks that come
|
|
* with allowing arbitrary Unicode.
|
|
*/
|
|
const NAME_PATTERN = /^[A-Za-z0-9]+$/
|
|
|
|
/**
|
|
* Why a player-supplied name is unacceptable, or `null` when it's fine.
|
|
*
|
|
* `label` names the thing in the returned sentence ('username', 'room name'), so the
|
|
* message reads correctly wherever it's surfaced. `max` is required rather than
|
|
* defaulted: the three limits differ, and a caller that forgets which one it wants
|
|
* should have to say so instead of silently taking someone else's.
|
|
*/
|
|
export function nameRejection(value: string, label: string, max: number): string | null {
|
|
if (value.length > max) {
|
|
return `Your ${label} can be at most ${max} characters.`
|
|
}
|
|
if (!NAME_PATTERN.test(value)) {
|
|
return `Your ${label} can only contain letters and numbers.`
|
|
}
|
|
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
|
|
* something typed into a sign-in box, and the underscore is how players write the space
|
|
* the rule still refuses (`Laser_Tag`). It carries none of the homoglyph or
|
|
* right-to-left risk that widening to arbitrary Unicode would.
|
|
*/
|
|
const ROOM_NAME_PATTERN = /^[A-Za-z0-9_]+$/
|
|
|
|
/**
|
|
* Why a player-supplied room or subroom name is unacceptable, or `null` when it's fine.
|
|
*
|
|
* Separate from `nameRejection` rather than a flag on it: usernames are held to the
|
|
* narrower rule, and the two limits differ. `label` names the thing in the returned
|
|
* sentence ('room name', 'subroom name'), which the client renders verbatim.
|
|
*/
|
|
export function roomNameRejection(value: string, label: string): string | null {
|
|
if (value.length > MAX_ROOM_NAME_LENGTH) {
|
|
return `Your ${label} can be at most ${MAX_ROOM_NAME_LENGTH} characters.`
|
|
}
|
|
if (!ROOM_NAME_PATTERN.test(value)) {
|
|
return `Your ${label} can only contain letters, numbers and underscores.`
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Letters, digits, spaces, dashes and colons — the title charset. Wider than
|
|
* `NAME_PATTERN` because an invention is a thing with a name ("Grappling Hook v2",
|
|
* "Speed-Boost Pad"), not an identifier someone types into a sign-in box. Still no
|
|
* arbitrary Unicode, for the same homoglyph reasons.
|
|
*
|
|
* The colon is not decorative: an invention the player never named is called after the
|
|
* moment it was saved (`071126 13:10:50`), generated by the CLIENT, so a rule without it
|
|
* would refuse every unnamed save the game makes. The dash stays last in the class so it
|
|
* reads as a literal rather than a range.
|
|
*/
|
|
const INVENTION_NAME_PATTERN = /^[A-Za-z0-9 :-]+$/
|
|
|
|
/** Lowercase letters only — the normalized form a tag is stored in. */
|
|
const INVENTION_TAG_PATTERN = /^[a-z]+$/
|
|
|
|
/**
|
|
* Why a player-supplied invention name is unacceptable, or `null` when it's fine.
|
|
*
|
|
* Callers pass the TRIMMED name: leading and trailing spaces are the player's typing,
|
|
* not part of what they named the thing, and counting them toward the minimum would let
|
|
* `" a "` through.
|
|
*/
|
|
export function inventionNameRejection(value: string): string | null {
|
|
if (glyphLength(value) < MIN_INVENTION_NAME_LENGTH) {
|
|
return `Invention names must be at least ${MIN_INVENTION_NAME_LENGTH} characters.`
|
|
}
|
|
if (glyphLength(value) > MAX_INVENTION_NAME_LENGTH) {
|
|
return `Invention names can be at most ${MAX_INVENTION_NAME_LENGTH} characters.`
|
|
}
|
|
if (!INVENTION_NAME_PATTERN.test(value)) {
|
|
return 'Invention names can only contain letters, numbers, spaces, dashes and colons.'
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Why an invention description is unacceptable, or `null` when it's fine. Length only —
|
|
* a description is prose, so nothing is refused for the characters it's made of, and an
|
|
* empty one is fine (it's how a creator clears the field).
|
|
*/
|
|
export function inventionDescriptionRejection(value: string): string | null {
|
|
if (glyphLength(value) > MAX_INVENTION_DESCRIPTION_LENGTH) {
|
|
return `Invention descriptions can be at most ${MAX_INVENTION_DESCRIPTION_LENGTH} characters.`
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Why an invention tag is unacceptable, or `null` when it's fine. Pass the NORMALIZED
|
|
* tag (trimmed and lowercased, as `setInventionTags` stores it) — checking what was typed
|
|
* instead would refuse `Racing` for a capital that never reaches the database.
|
|
*/
|
|
export function inventionTagRejection(value: string): string | null {
|
|
if (value.length > MAX_INVENTION_TAG_LENGTH) {
|
|
return `Invention tags can be at most ${MAX_INVENTION_TAG_LENGTH} characters.`
|
|
}
|
|
if (!INVENTION_TAG_PATTERN.test(value)) {
|
|
return 'Invention tags can only contain letters.'
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Whether a supplied email is one worth storing — RFC 5321/5322 syntax, via `isemail`.
|
|
*
|
|
* A hand-rolled pattern is the wrong shape of work here: this is a contact address
|
|
* nothing is ever sent to in order to prove it, so the only thing a stricter regex buys
|
|
* is more edge cases to get wrong. Note it also enforces the RFC's 254-character maximum
|
|
* itself, which is why there's no separate length cap.
|
|
*
|
|
* It accepts a dotless domain (`someone@localhost`), which a dotted-domain rule would
|
|
* refuse. That's the RFC being right and the shortcut being wrong, and an undeliverable
|
|
* address costs nothing here.
|
|
*/
|
|
export function isValidEmail(value: string): boolean {
|
|
return isEmail.validate(value)
|
|
}
|
|
|
|
/** Whether a supplied bio is within the stored length. */
|
|
export function isValidBio(value: string): boolean {
|
|
return value.length <= MAX_BIO_LENGTH
|
|
}
|