mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user