Be more forgiving around hashtag channel names

This commit is contained in:
Jack Kingsman committed 2026-06-30 14:43:05 -07:00
1 parent ce4946351f
commit 7b334160ce
5 files changed
+119 -30

No files matched your search

+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