aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-01 10:06:26 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-01 10:06:26 +0200
commitcf4fdda3523015248b3ff1f3e928ce9e69cc5a12 (patch)
tree8697e66b346e04802cb376e63398b7192ccf073d /packages/meshbay-hub
parentb3c031881b38b4c95e2945c05537b6a681a096d9 (diff)
downloadmeshbay-cf4fdda3523015248b3ff1f3e928ce9e69cc5a12.tar.gz
fix(hub): a group name fits its column and carries no control characters
Over 128 characters was a 500 on PostgreSQL; line breaks, C0/C1 controls and bidi overrides are refused (joiners stay, for emoji). The creation form caps the field at 128 (F-13, what remains of it). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js4
-rw-r--r--packages/meshbay-hub/tests/test_group_name_checked.py33
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),
+ ("evil‮gpj.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