From d87c0fb10b734918f430ca52cba56fadeb64d9ae Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 9 Jun 2026 07:42:47 +0200 Subject: [PATCH] Stage 17: cap display-name special characters at 5 (ui + backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit display_name validation gains a rule: at most 5 special characters — the '.' / '_' punctuation (spaces, which separate words, don't count) — so a still-well-formed name can't be mostly punctuation. Mirrored in the Go ValidateDisplayName and the UI validDisplayName; both unit-tested (5 ok, 6 rejected, 'J. R. R. Tolkien' ok). Docs: FUNCTIONAL (+ _ru). --- backend/internal/account/profile.go | 14 ++++++++++++++ backend/internal/account/validate_test.go | 3 +++ docs/FUNCTIONAL.md | 3 ++- docs/FUNCTIONAL_ru.md | 3 ++- ui/src/lib/profileValidation.test.ts | 3 +++ ui/src/lib/profileValidation.ts | 9 ++++++++- 6 files changed, 32 insertions(+), 3 deletions(-) diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index 8aa7985..ec87974 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -23,6 +23,11 @@ import ( // is unbounded; auto-provisioned platform names bypass this editor validation). const maxDisplayName = 32 +// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — +// every name rune that is neither a letter nor a space) an editable display name may +// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17). +const maxDisplayNameSpecials = 5 + // maxAwayWindow bounds the daily away window's duration (midnight-wrap aware). const maxAwayWindow = 12 * time.Hour @@ -110,6 +115,15 @@ func ValidateDisplayName(raw string) (string, error) { if !displayNameRe.MatchString(name) { return "", fmt.Errorf("%w: display name has an invalid character or layout", ErrInvalidProfile) } + specials := 0 + for _, r := range name { + if r != ' ' && !unicode.IsLetter(r) { + specials++ + } + } + if specials > maxDisplayNameSpecials { + return "", fmt.Errorf("%w: display name has more than %d special characters", ErrInvalidProfile, maxDisplayNameSpecials) + } return name, nil } diff --git a/backend/internal/account/validate_test.go b/backend/internal/account/validate_test.go index 0bdee79..e0b72b9 100644 --- a/backend/internal/account/validate_test.go +++ b/backend/internal/account/validate_test.go @@ -27,6 +27,9 @@ func TestValidateDisplayName(t *testing.T) { "digit rejected": {"Name2", "", false}, "blank": {" ", "", false}, "too long": {strings.Repeat("a", 33), "", false}, + "five specials ok": {"a.a.a.a.a.a", "a.a.a.a.a.a", true}, // 5 dots + "six specials": {"a.a.a.a.a.a.a", "", false}, // 6 dots + "initials spaces ok": {"J. R. R. Tolkien", "J. R. R. Tolkien", true}, // 3 dots; spaces don't count } for name, tc := range cases { t.Run(name, func(t *testing.T) { diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index cdb87b8..61ce34c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -141,7 +141,8 @@ new chat message raises an **unread badge** on the game's menu until the chat is ### Profile & settings *(Stage 4 / 8)* Edit the display name (letters joined by a single space / "." / "_" separator, with an -optional trailing ".", up to 32 characters), the timezone (chosen as a UTC offset), the +optional trailing ".", up to 32 characters and at most 5 special characters — the "." / "_" +punctuation, spaces aside), the timezone (chosen as a UTC offset), the daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. The profile form is edited inline (no separate edit mode). Linking an email or Telegram and merging accounts are covered under "Accounts, linking & diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f9e1ad5..aac767b 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -146,7 +146,8 @@ push доставляется через платформу. ### Профиль и настройки *(Stage 4 / 8)* Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / -«_», с необязательной завершающей «.», до 32 символов), таймзоны (выбор смещения от +«_», с необязательной завершающей «.», до 32 символов и не более 5 спецсимволов — +пунктуации «.» / «_», пробелы не в счёт), таймзоны (выбор смещения от UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется сразу (без отдельного режима редактирования). Привязка email и Telegram, а также diff --git a/ui/src/lib/profileValidation.test.ts b/ui/src/lib/profileValidation.test.ts index e2fca1c..c54a4f0 100644 --- a/ui/src/lib/profileValidation.test.ts +++ b/ui/src/lib/profileValidation.test.ts @@ -19,6 +19,9 @@ describe('validDisplayName', () => { ['Name2', false], ['', false], ['a'.repeat(33), false], + ['a.a.a.a.a.a', true], // 5 dots — at the special-char limit + ['a.a.a.a.a.a.a', false], // 6 dots — over the limit + ['J. R. R. Tolkien', true], // 3 dots; spaces are not special ])('%s -> %s', (name, ok) => { expect(validDisplayName(name)).toBe(ok); }); diff --git a/ui/src/lib/profileValidation.ts b/ui/src/lib/profileValidation.ts index 89f043d..c97377f 100644 --- a/ui/src/lib/profileValidation.ts +++ b/ui/src/lib/profileValidation.ts @@ -5,6 +5,10 @@ /** maxDisplayName caps the editable display name in runes. */ export const maxDisplayName = 32; +/** maxDisplayNameSpecials caps the total special characters (the "." / "_" separators — every + * rune that is neither a letter nor a space) a display name may carry. Mirrors the Go rule. */ +export const maxDisplayNameSpecials = 5; + /** maxAwayMinutes bounds the daily away window's length (12 h). */ export const maxAwayMinutes = 12 * 60; @@ -17,7 +21,10 @@ const displayNameRe = /^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$/u; /** displayNameError returns true when the trimmed name is a valid display name. */ export function validDisplayName(raw: string): boolean { const name = raw.trim(); - return name.length > 0 && [...name].length <= maxDisplayName && displayNameRe.test(name); + const chars = [...name]; + if (name.length === 0 || chars.length > maxDisplayName || !displayNameRe.test(name)) return false; + const specials = chars.filter((c) => c !== ' ' && !/\p{L}/u.test(c)).length; + return specials <= maxDisplayNameSpecials; } // A pragmatic email check (the backend re-validates with net/mail). Rejects spaces