diff --git a/AGENTS.md b/AGENTS.md index ba3786b..a4acae4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -422,6 +422,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). - Stored as 32-character hex string (TEXT PRIMARY KEY) - Hashtag channels: `SHA256("#name")[:16]` converted to hex +- Hashtag channel names are hashed **verbatim** (any character — `&`, capitals, spaces, accents — is valid), matching `meshcore_py` / meshcore-cli / meshcore.js, which impose no character restriction (firmware never validates or even sees the name; it only receives the precomputed secret). The New-Conversation UI defaults to normalizing names to lowercase `[a-z0-9-]`, but a "Permit capitals, whitespace, and extended characters" toggle hashes the name exactly as typed for cross-client interop. The only server-side limit is non-empty and ≤32 UTF-8 bytes including the leading `#` (the on-radio name field size). - Custom channels: User-provided or generated - Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting. - Channels may persist `path_hash_mode_override` (0/1/2); when set, channel sends temporarily switch the radio path hash mode for the duration of the send, then restore the radio default. diff --git a/app/routers/channels.py b/app/routers/channels.py index 3091620..ff86616 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -1,5 +1,4 @@ import logging -import re from hashlib import sha256 from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status @@ -121,9 +120,13 @@ def _normalize_bulk_hashtag_name(name: str) -> str | None: normalized = trimmed.lstrip("#").strip() if not normalized: return None - if len(normalized) > 31: - return None - if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized): + # Hashtag channel names are hashed verbatim (matching meshcore_py / meshcore-cli / + # meshcore.js), so any character is permitted — '&', capitals, accents, etc. all map + # to a valid SHA256-derived key. Character normalization (lowercasing / charset + # restriction) is a client-side display choice, applied by the caller before submit. + # The on-radio name field holds 32 UTF-8 bytes including the leading '#', so cap there + # to keep the stored label and the derived key in sync across clients. + if len(f"#{normalized}".encode()) > 32: return None return f"#{normalized}" diff --git a/frontend/src/components/NewMessageModal.tsx b/frontend/src/components/NewMessageModal.tsx index 2049fba..3b822f5 100644 --- a/frontend/src/components/NewMessageModal.tsx +++ b/frontend/src/components/NewMessageModal.tsx @@ -43,19 +43,30 @@ interface NewMessageModalProps { onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise; } -function validateHashtagName(channelName: string): string | null { +function validateHashtagName(channelName: string, permitExtended: boolean): string | null { if (!channelName) { return 'Channel name is required'; } + // The on-radio channel name field holds 32 UTF-8 bytes including the leading '#'. + if (new TextEncoder().encode(`#${channelName}`).length > 32) { + return 'Channel name is too long (max 32 bytes including #)'; + } + if (permitExtended) { + // Hashed verbatim, matching meshcore_py / meshcore-cli / meshcore.js — any character + // (capitals, whitespace, '&', accents, …) yields a valid SHA256-derived key. + return null; + } if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) { return 'Use letters, numbers, and single dashes (no leading/trailing dashes)'; } return null; } -function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult { +function parseBulkHashtagNames(rawText: string, permitExtended: boolean): BulkParseResult { + // When extended names are permitted, whitespace can be part of a name, so split on + // newlines/commas only. Otherwise keep the whitespace-delimited behavior. const tokens = rawText - .split(/[\s,]+/) + .split(permitExtended ? /[\n,]+/ : /[\s,]+/) .map((token) => token.trim()) .filter(Boolean); @@ -65,13 +76,13 @@ function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkPa for (const token of tokens) { const stripped = token.replace(/^#+/, ''); - const validationError = validateHashtagName(stripped); + const validationError = validateHashtagName(stripped, permitExtended); if (validationError) { invalidNames.push(token); continue; } - const normalized = permitCapitals ? stripped : stripped.toLowerCase(); + const normalized = permitExtended ? stripped : stripped.toLowerCase(); const channelName = `#${normalized}`; if (seen.has(channelName)) { continue; @@ -101,7 +112,7 @@ export function NewMessageModal({ const [channelKey, setChannelKey] = useState(''); const [bulkChannelText, setBulkChannelText] = useState(''); const [tryHistorical, setTryHistorical] = useState(false); - const [permitCapitals, setPermitCapitals] = useState(false); + const [permitExtended, setPermitExtended] = useState(false); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); const hashtagInputRef = useRef(null); @@ -114,7 +125,7 @@ export function NewMessageModal({ setChannelKey(''); setBulkChannelText(''); setTryHistorical(false); - setPermitCapitals(false); + setPermitExtended(false); setError(''); }; @@ -130,7 +141,7 @@ export function NewMessageModal({ setChannelKey(''); setBulkChannelText(''); setTryHistorical(false); - setPermitCapitals(false); + setPermitExtended(false); setError(''); setLoading(false); requestAnimationFrame(() => { @@ -146,7 +157,7 @@ export function NewMessageModal({ setChannelKey(''); setBulkChannelText(''); setTryHistorical(false); - setPermitCapitals(false); + setPermitExtended(false); setError(''); setLoading(false); requestAnimationFrame(() => { @@ -177,17 +188,17 @@ export function NewMessageModal({ await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical); } else if (tab === 'hashtag') { const channelName = name.trim(); - const validationError = validateHashtagName(channelName); + const validationError = validateHashtagName(channelName, permitExtended); if (validationError) { setError(validationError); return; } - const normalizedName = permitCapitals ? channelName : channelName.toLowerCase(); + const normalizedName = permitExtended ? channelName : channelName.toLowerCase(); await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical); } else { const { channelNames, invalidNames } = parseBulkHashtagNames( bulkChannelText, - permitCapitals + permitExtended ); if (channelNames.length === 0) { setError('Enter at least one valid channel name'); @@ -215,7 +226,7 @@ export function NewMessageModal({ const handleCreateAndAddAnother = async () => { setError(''); const channelName = name.trim(); - const validationError = validateHashtagName(channelName); + const validationError = validateHashtagName(channelName, permitExtended); if (validationError) { setError(validationError); return; @@ -223,7 +234,7 @@ export function NewMessageModal({ setLoading(true); try { - const normalizedName = permitCapitals ? channelName : channelName.toLowerCase(); + const normalizedName = permitExtended ? channelName : channelName.toLowerCase(); await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical); setName(''); hashtagInputRef.current?.focus(); @@ -375,14 +386,18 @@ export function NewMessageModal({

- Not recommended; most companions normalize to lowercase + Off normalizes to lowercase letters, numbers, and dashes. On hashes the name exactly + as typed — matches other MeshCore clients and lets you join channels with capitals, + spaces, or symbols like &.

@@ -401,22 +416,27 @@ export function NewMessageModal({ className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" />

- Paste channel names separated by lines, spaces, or commas. Leading # marks are - stripped automatically. + {permitExtended + ? 'One channel name per line (or comma-separated); spaces are kept as part of the name. Leading # marks are stripped automatically.' + : 'Paste channel names separated by lines, spaces, or commas. Leading # marks are stripped automatically.'}

- Not recommended; most companions normalize to lowercase + Off normalizes to lowercase letters, numbers, and dashes. On hashes each name + exactly as typed — matches other MeshCore clients and lets you join channels with + capitals, spaces, or symbols like &.

diff --git a/frontend/src/test/newMessageModal.test.tsx b/frontend/src/test/newMessageModal.test.tsx index d68eeb4..b24d962 100644 --- a/frontend/src/test/newMessageModal.test.tsx +++ b/frontend/src/test/newMessageModal.test.tsx @@ -111,6 +111,38 @@ describe('NewMessageModal form reset', () => { await user.click(screen.getByRole('button', { name: 'Cancel' })); expect(onClose).toHaveBeenCalled(); }); + + it('rejects extended characters when the extended toggle is off', async () => { + const user = userEvent.setup(); + renderModal(); + await switchToTab(user, 'Hashtag Channel'); + + await user.type(screen.getByPlaceholderText('channel-name'), 'Cats&Dogs'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + expect(onCreateHashtagChannel).not.toHaveBeenCalled(); + expect( + screen.getByText('Use letters, numbers, and single dashes (no leading/trailing dashes)') + ).toBeTruthy(); + }); + + it('hashes the name verbatim when the extended toggle is on', async () => { + const user = userEvent.setup(); + renderModal(); + await switchToTab(user, 'Hashtag Channel'); + + await user.click( + screen.getByRole('checkbox', { + name: /Permit capitals, whitespace, and extended characters/, + }) + ); + await user.type(screen.getByPlaceholderText('channel-name'), 'Cats&Dogs'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + expect(onCreateHashtagChannel).toHaveBeenCalledWith('#Cats&Dogs', false); + }); + }); }); describe('bulk hashtag tab', () => { @@ -158,6 +190,29 @@ describe('NewMessageModal form reset', () => { expect(onBulkAddHashtagChannels).not.toHaveBeenCalled(); expect(screen.getByText('Invalid channel names: bad_room')).toBeTruthy(); }); + + it('accepts extended names split by lines when the toggle is on', async () => { + const user = userEvent.setup(); + renderModal(true, { showBulkAddChannelTab: true }); + + await user.click( + screen.getByRole('checkbox', { + name: /Permit capitals, whitespace, and extended characters/, + }) + ); + await user.type( + screen.getByRole('textbox', { name: 'Bulk channel names' }), + '#Cats & Dogs{enter}Mesh Room' + ); + await user.click(screen.getByRole('button', { name: 'Add Channels' })); + + await waitFor(() => { + expect(onBulkAddHashtagChannels).toHaveBeenCalledWith( + ['#Cats & Dogs', '#Mesh Room'], + false + ); + }); + }); }); describe('new-contact tab', () => { diff --git a/tests/test_channels_router.py b/tests/test_channels_router.py index be8ef37..d943e17 100644 --- a/tests/test_channels_router.py +++ b/tests/test_channels_router.py @@ -83,10 +83,19 @@ class TestCreateChannel: ops_key = sha256(b"#ops").digest()[:16].hex().upper() await ChannelRepository.upsert(key=ops_key, name="#ops", is_hashtag=True) + # Names are hashed verbatim, so extended characters (e.g. '&') and underscores are + # valid; only empty/whitespace-only or over-long names are rejected server-side. response = await client.post( "/api/channels/bulk-hashtag", json={ - "channel_names": ["#ops", "mesh-room", "bad_room", "mesh-room", "another-room"], + "channel_names": [ + "#ops", + "mesh-room", + "cats&dogs", + "mesh-room", + " ", + "another-room", + ], "try_historical": False, }, ) @@ -95,10 +104,11 @@ class TestCreateChannel: data = response.json() assert [channel["name"] for channel in data["created_channels"]] == [ "#mesh-room", + "#cats&dogs", "#another-room", ] assert data["existing_count"] == 2 - assert data["invalid_names"] == ["bad_room"] + assert data["invalid_names"] == [" "] assert data["decrypt_started"] is False @pytest.mark.asyncio