Be more forgiving around hashtag channel names

This commit is contained in:
Jack Kingsman
2026-06-30 14:38:10 -07:00
parent ce4946351f
commit 7b334160ce
5 changed files with 119 additions and 30 deletions
+1
View File
@@ -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.
+7 -4
View File
@@ -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}"
+44 -24
View File
@@ -43,19 +43,30 @@ interface NewMessageModalProps {
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
}
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<HTMLInputElement>(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({
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
checked={permitExtended}
onChange={(e) => setPermitExtended(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
<span className="text-sm">
Permit capitals, whitespace, and extended characters
</span>
</label>
<p className="pl-7 text-xs text-muted-foreground">
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 &amp;.
</p>
</div>
</TabsContent>
@@ -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"
/>
<p className="text-xs text-muted-foreground">
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.'}
</p>
</div>
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
checked={permitExtended}
onChange={(e) => setPermitExtended(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
<span className="text-sm">
Permit capitals, whitespace, and extended characters
</span>
</label>
<p className="pl-7 text-xs text-muted-foreground">
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 &amp;.
</p>
</div>
</TabsContent>
@@ -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', () => {
+12 -2
View File
@@ -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