mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1z3hmzc9ryehxzedl5wzlvpyvja0d483peaja5zt6pd0209f9x2jspe2dxh
npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je
parent
06e3d82b04
commit
d0ab3fdb05
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user