fix(channels): strip leading hash prefixes from names (#2250)

Signed-off-by: Logan Johnson <loganj@squareup.com>
Signed-off-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Co-authored-by: npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
Logan Johnson
2026-07-23 12:56:10 -04:00
committed by GitHub
co-authored by npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je
parent 06e3d82b04
commit d0ab3fdb05
10 changed files with 190 additions and 8 deletions
+29
View File
@@ -7,6 +7,16 @@
use std::fmt;
use std::str::FromStr;
/// Returns the canonical display name for a channel.
///
/// Channel names are rendered with a leading `#` by clients, so surrounding
/// whitespace and user-supplied hash prefixes are removed here to keep the
/// stored name prefix-free.
pub fn canonical_channel_name(name: &str) -> &str {
name.trim_start_matches(|c: char| c == '#' || c.is_whitespace())
.trim_end()
}
/// Whether a channel is publicly visible or invite-only.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelVisibility {
@@ -167,3 +177,22 @@ impl FromStr for MemberRole {
}
}
}
#[cfg(test)]
mod tests {
use super::canonical_channel_name;
#[test]
fn channel_names_trim_whitespace_and_drop_all_leading_hashes() {
assert_eq!(canonical_channel_name("channel"), "channel");
assert_eq!(canonical_channel_name("#channel"), "channel");
assert_eq!(canonical_channel_name("###channel"), "channel");
assert_eq!(canonical_channel_name(" ###channel "), "channel");
assert_eq!(canonical_channel_name("# channel"), "channel");
assert_eq!(canonical_channel_name("### channel "), "channel");
assert_eq!(canonical_channel_name(" ### "), "");
assert_eq!(canonical_channel_name("# #"), "");
assert_eq!(canonical_channel_name("### ###"), "");
assert_eq!(canonical_channel_name("channel#topic"), "channel#topic");
}
}
+18 -1
View File
@@ -101,6 +101,11 @@ pub async fn create_channel(
)));
}
let name = buzz_core::channel::canonical_channel_name(name);
if name.trim().is_empty() {
return Err(DbError::InvalidData("channel name is required".into()));
}
let id = Uuid::new_v4();
let mut tx = pool.begin().await?;
@@ -191,6 +196,11 @@ pub async fn create_channel_with_id(
));
}
let name = buzz_core::channel::canonical_channel_name(name);
if name.trim().is_empty() {
return Err(DbError::InvalidData("channel name is required".into()));
}
let mut tx = pool.begin().await?;
let rows_affected = sqlx::query(
@@ -1041,7 +1051,7 @@ pub async fn update_channel(
pool: &PgPool,
community_id: CommunityId,
channel_id: Uuid,
updates: ChannelUpdate,
mut updates: ChannelUpdate,
) -> Result<ChannelRecord> {
if updates.name.is_none()
&& updates.description.is_none()
@@ -1053,6 +1063,13 @@ pub async fn update_channel(
));
}
if let Some(name) = updates.name.as_mut() {
*name = buzz_core::channel::canonical_channel_name(name).to_owned();
if name.is_empty() {
return Err(DbError::InvalidData("channel name is required".into()));
}
}
// Build SET clause dynamically — only include fields that are provided.
// Track parameter index for positional placeholders.
let mut set_parts: Vec<String> = Vec::new();
+7 -2
View File
@@ -2039,7 +2039,11 @@ async fn ingest_event_inner(
});
if create_name
.as_ref()
.map(|n| n.trim().is_empty())
.map(|n| {
buzz_core::channel::canonical_channel_name(n)
.trim()
.is_empty()
})
.unwrap_or(true)
{
return Err(IngestError::Rejected(
@@ -2082,6 +2086,7 @@ async fn ingest_event_inner(
if let Some(client_uuid) = channel_id {
let name = create_name.unwrap_or_default();
let name = buzz_core::channel::canonical_channel_name(&name);
let description = event.tags.iter().find_map(|t| {
if t.kind().to_string() == "about" {
@@ -2099,7 +2104,7 @@ async fn ingest_event_inner(
.create_channel_with_id(
tenant.community(),
client_uuid,
&name,
name,
channel_type,
visibility,
description.as_deref(),
@@ -445,6 +445,22 @@ pub async fn validate_admin_event(
}
}
// Validate channel names before storage. A name made entirely of
// display-prefix hashes becomes empty after canonicalization.
for t in event.tags.iter() {
if t.kind().to_string() == "name" {
match t.content() {
Some(v)
if !buzz_core::channel::canonical_channel_name(v)
.trim()
.is_empty() => {}
_ => {
return Err(anyhow::anyhow!("channel name is required"));
}
}
}
}
// Validate visibility values before storage.
for t in event.tags.iter() {
if t.kind().to_string() == "visibility" {
+60 -1
View File
@@ -620,9 +620,18 @@ pub fn build_update_channel(
));
}
}
if name
.map(buzz_core::channel::canonical_channel_name)
.is_some_and(|name| name.trim().is_empty())
{
return Err(SdkError::InvalidTag("channel name is required".into()));
}
let mut tags = vec![tag(&["h", &channel_id.to_string()])?];
if let Some(n) = name {
tags.push(tag(&["name", n])?);
tags.push(tag(&[
"name",
buzz_core::channel::canonical_channel_name(n),
])?);
}
if let Some(a) = about {
tags.push(tag(&["about", a])?);
@@ -670,6 +679,10 @@ pub fn build_create_channel(
about: Option<&str>,
ttl: Option<i32>,
) -> Result<EventBuilder, SdkError> {
let name = buzz_core::channel::canonical_channel_name(name);
if name.trim().is_empty() {
return Err(SdkError::InvalidTag("channel name is required".into()));
}
let mut tags = vec![tag(&["h", &channel_id.to_string()])?, tag(&["name", name])?];
if let Some(v) = visibility {
tags.push(tag(&["visibility", v.as_str()])?);
@@ -2381,6 +2394,21 @@ mod tests {
assert!(has_tag(&ev, "about", "new about"));
}
#[test]
fn update_channel_strips_all_leading_hashes_from_name() {
let ev =
sign(build_update_channel(uuid(), Some(" ###new-name "), None, None, None).unwrap());
assert!(has_tag(&ev, "name", "new-name"));
}
#[test]
fn update_channel_rejects_hash_only_name() {
assert!(matches!(
build_update_channel(uuid(), Some(" ### "), None, None, None),
Err(SdkError::InvalidTag(_))
));
}
#[test]
fn update_channel_visibility_and_ttl() {
let cid = uuid();
@@ -2471,6 +2499,37 @@ mod tests {
assert!(has_tag(&ev, "name", "dev"));
}
#[test]
fn create_channel_strips_all_leading_hashes_from_name() {
let ev = sign(
build_create_channel(
uuid(),
" ###dev ",
None::<Visibility>,
None::<ChannelKind>,
None,
None,
)
.unwrap(),
);
assert!(has_tag(&ev, "name", "dev"));
}
#[test]
fn create_channel_rejects_hash_only_name() {
assert!(matches!(
build_create_channel(
uuid(),
" ### ",
None::<Visibility>,
None::<ChannelKind>,
None,
None,
),
Err(SdkError::InvalidTag(_))
));
}
#[test]
fn create_channel_ephemeral_emits_ttl() {
let cid = uuid();
+2
View File
@@ -74,6 +74,8 @@ pub struct CustomEmoji {
pub url: String,
}
/// Return a channel name without client-rendered leading hash prefixes.
pub use buzz_core::channel::canonical_channel_name;
/// Channel type.
pub use buzz_core::channel::ChannelType as ChannelKind;
/// Channel visibility.
+14 -1
View File
@@ -148,6 +148,10 @@ pub fn build_create_channel(
about: Option<&str>,
ttl_seconds: Option<i32>,
) -> Result<EventBuilder, String> {
let name = buzz_sdk_pkg::canonical_channel_name(name);
if name.trim().is_empty() {
return Err("channel name is required".into());
}
let mut tags = vec![
tag(vec!["h", &channel_id.to_string()])?,
tag(vec!["name", name])?,
@@ -194,6 +198,10 @@ pub fn build_update_channel(
return Err("visibility must be \"open\" or \"private\"".into());
}
}
let name = name.map(buzz_sdk_pkg::canonical_channel_name);
if name.is_some_and(|name| name.trim().is_empty()) {
return Err("channel name is required".into());
}
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
if let Some(n) = name {
tags.push(tag(vec!["name", n])?);
@@ -842,7 +850,12 @@ pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result<EventBuild
mod tests {
use super::*;
use nostr::Keys;
#[test]
fn channel_builders_reject_hash_only_names() {
let channel_id = Uuid::new_v4();
assert!(build_create_channel(channel_id, "###", "open", "stream", None, None).is_err());
assert!(build_update_channel(channel_id, Some("###"), None, None, None).is_err());
}
/// Builder layout regression for the NIP-IA owner-of-agent archive flow.
/// Compares against `docs/nips/NIP-IA.md` §Vector 1.
#[test]
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
canonicalChannelName,
channelNamesMatch,
} from "./canonicalChannelName.ts";
test("canonicalChannelName strips interleaved leading hashes and whitespace", () => {
assert.equal(canonicalChannelName("channel"), "channel");
assert.equal(canonicalChannelName("#channel"), "channel");
assert.equal(canonicalChannelName(" ### channel "), "channel");
assert.equal(canonicalChannelName("# #"), "");
assert.equal(canonicalChannelName("### ###"), "");
assert.equal(canonicalChannelName("channel#topic"), "channel#topic");
});
test("channelNamesMatch canonicalizes both legacy names and search input", () => {
assert.equal(channelNamesMatch("#general", "general"), true);
assert.equal(channelNamesMatch("general", " #GENERAL "), true);
assert.equal(channelNamesMatch("#random", "general"), false);
});
@@ -0,0 +1,14 @@
/**
* Returns the stored form of a channel name after removing the display prefix.
* Keep this aligned with `buzz_core::channel::canonical_channel_name`.
*/
export function canonicalChannelName(name: string): string {
return name.replace(/^[#\s]+/u, "").trimEnd();
}
export function channelNamesMatch(left: string, right: string): boolean {
return (
canonicalChannelName(left).toLowerCase() ===
canonicalChannelName(right).toLowerCase()
);
}
@@ -9,6 +9,10 @@ import {
} from "lucide-react";
import type { Channel } from "@/shared/api/types";
import {
canonicalChannelName,
channelNamesMatch,
} from "@/features/channels/lib/canonicalChannelName";
import { scoreChannelMatch } from "@/features/channels/lib/channelSearchScore";
import {
type ChannelSortMode,
@@ -127,8 +131,9 @@ export function ChannelBrowserDialog({
left: 0,
width: 0,
});
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
const trimmedQuery = query.trim();
const canonicalQuery = canonicalChannelName(query);
const deferredQuery = React.useDeferredValue(canonicalQuery.toLowerCase());
const trimmedQuery = canonicalQuery;
// Immediate (non-deferred) lowercased query. The create row's visibility
// (via hasExactMatch) and its label both read from the live query so they
// can never disagree for a frame while the fuzzy filter catches up.
@@ -244,7 +249,7 @@ export function ChannelBrowserDialog({
channels.some(
(channel) =>
channel.channelType !== "dm" &&
channel.name.toLowerCase() === normalizedQuery &&
channelNamesMatch(channel.name, normalizedQuery) &&
(channelTypeFilter
? channel.channelType === channelTypeFilter
: true),