diff options
Diffstat (limited to 'packages/meshbay-hub')
3 files changed, 58 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 10049b2..fc117bf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -1,6 +1,7 @@ """Group endpoints — /v1/groups/*""" import re +import unicodedata from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -347,6 +348,25 @@ async def join_group( "owner_username": owner} +# The column's width. A longer name was a database error on PostgreSQL (a 500) +# and silently truncated on SQLite. +MAX_GROUP_NAME = 128 +# Line breaks and other C0/C1 controls, and the bidirectional overrides that +# make a name display as something other than what it is. Joiners stay: an +# emoji family is a ZWJ sequence. +_BIDI_CONTROLS = frozenset("\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069") + + +def _group_name_problem(name: str) -> str | None: + if not name: + return "A group needs a name." + if len(name) > MAX_GROUP_NAME: + return f"A group name is at most {MAX_GROUP_NAME} characters." + if any(unicodedata.category(c) == "Cc" or c in _BIDI_CONTROLS for c in name): + return "A group name cannot contain control characters." + return None + + class GroupCreateRequest(BaseModel): name: str visibility: str = "private" # public|private @@ -426,8 +446,9 @@ async def create_group( "anyone to be able to join.") name = body.name.strip() - if not name: - raise HTTPException(status_code=422, detail="A group needs a name.") + problem = _group_name_problem(name) + if problem: + raise HTTPException(status_code=422, detail=problem) # One name per owner, case-insensitively. Two *different* owners may each # have a "photos" — that is why the check is scoped to `admin_id` and why # the group's real identity stays its UUID. The DB has a unique index too diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js index bdb3dbc..ec4a5b6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js @@ -77,7 +77,7 @@ function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) { <div class="form-field"> <label class="form-label">${t('create_group.name')}</label> <input type="text" placeholder="${t('create_group.name_placeholder')}" - value=${name} onInput=${e => setName(e.target.value)} required autofocus /> + value=${name} onInput=${e => setName(e.target.value)} required autofocus maxlength="128" /> </div> <div class="form-field" style="margin-bottom:0"> @@ -374,7 +374,7 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl <div class="form-field"> <label class="form-label">${t('create_group.name')}</label> <input type="text" placeholder="${t('create_group.name_placeholder')}" - value=${name} onInput=${e => setName(e.target.value)} required autofocus /> + value=${name} onInput=${e => setName(e.target.value)} required autofocus maxlength="128" /> </div> <div class="form-field"> diff --git a/packages/meshbay-hub/tests/test_group_name_checked.py b/packages/meshbay-hub/tests/test_group_name_checked.py new file mode 100644 index 0000000..fbc8277 --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_name_checked.py @@ -0,0 +1,33 @@ +""" +A group name is checked where it is created. + +The column is 128 characters wide: a longer name was a database error on +PostgreSQL and a silent truncation on SQLite. And the name is shown to other +people — in their group list, in the invitation mail — so it carries no line +breaks or other control characters, and no bidirectional override that makes it +display as something other than what it is. +""" + +import pytest +from test_bundle_pepper import _login, _register + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name,ok", [ + ("Photos de famille", True), + ("x" * 128, True), + ("👨👩👧 Family", True), # a ZWJ sequence is a name, not a trick + ("x" * 129, False), + ("Films\nClick here", False), + ("tab\there", False), + ("evilgpj.exe", False), + (" ", False), +]) +async def test_a_group_name(client, name, ok): + await _register(client, "group_namer") + token = (await _login(client, "group_namer"))["access_token"] + r = await client.post("/v1/groups", json={"name": name}, + headers={"Authorization": f"Bearer {token}"}) + assert (r.status_code < 300) is ok, (name, r.status_code, r.text) + if not ok: + assert r.status_code == 422 |