fix(reactions): support max-length custom emoji (#3833)

**Category:** fix
**User Impact:** Custom emoji with valid 64-character names can now be
used as reactions without errors.

**Problem:** Buzz accepted 64-character custom emoji names during
registration, but rejected them as reactions after the required
surrounding colons made the payload 66 characters. Validation also
differed between desktop, SDK, relay, and storage boundaries.

<img width="554" height="47" alt="image"
src="https://github.com/user-attachments/assets/4013452f-210e-4dd3-9003-f45ff3b28dc8"
/>

**Solution:** Keep the product limit at 64 ASCII characters for custom
emoji names, enforce it consistently when emoji sets are registered, and
allow only valid matching custom reaction payloads up to 66 characters.
Widen the reaction projection to preserve the wrapped payload while
retaining the existing 64-character limit for ordinary reactions.

<details>
<summary>File changes</summary>

**crates/buzz-sdk/src/builders.rs**
Defines the shared custom emoji boundaries and covers accepted
64-character and rejected 65-character shortcodes.

**crates/buzz-relay/src/handlers/ingest.rs**
Validates emoji-set shortcodes and permits 66-character reactions only
when they are valid colon-wrapped custom emoji with a matching tag.

**crates/buzz-db/src/event.rs**
Adds storage regression coverage for maximum-length custom emoji
reactions.

**crates/buzz-db/src/migration.rs**
Verifies the reaction column migration is applied correctly.

**desktop/src/shared/api/customEmoji.ts**
Enforces the existing 64-character shortcode maximum during desktop
normalization and registration/import.

**desktop/src/shared/api/customEmoji.test.mjs**
Covers the desktop shortcode boundary.

**migrations/0027_long_reaction_payloads.sql**
Widens stored reaction payloads to 66 characters for the two required
surrounding colons.

**schema/schema.sql**
Keeps the desired schema aligned with the migration.

</details>

## Reproduction Steps

1. Register or import a custom emoji whose ASCII shortcode is exactly 64
characters.
2. Select that emoji as a reaction to a message.
3. Confirm the reaction publishes, persists, and renders without an
error.
4. Attempt to register a 65-character shortcode and confirm it is
rejected.
5. Publish an ordinary or malformed reaction over 64 characters and
confirm the relay rejects it.

## Verification

- `cargo test -p buzz-sdk`: 243 passed
- `cargo test -p buzz-db`: 94 passed, 152 Postgres-required tests
ignored
- `pnpm test` in `desktop`: 3,859 passed
- `cargo test -p buzz-relay`: 795 passed, 9 existing
Postgres-unavailable failures, 35 ignored; new reaction boundary tests
pass directly
- `cargo fmt --all -- --check`
- `git diff --check`

Originating Buzz channel: `f2ec9671-d78e-4cde-894c-9f4c458c7f1f`

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
Taylor Ho
2026-08-05 21:02:57 +00:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 719f9730d4
commit 2ea9385015
8 changed files with 221 additions and 18 deletions
+37 -1
View File
@@ -1541,7 +1541,7 @@ mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
async fn setup_pool() -> PgPool {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
@@ -1943,6 +1943,42 @@ mod tests {
.expect("sign reaction event")
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn reaction_single_tx_stores_wrapped_max_shortcode() {
let pool = setup_pool().await;
let community = CommunityId::from_uuid(make_test_community(&pool).await);
let target = make_text_event("long custom emoji target");
insert_event(&pool, community, &target, None)
.await
.expect("insert target");
let actor = Keys::generate();
let emoji = format!(":{}:", "a".repeat(64));
let reaction = make_reaction_event(&actor, &target.id.to_hex(), &emoji);
let outcome = insert_reaction_event_with_thread_metadata(
&pool,
community,
&reaction,
None,
None,
target.id.as_bytes(),
&actor.public_key().to_bytes(),
&emoji,
)
.await
.expect("store wrapped 64-character shortcode");
assert!(matches!(
outcome,
ReactionEventInsertOutcome::Inserted {
was_inserted: true,
..
}
));
assert_eq!(emoji.chars().count(), 66);
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn reaction_single_tx_duplicate_short_circuit_stores_no_event() {
+8 -2
View File
@@ -561,7 +561,7 @@ mod tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);
assert_eq!(migrations.len(), 27);
assert_eq!(migrations.len(), 28);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(migrations[0]
@@ -919,7 +919,6 @@ mod tests {
assert!(heartbeat.contains("epoch"));
assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)"));
assert!(heartbeat.contains("_operator_global_tables"));
// Channel-id lookup index (0027): serves the tenant-independent
// `channels` lookups that carry no community_id predicate, which no
// community_id-leading index can satisfy. Covering + partial so the
@@ -940,6 +939,13 @@ mod tests {
desired_schema.contains("idx_channels_id_live"),
"desired-state schema must carry the channel-id lookup index",
);
assert_eq!(migrations[27].version, 28);
let long_reactions = migrations[27].sql.as_str();
assert!(
long_reactions.contains("ALTER TABLE reactions ALTER COLUMN emoji TYPE VARCHAR(66)")
);
assert!(desired_schema.contains("emoji VARCHAR(66) NOT NULL"));
}
#[test]
+132 -11
View File
@@ -49,6 +49,55 @@ use crate::conformance::{
state_for_request, EmitGuard, TraceAction, Verdict,
};
fn validate_custom_emoji_tags(event: &Event) -> Result<(), IngestError> {
for tag in event.tags.iter() {
let parts = tag.as_slice();
if parts.first().map(String::as_str) != Some("emoji") {
continue;
}
let shortcode = parts.get(1).ok_or_else(|| {
IngestError::Rejected("invalid: emoji tag must include a shortcode".into())
})?;
buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|err| IngestError::Rejected(format!("invalid: {err}")))?;
}
Ok(())
}
fn validate_reaction_emoji(event: &Event, emoji: &str) -> Result<(), IngestError> {
let emoji_char_count = emoji.chars().count();
if emoji_char_count <= 64 {
return Ok(());
}
let Some(shortcode) = emoji
.strip_prefix(':')
.and_then(|value| value.strip_suffix(':'))
else {
return Err(IngestError::Rejected(format!(
"invalid: reaction emoji exceeds 64 characters (got {emoji_char_count})"
)));
};
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|err| IngestError::Rejected(format!("invalid: {err}")))?;
if shortcode != normalized {
return Err(IngestError::Rejected(
"invalid: long custom emoji reaction shortcode must be canonical lowercase".into(),
));
}
let has_matching_tag = event.tags.iter().any(|tag| {
let parts = tag.as_slice();
parts.first().map(String::as_str) == Some("emoji")
&& parts.get(1).is_some_and(|value| value == shortcode)
});
if !has_matching_tag || emoji_char_count > buzz_sdk::MAX_CUSTOM_EMOJI_REACTION_LEN {
return Err(IngestError::Rejected(format!(
"invalid: reaction emoji exceeds 64 characters (got {emoji_char_count})"
)));
}
Ok(())
}
/// How the HTTP caller authenticated (for [`IngestAuth::Http`]).
#[derive(Debug, Clone)]
pub enum HttpAuthMethod {
@@ -2634,6 +2683,10 @@ async fn ingest_event_inner(
));
}
if kind_u32 == KIND_EMOJI_SET || kind_u32 == KIND_EMOJI_LIST {
validate_custom_emoji_tags(&event)?;
}
// Resolve the target reference, then use one DB transaction to upsert the
// reaction row (dedup via ON CONFLICT) with reaction_event_id already set and
// store the kind:7 event. This replaces the post-storage side-effect handler.
@@ -2672,17 +2725,7 @@ async fn ingest_event_inner(
&event.content
};
// Mirror the SDK's 64-character emoji limit server-side so raw clients
// cannot bypass it. Uses chars().count() (not byte len) to match the
// SDK's check_emoji_len, which also counts Unicode characters.
const MAX_REACTION_EMOJI_CHARS: usize = 64;
let emoji_char_count = emoji.chars().count();
if emoji_char_count > MAX_REACTION_EMOJI_CHARS {
return Err(IngestError::Rejected(format!(
"invalid: reaction emoji exceeds {} characters (got {})",
MAX_REACTION_EMOJI_CHARS, emoji_char_count
)));
}
validate_reaction_emoji(&event, emoji)?;
// Atomically upsert the reaction row with this kind:7 event id, then store
// the event in the same transaction. Ordering is load-bearing: active
@@ -2916,6 +2959,84 @@ mod tests {
};
use nostr::{EventBuilder, Kind};
#[test]
fn reaction_validation_accepts_wrapped_max_shortcode() {
let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN);
let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:"))
.tags([
nostr::Tag::parse(["emoji", &shortcode, "https://example.com/max.png"])
.expect("emoji tag"),
])
.sign_with_keys(&nostr::Keys::generate())
.expect("sign reaction");
assert!(validate_reaction_emoji(&event, &event.content).is_ok());
}
#[test]
fn reaction_validation_rejects_mixed_case_max_shortcode() {
let shortcode = "Ab".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN / 2);
let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:"))
.tags([
nostr::Tag::parse(["emoji", &shortcode, "https://example.com/max.png"])
.expect("emoji tag"),
])
.sign_with_keys(&nostr::Keys::generate())
.expect("sign reaction");
assert!(matches!(
validate_reaction_emoji(&event, &event.content),
Err(IngestError::Rejected(_))
));
}
#[test]
fn reaction_validation_rejects_case_mismatched_tag() {
let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN);
let uppercase_shortcode = shortcode.to_uppercase();
let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:"))
.tags([nostr::Tag::parse([
"emoji",
&uppercase_shortcode,
"https://example.com/max.png",
])
.expect("emoji tag")])
.sign_with_keys(&nostr::Keys::generate())
.expect("sign reaction");
assert!(matches!(
validate_reaction_emoji(&event, &event.content),
Err(IngestError::Rejected(_))
));
}
#[test]
fn emoji_set_validation_enforces_shortcode_boundary() {
let max_shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN);
let valid_event = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "")
.tags([
nostr::Tag::parse(["emoji", &max_shortcode, "https://example.com/max.png"])
.expect("emoji tag"),
])
.sign_with_keys(&nostr::Keys::generate())
.expect("sign valid emoji set");
assert!(validate_custom_emoji_tags(&valid_event).is_ok());
let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 1);
let event = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "")
.tags([
nostr::Tag::parse(["emoji", &shortcode, "https://example.com/long.png"])
.expect("emoji tag"),
])
.sign_with_keys(&nostr::Keys::generate())
.expect("sign emoji set");
assert!(matches!(
validate_custom_emoji_tags(&event),
Err(IngestError::Rejected(message)) if message.contains("exceeds 64 bytes")
));
}
/// A banned relay admin must be refused with the same wire prefix and
/// transport status as every other durable-restriction refusal:
/// `blocked:` and (via `bridge.rs`'s `AuthFailed` arm) HTTP 403 — never
+31 -2
View File
@@ -120,6 +120,11 @@ fn check_repo_id(repo_id: &str) -> Result<(), SdkError> {
Ok(())
}
/// Maximum length of a custom emoji shortcode.
pub const MAX_CUSTOM_EMOJI_SHORTCODE_LEN: usize = 64;
/// Maximum reaction payload length for a colon-wrapped custom emoji shortcode.
pub const MAX_CUSTOM_EMOJI_REACTION_LEN: usize = MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 2;
/// Validate and normalize a NIP-30 custom emoji shortcode.
///
/// Shortcodes are case-insensitive in Buzz's relay-global set; lowercase
@@ -131,9 +136,9 @@ pub fn normalize_custom_emoji_shortcode(shortcode: &str) -> Result<String, SdkEr
"emoji shortcode must not be empty".into(),
));
}
if trimmed.len() > 64 {
if trimmed.len() > MAX_CUSTOM_EMOJI_SHORTCODE_LEN {
return Err(SdkError::InvalidInput(format!(
"emoji shortcode exceeds 64 bytes (got {})",
"emoji shortcode exceeds {MAX_CUSTOM_EMOJI_SHORTCODE_LEN} bytes (got {})",
trimmed.len()
)));
}
@@ -2654,6 +2659,30 @@ mod tests {
assert!(has_tag(&ev, "emoji", "party_parrot"));
}
#[test]
fn custom_emoji_reaction_accepts_max_shortcode_length() {
let eid = event_id();
let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN);
let ev = sign(
build_custom_emoji_reaction(eid, &shortcode, "https://example.com/max.png").unwrap(),
);
assert_eq!(ev.content, format!(":{shortcode}:"));
assert_eq!(ev.content.chars().count(), MAX_CUSTOM_EMOJI_REACTION_LEN);
assert!(has_tag(&ev, "emoji", &shortcode));
}
#[test]
fn custom_emoji_reaction_rejects_overlong_shortcode() {
let eid = event_id();
let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 1);
assert!(matches!(
build_custom_emoji_reaction(eid, &shortcode, "https://example.com/too-long.png"),
Err(SdkError::InvalidInput(message)) if message.contains("exceeds 64 bytes")
));
}
#[test]
fn custom_emoji_set_happy_path() {
let ev = sign(
@@ -85,6 +85,11 @@ test("normalizeShortcode rejects invalid chars and empties", () => {
assert.equal(normalizeShortcode(""), null);
});
test("normalizeShortcode enforces the 64-character boundary", () => {
assert.equal(normalizeShortcode("a".repeat(64)), "a".repeat(64));
assert.equal(normalizeShortcode("a".repeat(65)), null);
});
test("suggestShortcodeFromFilename derives a valid name from common filenames", () => {
assert.equal(
suggestShortcodeFromFilename("Party Parrot.gif"),
+4 -1
View File
@@ -44,6 +44,7 @@ export function reactionEmojiUrl(
/** NIP-30 shortcode chars. Matches the relay's `[A-Za-z0-9_-]` validation. */
const SHORTCODE_RE = /^[a-z0-9_-]+$/;
const MAX_SHORTCODE_LENGTH = 64;
/**
* Normalize a shortcode the same way the relay does: strip surrounding colons
@@ -52,7 +53,9 @@ const SHORTCODE_RE = /^[a-z0-9_-]+$/;
export function normalizeShortcode(raw: string): string | null {
const stripped = raw.trim().replace(/^:+/, "").replace(/:+$/, "");
const lower = stripped.toLowerCase();
return SHORTCODE_RE.test(lower) ? lower : null;
return lower.length <= MAX_SHORTCODE_LENGTH && SHORTCODE_RE.test(lower)
? lower
: null;
}
/**
@@ -0,0 +1,3 @@
-- A valid 64-character custom emoji shortcode is wrapped as `:shortcode:` in
-- NIP-25 reaction content. Preserve the wrapper in the reaction projection.
ALTER TABLE reactions ALTER COLUMN emoji TYPE VARCHAR(66);
+1 -1
View File
@@ -539,7 +539,7 @@ CREATE TABLE reactions (
event_created_at TIMESTAMPTZ NOT NULL,
event_id BYTEA NOT NULL,
pubkey BYTEA NOT NULL,
emoji VARCHAR(64) NOT NULL,
emoji VARCHAR(66) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
removed_at TIMESTAMPTZ,
reaction_event_id BYTEA,