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 14:02:57 -07:00
committed by GitHub
parent 719f9730d4
commit 2ea9385015
8 changed files with 221 additions and 18 deletions

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