From 9af431460bdc0dcb8948d363b6ab388d2a4d8aad Mon Sep 17 00:00:00 2001 From: Matthew Campbell Date: Sat, 1 Aug 2026 04:04:29 +0700 Subject: [PATCH 1/4] feat(stickers): add Sonar sticker packs (rebased from #2968) Rebases vincenzopalazzo:feat/sonar-stickers onto current main (182 commits newer). Conflict resolutions: - ingest.rs / relay_admin.rs: union of main's project + team-catalog validation with the sticker pack/list/reference validators. - buzz-db event.rs: main's NIP-09 created_at deletion predicate now runs inside the PR's advisory-lock transaction. - Migration renumbered 0025 -> 0027 (0025_relay_invites and 0026_replica_heartbeat landed on main); migration test expects 27. - Desktop: adopted main's MessageComposer.types.ts and ComposerDockToolbar, dropping the PR's resurrected MessageComposerProps.ts; settings sections stay extracted in lib/settingsSections.ts with main's 'voice' section added. - Mobile message_content.dart: main's KeyedSubtree + trailing-gallery body composed with the sticker preview branch. main removed check-file-sizes.mjs's per-file override map, so the four files the sticker work grew past the ceiling were split rather than exempted: - desktop/src-tauri: NIP-IA builders + their tests -> identity_archive_events.rs - shared/api/tauri.ts: NIP-44 + NIP-AB pairing calls -> api/pairing.ts - MessageComposer.tsx: edit payload, emoji insertion, autocomplete key dispatch, and the sticker send path -> four sibling modules Co-Authored-By: Claude Fable 5 Signed-off-by: Matthew Campbell Co-authored-by: Vincenzo Palazzo --- Cargo.lock | 28 + Cargo.toml | 3 + crates/buzz-cli/Cargo.toml | 3 + crates/buzz-cli/src/client.rs | 107 ++ crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/commands/stickers.rs | 1011 +++++++++++++++++ crates/buzz-cli/src/lib.rs | 69 ++ crates/buzz-core/Cargo.toml | 1 + crates/buzz-core/src/kind.rs | 23 +- crates/buzz-core/src/lib.rs | 2 + crates/buzz-core/src/network.rs | 4 + crates/buzz-core/src/stickers.rs | 523 +++++++++ crates/buzz-db/Cargo.toml | 1 + crates/buzz-db/src/event.rs | 16 +- crates/buzz-db/src/lib.rs | 2 + crates/buzz-db/src/migration.rs | 11 +- crates/buzz-db/src/sticker_catalog.rs | 517 +++++++++ crates/buzz-media/src/lib.rs | 5 +- crates/buzz-media/src/validation.rs | 286 ++++- crates/buzz-relay/Cargo.toml | 1 + crates/buzz-relay/src/api/media.rs | 448 +++++++- crates/buzz-relay/src/handlers/event.rs | 4 +- crates/buzz-relay/src/handlers/ingest.rs | 61 +- crates/buzz-relay/src/handlers/relay_admin.rs | 213 +++- .../buzz-relay/src/handlers/side_effects.rs | 56 +- crates/buzz-relay/src/nip11.rs | 2 +- crates/buzz-relay/src/router.rs | 4 + crates/buzz-sdk/Cargo.toml | 1 + crates/buzz-sdk/src/lib.rs | 4 + desktop/src-tauri/Cargo.lock | 40 + desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/src/commands/agents.rs | 2 +- .../src/commands/identity_archive.rs | 5 +- desktop/src-tauri/src/commands/media.rs | 2 +- desktop/src-tauri/src/commands/messages.rs | 8 +- desktop/src-tauri/src/commands/mod.rs | 2 + desktop/src-tauri/src/commands/stickers.rs | 624 ++++++++++ desktop/src-tauri/src/events.rs | 209 +--- desktop/src-tauri/src/huddle/pipeline.rs | 2 +- .../src-tauri/src/identity_archive_events.rs | 211 ++++ desktop/src-tauri/src/lib.rs | 11 +- desktop/src-tauri/src/sticker_events.rs | 34 + desktop/src/app/AppShell.tsx | 4 +- desktop/src/app/useLiveUpdates.ts | 12 + desktop/src/features/messages/hooks.ts | 10 +- .../messages/lib/imetaMediaMarkdown.test.mjs | 33 +- .../messages/lib/imetaMediaMarkdown.ts | 12 +- .../features/messages/ui/MessageComposer.tsx | 346 +++--- .../messages/ui/MessageComposerToolbar.tsx | 12 + .../src/features/messages/ui/MessageRow.tsx | 13 +- .../messages/ui/MessageThreadPanel.tsx | 3 + .../messages/ui/TimelineMessageList.tsx | 6 +- .../messages/ui/composerAutocompleteKeys.ts | 26 + .../messages/ui/composerEmojiInsert.ts | 31 + .../messages/ui/messageEditPayload.ts | 61 + .../messages/ui/stickerComposerSend.ts | 99 ++ .../messages/ui/useMentionSendFlow.ts | 11 + .../settings/lib/settingsSections.test.mjs | 49 + .../features/settings/lib/settingsSections.ts | 47 + .../features/settings/ui/SettingsPanels.tsx | 60 +- .../src/features/settings/ui/SettingsView.tsx | 1 + desktop/src/features/stickers/hooks.ts | 168 +++ .../stickers/ui/ComposerStickerPicker.tsx | 188 +++ .../features/stickers/ui/StickerMessage.tsx | 37 + .../stickers/ui/StickerSettingsCard.tsx | 586 ++++++++++ desktop/src/shared/api/mediaTypes.ts | 14 + desktop/src/shared/api/pairing.ts | 33 + desktop/src/shared/api/stickers.test.mjs | 129 +++ desktop/src/shared/api/stickers.ts | 618 ++++++++++ desktop/src/shared/api/stickersTauri.ts | 28 + desktop/src/shared/api/tauri.ts | 40 +- desktop/src/shared/constants/kinds.ts | 8 + desktop/src/shared/lib/mediaUrl.ts | 22 +- desktop/src/testing/e2eBridge.ts | 32 +- .../2026-07-15-sonar-stickers-in-buzz.md | 105 ++ migrations/0027_sticker_catalog.sql | 15 + .../features/channels/message_content.dart | 47 +- mobile/lib/shared/relay/nostr_models.dart | 3 + .../lib/shared/stickers/sticker_preview.dart | 96 ++ .../shared/stickers/sticker_reference.dart | 108 ++ .../shared/stickers/sticker_preview_test.dart | 149 +++ .../stickers/sticker_reference_test.dart | 93 ++ scripts/diagnose-managed-agent.sh | 72 ++ 83 files changed, 7471 insertions(+), 515 deletions(-) create mode 100644 crates/buzz-cli/src/commands/stickers.rs create mode 100644 crates/buzz-core/src/stickers.rs create mode 100644 crates/buzz-db/src/sticker_catalog.rs create mode 100644 desktop/src-tauri/src/commands/stickers.rs create mode 100644 desktop/src-tauri/src/identity_archive_events.rs create mode 100644 desktop/src-tauri/src/sticker_events.rs create mode 100644 desktop/src/app/useLiveUpdates.ts create mode 100644 desktop/src/features/messages/ui/composerAutocompleteKeys.ts create mode 100644 desktop/src/features/messages/ui/composerEmojiInsert.ts create mode 100644 desktop/src/features/messages/ui/messageEditPayload.ts create mode 100644 desktop/src/features/messages/ui/stickerComposerSend.ts create mode 100644 desktop/src/features/settings/lib/settingsSections.test.mjs create mode 100644 desktop/src/features/settings/lib/settingsSections.ts create mode 100644 desktop/src/features/stickers/hooks.ts create mode 100644 desktop/src/features/stickers/ui/ComposerStickerPicker.tsx create mode 100644 desktop/src/features/stickers/ui/StickerMessage.tsx create mode 100644 desktop/src/features/stickers/ui/StickerSettingsCard.tsx create mode 100644 desktop/src/shared/api/mediaTypes.ts create mode 100644 desktop/src/shared/api/pairing.ts create mode 100644 desktop/src/shared/api/stickers.test.mjs create mode 100644 desktop/src/shared/api/stickers.ts create mode 100644 desktop/src/shared/api/stickersTauri.ts create mode 100644 docs/brainstorms/2026-07-15-sonar-stickers-in-buzz.md create mode 100644 migrations/0027_sticker_catalog.sql create mode 100644 mobile/lib/shared/stickers/sticker_preview.dart create mode 100644 mobile/lib/shared/stickers/sticker_reference.dart create mode 100644 mobile/test/shared/stickers/sticker_preview_test.dart create mode 100644 mobile/test/shared/stickers/sticker_reference_test.dart create mode 100755 scripts/diagnose-managed-agent.sh diff --git a/Cargo.lock b/Cargo.lock index 9a3f91671db..3c261e502b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -931,6 +931,7 @@ dependencies = [ "diffy", "dirs", "hex", + "imagesize", "infer", "nostr", "rand 0.10.1", @@ -939,11 +940,13 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "sonar-stickers", "tempfile", "thiserror 2.0.18", "tokio", "url", "uuid", + "zeroize", ] [[package]] @@ -971,6 +974,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "sonar-stickers", "subtle", "thiserror 2.0.18", "url", @@ -996,6 +1000,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "url", "uuid", ] @@ -1208,6 +1213,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.11.0", + "sonar-stickers", "sqlx", "subtle", "tempfile", @@ -1255,6 +1261,7 @@ dependencies = [ "nostr", "serde", "serde_json", + "sonar-stickers", "thiserror 2.0.18", "uuid", ] @@ -7662,6 +7669,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -8720,6 +8728,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sonar-stickers" +version = "0.1.0" +source = "git+https://github.com/hedwig-corp/bitchat-to-sonar?rev=eea8ada304517d6a3ea6c81d6ffa57ce00504bcd#eea8ada304517d6a3ea6c81d6ffa57ce00504bcd" +dependencies = [ + "aes", + "base64 0.22.1", + "cbc", + "hex", + "hkdf 0.12.4", + "hmac 0.12.1", + "nostr", + "prost 0.13.5", + "reqwest 0.12.28", + "serde", + "sha2 0.10.9", + "thiserror 2.0.18", + "url", +] + [[package]] name = "sorted-index-buffer" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 3268cfaf8d3..6e85ba93f6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,6 +135,9 @@ buzz-media = { path = "crates/buzz-media" } buzz-sdk = { path = "crates/buzz-sdk" } buzz-ws-client = { path = "crates/buzz-ws-client" } buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } +# Sonar sticker pack wire model and validators. Keep this exact revision in +# sync with the standalone desktop Tauri workspace. +sonar-stickers = { git = "https://github.com/hedwig-corp/bitchat-to-sonar", rev = "eea8ada304517d6a3ea6c81d6ffa57ce00504bcd", package = "sonar-stickers" } # CI profile — builds the relay for desktop e2e. Dependencies keep full # release optimization (warm from main's cache; they carry the runtime hot diff --git a/crates/buzz-cli/Cargo.toml b/crates/buzz-cli/Cargo.toml index 1476e60bfd4..affc2b92492 100644 --- a/crates/buzz-cli/Cargo.toml +++ b/crates/buzz-cli/Cargo.toml @@ -44,6 +44,7 @@ chrono = { workspace = true } # Typed event builders for all write operations buzz-sdk = { workspace = true } buzz-core = { workspace = true } +sonar-stickers = { workspace = true, features = ["signal-import"] } # Base64 encoding — NIP-98 event serialization for Authorization header base64 = "0.22" @@ -62,6 +63,8 @@ bytes = "1" # MIME type detection via magic bytes — file upload validation infer = "0.19" +imagesize = "0.14" +zeroize = "1" # URL parsing — extract server domain for Blossom auth tag url = { workspace = true } diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9e..872b3abe5ab 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -64,6 +64,7 @@ pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { const ALLOWED_MIMES: &[&str] = &[ "image/jpeg", "image/png", + "image/apng", "image/gif", "image/webp", "video/mp4", @@ -1097,6 +1098,112 @@ impl BuzzClient { /// Upload a file to the relay's Blossom endpoint. /// Returns a BlobDescriptor on success. + pub async fn upload_bytes( + &self, + bytes: Vec, + mime: &str, + ) -> Result { + if !ALLOWED_MIMES.contains(&mime) { + return Err(CliError::Usage(format!("unsupported file type: {mime}"))); + } + + // 3. Size check + let max = if mime.starts_with("video/") { + MAX_VIDEO_BYTES + } else { + MAX_IMAGE_BYTES + }; + if bytes.len() as u64 > max { + return Err(CliError::Usage(format!( + "file too large: {} bytes (max {})", + bytes.len(), + max + ))); + } + + // 4. SHA-256 + let sha256 = hex::encode(Sha256::digest(&bytes)); + + // 5. Sign Blossom auth event (kind:24242) + use nostr::Timestamp; + let now = Timestamp::now().as_secs(); + let expiry = if mime.starts_with("video/") { + 3600 + } else { + 600 + }; + let exp_str = (now + expiry).to_string(); + + let mut blossom_tags = vec![ + Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(["x", &sha256]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, + ]; + // Extract server domain from relay URL for BUD-11 server tag + if let Some(domain) = relay_server_tag(&self.relay_url) { + blossom_tags + .push(Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?); + } + + let auth_event = EventBuilder::new(Kind::from(24242), "Upload file") + .tags(blossom_tags) + .sign_with_keys(&self.keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + + // 6. Base64url encode the auth event for the header + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let auth_header = format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) + ); + + // 7. PUT request to the BUD-02 /upload endpoint with a generous timeout. + let upload_timeout = if mime.starts_with("video/") { + Duration::from_secs(600) + } else { + Duration::from_secs(120) + }; + let url = format!("{}/upload", self.relay_url); + let upload_body = bytes::Bytes::from(bytes); + let req = self + .http + .put(&url) + .timeout(upload_timeout) + .header("Authorization", &auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", &sha256); + + let mut resp = self + .with_auth_tag(req) + .body(upload_body.clone()) + .send() + .await?; + if should_retry_legacy_upload(resp.status()) { + let legacy_url = format!("{}/media/upload", self.relay_url); + let legacy_req = self + .http + .put(&legacy_url) + .timeout(upload_timeout) + .header("Authorization", &auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", &sha256); + resp = self + .with_auth_tag(legacy_req) + .body(upload_body) + .send() + .await?; + } + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::Relay { status, body }); + } + + resp.json::() + .await + .map_err(|e| CliError::Other(format!("invalid upload response: {e}"))) + } + pub async fn upload_file(&self, file_path: &str) -> Result { // 1. Read file — validate it exists and is a regular file let metadata = std::fs::metadata(file_path) diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 86915906360..bef63a258d8 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -15,6 +15,7 @@ pub mod pr; pub mod reactions; pub mod repos; pub mod social; +pub mod stickers; pub mod upload; pub mod users; pub mod workflows; diff --git a/crates/buzz-cli/src/commands/stickers.rs b/crates/buzz-cli/src/commands/stickers.rs new file mode 100644 index 00000000000..59ac7ce85aa --- /dev/null +++ b/crates/buzz-cli/src/commands/stickers.rs @@ -0,0 +1,1011 @@ +use std::collections::{HashMap, HashSet}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use nostr::{Event, EventBuilder, Kind}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sonar_stickers::signal::{ + import_signal_pack_with_options, ImportedSignalSticker, SignalImportOptions, SignalPackLink, +}; +use sonar_stickers::{ + build_installed_packs_tags, build_pack_tags, is_allowed_sticker_mime, + parse_installed_pack_list, parse_pack_event, validate_shortcode, InstalledPackList, + PackAddress, Sticker, StickerPack, STICKER_PACK_KIND, USER_STICKER_PACKS_KIND, +}; +use zeroize::Zeroizing; + +use crate::client::{normalize_write_response, BlobDescriptor, BuzzClient}; +use crate::error::CliError; + +const MAX_STICKER_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Debug, Serialize)] +struct PackSummary { + address: String, + title: String, + description: Option, + cover_url: Option, + sticker_count: usize, + available: bool, + installed: bool, + event_id: String, +} + +/// JSON input for `stickers create` and `stickers update`. +/// +/// Asset paths may be absolute or relative to the manifest file. +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PackManifest { + identifier: String, + title: String, + #[serde(default)] + description: Option, + #[serde(default)] + cover_file: Option, + #[serde(default)] + cover_alt: Option, + stickers: Vec, + #[serde(default)] + license: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct StickerManifest { + shortcode: String, + file: PathBuf, + #[serde(default)] + alt: Option, + #[serde(default)] + emoji: Option, +} + +#[derive(Clone)] +struct PreparedAsset { + bytes: Vec, + mime: String, + sha256: String, + width: u32, + height: u32, +} + +fn parse_events(raw: &str, context: &str) -> Result, CliError> { + serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("failed to parse {context}: {e}"))) +} + +fn sticker_error(context: &str, err: impl std::fmt::Display) -> CliError { + CliError::Usage(format!("{context}: {err}")) +} + +/// NIP-01 addressable-event head: newest timestamp, then smallest event id. +fn canonical_head(events: impl IntoIterator) -> Option { + events.into_iter().max_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| b.id.cmp(&a.id)) + }) +} + +async fn fetch_pack_event( + client: &BuzzClient, + address: &PackAddress, +) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [STICKER_PACK_KIND], + "authors": [address.author_pubkey_hex], + "#d": [address.identifier], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let events = parse_events(&raw, "sticker pack query")?; + Ok(canonical_head( + events + .into_iter() + .filter(|event| parse_pack_event(event).is_ok_and(|pack| pack.address == *address)) + .collect::>(), + )) +} + +async fn fetch_own_installed(client: &BuzzClient) -> Result { + let Some(event) = fetch_own_installed_event(client).await? else { + return Ok(InstalledPackList::default()); + }; + parse_installed_pack_list(&event) + .map_err(|e| CliError::Other(format!("invalid installed sticker list: {e}"))) +} + +async fn fetch_own_installed_event(client: &BuzzClient) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [USER_STICKER_PACKS_KIND], + "authors": [client.keys().public_key().to_hex()], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let events = parse_events(&raw, "installed sticker list query")?; + Ok(canonical_head(events)) +} + +async fn fetch_approved_revisions( + client: &BuzzClient, +) -> Result, CliError> { + let filter = serde_json::json!({ + "kinds": [buzz_core::kind::KIND_STICKER_CATALOG], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let events = parse_events(&raw, "workspace sticker catalog query")?; + let Some(event) = canonical_head(events) else { + return Ok(HashMap::new()); + }; + approved_revisions_from_event(&event) + .ok_or_else(|| CliError::Other("workspace sticker catalog is malformed".into())) +} + +fn approved_revisions_from_event(event: &Event) -> Option> { + if u32::from(event.kind.as_u16()) != buzz_core::kind::KIND_STICKER_CATALOG + || !event.content.is_empty() + { + return None; + } + + let mut saw_protected = false; + let mut revisions = HashMap::new(); + for tag in event.tags.iter() { + let fields = tag.as_slice(); + match fields.first().map(String::as_str) { + Some("-") if fields.len() == 1 && !saw_protected => saw_protected = true, + Some("a") if fields.len() == 3 => { + if revisions.len() >= buzz_core::stickers::MAX_STICKER_CATALOG_PACKS { + return None; + } + let coordinate = fields.get(1)?; + let approved_event_id = fields.get(2)?; + let address = PackAddress::parse(coordinate).ok()?; + if address.coordinate() != *coordinate + || approved_event_id.len() != 64 + || !approved_event_id + .chars() + .all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase()) + || revisions + .insert(coordinate.clone(), approved_event_id.clone()) + .is_some() + { + return None; + } + } + _ => return None, + } + } + saw_protected.then_some(revisions) +} + +async fn cmd_list(client: &BuzzClient) -> Result<(), CliError> { + let installed = fetch_own_installed(client).await?; + let approved_revisions = fetch_approved_revisions(client).await?; + let installed_coordinates: HashSet = installed + .packs + .iter() + .map(PackAddress::coordinate) + .collect(); + let filter = serde_json::json!({ + "kinds": [STICKER_PACK_KIND], + }); + let raw = client.query(&filter).await?; + let events = parse_events(&raw, "sticker pack list")?; + + let mut invalid_events = 0usize; + let mut packs = Vec::new(); + for event in events { + let pack = match parse_pack_event(&event) { + Ok(pack) => pack, + Err(_) => { + invalid_events += 1; + continue; + } + }; + let address = pack.address.coordinate(); + let installed = installed_coordinates.contains(&address); + let available = approved_revisions + .get(&address) + .is_some_and(|approved_id| approved_id == &event.id.to_hex()); + if !available && !installed { + continue; + } + packs.push(PackSummary { + address: address.clone(), + title: pack.title, + description: pack.description, + cover_url: pack.cover.map(|cover| cover.url), + sticker_count: pack.stickers.len(), + available, + installed, + event_id: event.id.to_hex(), + }); + } + packs.sort_by(|a, b| a.title.cmp(&b.title).then(a.address.cmp(&b.address))); + let available_count = packs.iter().filter(|pack| pack.available).count(); + println!( + "{}", + serde_json::json!({ + "packs": packs, + "available_count": available_count, + "installed_count": installed_coordinates.len(), + "invalid_events": invalid_events, + }) + ); + Ok(()) +} + +async fn cmd_show(client: &BuzzClient, address: &str) -> Result<(), CliError> { + let address = PackAddress::parse(address).map_err(|e| sticker_error("invalid address", e))?; + let event = fetch_pack_event(client, &address) + .await? + .ok_or_else(|| CliError::NotFound(format!("sticker pack not found: {address}")))?; + let pack = parse_pack_event(&event) + .map_err(|e| CliError::Other(format!("invalid sticker pack event: {e}")))?; + let installed = fetch_own_installed(client).await?.packs.contains(&address); + let available = fetch_approved_revisions(client) + .await? + .get(&address.coordinate()) + .is_some_and(|approved_id| approved_id == &event.id.to_hex()); + println!( + "{}", + serde_json::json!({ + "pack": pack, + "available": available, + "installed": installed, + "event_id": event.id.to_hex(), + "created_at": event.created_at.as_secs(), + }) + ); + Ok(()) +} + +async fn publish_installed(client: &BuzzClient, list: InstalledPackList) -> Result<(), CliError> { + let builder = EventBuilder::new(Kind::Custom(USER_STICKER_PACKS_KIND), "") + .tags(build_installed_packs_tags(&list)); + let event = client.sign_event(builder)?; + let event_id = event.id; + let response = client.submit_event(event).await?; + let current = fetch_own_installed_event(client) + .await? + .ok_or_else(|| CliError::Conflict("installed sticker list has no current head".into()))?; + if current.id != event_id { + return Err(CliError::Conflict(format!( + "installed sticker list write {} was superseded by {}", + event_id.to_hex(), + current.id.to_hex() + ))); + } + println!("{}", normalize_write_response(&response)); + Ok(()) +} + +async fn cmd_install(client: &BuzzClient, address: &str) -> Result<(), CliError> { + let address = PackAddress::parse(address).map_err(|e| sticker_error("invalid address", e))?; + let pack_event = fetch_pack_event(client, &address).await?.ok_or_else(|| { + CliError::Usage(format!("cannot install missing sticker pack: {address}")) + })?; + if fetch_approved_revisions(client) + .await? + .get(&address.coordinate()) + .is_none_or(|approved_id| approved_id != &pack_event.id.to_hex()) + { + return Err(CliError::Usage(format!( + "sticker pack is not available in this workspace catalog: {address}" + ))); + } + let mut installed = fetch_own_installed(client).await?; + if installed.packs.contains(&address) { + println!( + "{}", + serde_json::json!({"accepted": true, "message": "already installed"}) + ); + return Ok(()); + } + installed.packs.push(address); + publish_installed(client, InstalledPackList::new(installed.packs)).await +} + +async fn cmd_uninstall(client: &BuzzClient, address: &str) -> Result<(), CliError> { + let address = PackAddress::parse(address).map_err(|e| sticker_error("invalid address", e))?; + let mut installed = fetch_own_installed(client).await?; + let before = installed.packs.len(); + installed.packs.retain(|pack| pack != &address); + if installed.packs.len() == before { + println!( + "{}", + serde_json::json!({"accepted": true, "message": "not installed"}) + ); + return Ok(()); + } + publish_installed(client, InstalledPackList::new(installed.packs)).await +} + +fn require_https_upload_target(client: &BuzzClient) -> Result<(), CliError> { + let relay = url::Url::parse(client.relay_url()) + .map_err(|e| CliError::Usage(format!("invalid relay URL: {e}")))?; + if relay.scheme() != "https" { + return Err(CliError::Usage( + "Sonar sticker assets require HTTPS URLs; use an HTTPS Buzz relay".into(), + )); + } + Ok(()) +} + +fn sniff_sticker_asset(bytes: Vec, source: &str) -> Result { + if bytes.len() > MAX_STICKER_BYTES { + return Err(CliError::Usage(format!( + "{source}: sticker is {} bytes (max {MAX_STICKER_BYTES})", + bytes.len() + ))); + } + let mime = infer::get(&bytes) + .map(|kind| { + if kind.mime_type() == "image/png" + && buzz_core::stickers::apng_frame_count(&bytes).is_some() + { + "image/apng".to_owned() + } else { + kind.mime_type().to_ascii_lowercase() + } + }) + .ok_or_else(|| CliError::Usage(format!("{source}: unrecognized image bytes")))?; + if !is_allowed_sticker_mime(&mime) { + return Err(CliError::Usage(format!( + "{source}: unsupported sticker type {mime}; expected WebP, PNG/APNG, or GIF" + ))); + } + let dimensions = imagesize::blob_size(&bytes) + .map_err(|_| CliError::Usage(format!("{source}: cannot read image dimensions")))?; + if dimensions.width == 0 + || dimensions.height == 0 + || dimensions.width > 4096 + || dimensions.height > 4096 + { + return Err(CliError::Usage(format!( + "{source}: sticker dimensions must be between 1x1 and 4096x4096" + ))); + } + let width = u32::try_from(dimensions.width) + .map_err(|_| CliError::Usage(format!("{source}: sticker width is too large")))?; + let height = u32::try_from(dimensions.height) + .map_err(|_| CliError::Usage(format!("{source}: sticker height is too large")))?; + let sha256 = hex::encode(Sha256::digest(&bytes)); + Ok(PreparedAsset { + bytes, + mime, + sha256, + width, + height, + }) +} + +fn prepare_file(path: &Path) -> Result { + let metadata = std::fs::metadata(path) + .map_err(|e| CliError::Other(format!("cannot access {}: {e}", path.display())))?; + if !metadata.is_file() { + return Err(CliError::Usage(format!("{} is not a file", path.display()))); + } + let bytes = std::fs::read(path) + .map_err(|e| CliError::Other(format!("failed to read {}: {e}", path.display())))?; + sniff_sticker_asset(bytes, &path.display().to_string()) +} + +fn resolve_asset_path(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_owned() + } else { + base.join(path) + } +} + +fn parse_dim(dim: Option<&str>) -> Result<(Option, Option), CliError> { + let Some(dim) = dim else { + return Ok((None, None)); + }; + let Some((width, height)) = dim.split_once('x') else { + return Err(CliError::Other(format!("invalid upload dimensions: {dim}"))); + }; + let width = width + .parse() + .map_err(|_| CliError::Other(format!("invalid upload dimensions: {dim}")))?; + let height = height + .parse() + .map_err(|_| CliError::Other(format!("invalid upload dimensions: {dim}")))?; + Ok((Some(width), Some(height))) +} + +async fn upload_assets( + client: &BuzzClient, + assets: impl IntoIterator, +) -> Result, CliError> { + let mut uploaded = HashMap::new(); + for asset in assets { + if uploaded.contains_key(&asset.sha256) { + continue; + } + let descriptor = client.upload_bytes(asset.bytes, &asset.mime).await?; + if descriptor.sha256 != asset.sha256 { + return Err(CliError::Other( + "relay returned a different hash for uploaded sticker".into(), + )); + } + if descriptor.mime_type != asset.mime { + return Err(CliError::Other(format!( + "relay returned MIME {} for uploaded {}", + descriptor.mime_type, asset.mime + ))); + } + uploaded.insert(asset.sha256, descriptor); + } + Ok(uploaded) +} + +fn sticker_from_descriptor( + shortcode: &str, + asset: &PreparedAsset, + descriptor: &BlobDescriptor, + alt: Option, + emoji: Option, +) -> Result { + if let Some(dim) = descriptor.dim.as_deref() { + let (width, height) = parse_dim(Some(dim))?; + if width != Some(asset.width) || height != Some(asset.height) { + return Err(CliError::Other(format!( + "relay returned dimensions {dim} for a {}x{} sticker", + asset.width, asset.height + ))); + } + } + Sticker::new( + shortcode, + descriptor.url.clone(), + asset.sha256.clone(), + asset.mime.clone(), + Some(asset.width), + Some(asset.height), + alt, + emoji, + ) + .map_err(|e| sticker_error("invalid uploaded sticker", e)) +} + +async fn publish_pack(client: &BuzzClient, pack: StickerPack) -> Result<(), CliError> { + let address = pack.address.clone(); + let builder = + EventBuilder::new(Kind::Custom(STICKER_PACK_KIND), "").tags(build_pack_tags(&pack)); + let event = client.sign_event(builder)?; + let event_id = event.id; + let response = client.submit_event(event).await?; + let current = fetch_pack_event(client, &address) + .await? + .ok_or_else(|| CliError::Conflict(format!("sticker pack {address} has no current head")))?; + if current.id != event_id { + return Err(CliError::Conflict(format!( + "sticker pack write {} was superseded by {}", + event_id.to_hex(), + current.id.to_hex() + ))); + } + println!("{}", normalize_write_response(&response)); + Ok(()) +} + +fn load_manifest(path: &Path) -> Result { + let raw = std::fs::read_to_string(path) + .map_err(|e| CliError::Other(format!("failed to read {}: {e}", path.display())))?; + serde_json::from_str(&raw) + .map_err(|e| CliError::Usage(format!("invalid sticker manifest {}: {e}", path.display()))) +} + +fn validate_pack_text( + title: &str, + description: Option<&str>, + cover_alt: Option<&str>, + license: Option<&str>, + stickers: &[StickerManifest], +) -> Result<(), CliError> { + if title.trim().is_empty() || title.chars().count() > 80 { + return Err(CliError::Usage( + "sticker pack title must contain 1..80 characters".into(), + )); + } + if description.is_some_and(|value| value.chars().count() > 500) { + return Err(CliError::Usage( + "sticker pack description must be at most 500 characters".into(), + )); + } + if cover_alt.is_some_and(|value| value.chars().count() > 160) { + return Err(CliError::Usage( + "sticker cover alt text must be at most 160 characters".into(), + )); + } + if license.is_some_and(|value| value.chars().count() > 160) { + return Err(CliError::Usage( + "sticker pack license must be at most 160 characters".into(), + )); + } + if stickers.is_empty() || stickers.len() > 200 { + return Err(CliError::Usage( + "sticker pack must contain 1..200 stickers".into(), + )); + } + for sticker in stickers { + if sticker + .alt + .as_ref() + .is_some_and(|value| value.chars().count() > 160) + { + return Err(CliError::Usage(format!( + "sticker {} alt text must be at most 160 characters", + sticker.shortcode + ))); + } + if sticker + .emoji + .as_ref() + .is_some_and(|value| value.chars().count() > 8) + { + return Err(CliError::Usage(format!( + "sticker {} emoji must be at most 8 characters", + sticker.shortcode + ))); + } + } + Ok(()) +} + +async fn cmd_manifest( + client: &BuzzClient, + file: &str, + require_existing: bool, +) -> Result<(), CliError> { + require_https_upload_target(client)?; + let manifest_path = Path::new(file); + let manifest = load_manifest(manifest_path)?; + validate_pack_text( + &manifest.title, + manifest.description.as_deref(), + manifest.cover_alt.as_deref(), + manifest.license.as_deref(), + &manifest.stickers, + )?; + let base = manifest_path.parent().unwrap_or_else(|| Path::new(".")); + let address = PackAddress::new( + client.keys().public_key().to_hex(), + manifest.identifier.clone(), + ) + .map_err(|e| sticker_error("invalid pack identifier", e))?; + let existing = fetch_pack_event(client, &address).await?.is_some(); + if require_existing && !existing { + return Err(CliError::Usage(format!( + "cannot update missing sticker pack: {address}" + ))); + } + if !require_existing && existing { + return Err(CliError::Usage(format!( + "sticker pack already exists; use `buzz stickers update --file {file}`" + ))); + } + + let mut seen_shortcodes = HashSet::new(); + let mut seen_hashes = HashSet::new(); + let mut prepared_stickers = Vec::with_capacity(manifest.stickers.len()); + for entry in &manifest.stickers { + validate_shortcode(&entry.shortcode) + .map_err(|e| sticker_error("invalid sticker shortcode", e))?; + if !seen_shortcodes.insert(entry.shortcode.clone()) { + return Err(CliError::Usage(format!( + "duplicate sticker shortcode: {}", + entry.shortcode + ))); + } + let asset = prepare_file(&resolve_asset_path(base, &entry.file))?; + if !seen_hashes.insert(asset.sha256.clone()) { + return Err(CliError::Usage(format!( + "duplicate sticker content hash: {}", + asset.sha256 + ))); + } + prepared_stickers.push(asset); + } + let prepared_cover = manifest + .cover_file + .as_deref() + .map(|path| prepare_file(&resolve_asset_path(base, path))) + .transpose()?; + if prepared_cover + .as_ref() + .is_some_and(|cover| cover.mime != "image/webp") + { + return Err(CliError::Usage( + "sticker pack cover must be WebP because the Sonar image tag has no MIME field".into(), + )); + } + + let all_assets = prepared_stickers + .iter() + .cloned() + .chain(prepared_cover.iter().cloned()); + let uploaded = upload_assets(client, all_assets).await?; + let stickers = manifest + .stickers + .iter() + .zip(&prepared_stickers) + .map(|(entry, asset)| { + let descriptor = uploaded + .get(&asset.sha256) + .ok_or_else(|| CliError::Other("uploaded sticker descriptor missing".into()))?; + sticker_from_descriptor( + &entry.shortcode, + asset, + descriptor, + entry.alt.clone(), + entry.emoji.clone(), + ) + }) + .collect::, _>>()?; + let cover = prepared_cover + .as_ref() + .map(|asset| { + let descriptor = uploaded + .get(&asset.sha256) + .ok_or_else(|| CliError::Other("uploaded cover descriptor missing".into()))?; + sticker_from_descriptor("cover", asset, descriptor, manifest.cover_alt.clone(), None) + }) + .transpose()?; + let pack = StickerPack::new( + address, + manifest.title, + manifest.description, + cover, + stickers, + manifest.license, + ) + .map_err(|e| sticker_error("invalid sticker pack", e))?; + publish_pack(client, pack).await +} + +fn sniff_signal_sticker(asset: &ImportedSignalSticker) -> Result { + let prepared = sniff_sticker_asset(asset.bytes.clone(), "Signal sticker asset")?; + if prepared.sha256 != asset.sha256 { + return Err(CliError::Other( + "Signal sticker hash changed after authenticated import".into(), + )); + } + Ok(prepared) +} + +fn truncate_chars(value: &str, limit: usize) -> String { + value.chars().take(limit).collect() +} + +fn short_emoji(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| truncate_chars(value, 8)) +} + +async fn cmd_import( + client: &BuzzClient, + signal_link: &str, + identifier: Option, + title: Option, + skip_missing_signal_stickers: bool, +) -> Result<(), CliError> { + require_https_upload_target(client)?; + validate_signal_link(signal_link)?; + let imported = import_signal_pack_with_options( + signal_link, + SignalImportOptions { + accept_invalid_certs: false, + skip_failed_stickers: skip_missing_signal_stickers, + }, + ) + .await + .map_err(|e| CliError::Other(format!("Signal sticker import failed: {e}")))?; + + let pack_title = title.unwrap_or_else(|| truncate_chars(&imported.title, 80)); + if pack_title.trim().is_empty() || pack_title.chars().count() > 80 { + return Err(CliError::Usage( + "sticker pack title must contain 1..80 characters".into(), + )); + } + + let identifier = identifier.unwrap_or_else(|| format!("signal-{}", imported.pack_id)); + let address = PackAddress::new(client.keys().public_key().to_hex(), identifier) + .map_err(|e| sticker_error("invalid pack identifier", e))?; + if fetch_pack_event(client, &address).await?.is_some() { + return Err(CliError::Usage(format!( + "sticker pack already exists: {address}; choose --identifier or use a manifest update" + ))); + } + + let mut seen_shortcodes = HashSet::new(); + let mut seen_hashes = HashSet::new(); + let mut prepared_stickers = Vec::with_capacity(imported.stickers.len()); + for sticker in &imported.stickers { + validate_shortcode(&sticker.shortcode) + .map_err(|e| sticker_error("invalid imported shortcode", e))?; + if !seen_shortcodes.insert(sticker.shortcode.clone()) { + return Err(CliError::Usage(format!( + "duplicate imported shortcode: {}", + sticker.shortcode + ))); + } + let asset = sniff_signal_sticker(sticker)?; + if !seen_hashes.insert(asset.sha256.clone()) { + return Err(CliError::Usage(format!( + "duplicate imported sticker hash: {}", + asset.sha256 + ))); + } + prepared_stickers.push(asset); + } + let prepared_cover = imported + .cover + .as_ref() + .map(sniff_signal_sticker) + .transpose()? + .filter(|cover| cover.mime == "image/webp"); + let all_assets = prepared_stickers + .iter() + .cloned() + .chain(prepared_cover.iter().cloned()); + let uploaded = upload_assets(client, all_assets).await?; + + let stickers = imported + .stickers + .iter() + .zip(&prepared_stickers) + .map(|(source, asset)| { + let descriptor = uploaded + .get(&asset.sha256) + .ok_or_else(|| CliError::Other("uploaded sticker descriptor missing".into()))?; + let alt = match source.emoji.as_deref() { + Some(emoji) if !emoji.is_empty() => Some(truncate_chars( + &format!("Signal sticker {} {emoji}", source.id), + 160, + )), + _ => Some(format!("Signal sticker {}", source.id)), + }; + sticker_from_descriptor( + &source.shortcode, + asset, + descriptor, + alt, + short_emoji(source.emoji.as_deref()), + ) + }) + .collect::, _>>()?; + let cover = prepared_cover + .as_ref() + .map(|asset| { + let descriptor = uploaded + .get(&asset.sha256) + .ok_or_else(|| CliError::Other("uploaded cover descriptor missing".into()))?; + sticker_from_descriptor( + "cover", + asset, + descriptor, + Some("Sticker pack cover".into()), + None, + ) + }) + .transpose()? + .or_else(|| { + stickers + .iter() + .find(|sticker| sticker.mime == "image/webp") + .cloned() + }); + let description = imported + .author + .as_deref() + .map(str::trim) + .filter(|author| !author.is_empty()) + .map_or_else( + || Some("Imported from a Signal sticker pack.".to_owned()), + |author| { + Some(truncate_chars( + &format!("Imported from a Signal sticker pack by {author}."), + 500, + )) + }, + ); + let pack = StickerPack::new(address, pack_title, description, cover, stickers, None) + .map_err(|e| sticker_error("invalid imported sticker pack", e))?; + publish_pack(client, pack).await +} + +fn validate_signal_link(link: &str) -> Result<(), CliError> { + let url = + url::Url::parse(link).map_err(|_| CliError::Usage("invalid Signal sticker link".into()))?; + if url.scheme() != "https" + || url.host_str() != Some("signal.art") + || !url.username().is_empty() + || url.password().is_some() + || url.path().trim_end_matches('/') != "/addstickers" + { + return Err(CliError::Usage( + "expected an https://signal.art/addstickers/ link".into(), + )); + } + SignalPackLink::parse(link) + .map(|_| ()) + .map_err(|error| CliError::Usage(format!("invalid Signal sticker link: {error}"))) +} + +fn read_signal_link(path: &str) -> Result, CliError> { + let mut link = String::new(); + if path == "-" { + std::io::stdin() + .take(8 * 1024) + .read_to_string(&mut link) + .map_err(|e| CliError::Other(format!("failed to read Signal link: {e}")))?; + } else { + std::fs::File::open(path) + .map_err(|e| CliError::Other(format!("failed to open Signal link file: {e}")))? + .take(8 * 1024) + .read_to_string(&mut link) + .map_err(|e| CliError::Other(format!("failed to read Signal link file: {e}")))?; + } + let link = Zeroizing::new(link.trim().to_owned()); + if link.is_empty() { + return Err(CliError::Usage("Signal link input is empty".into())); + } + Ok(link) +} + +pub async fn dispatch(cmd: crate::StickersCmd, client: &BuzzClient) -> Result<(), CliError> { + use crate::StickersCmd; + match cmd { + StickersCmd::List => cmd_list(client).await, + StickersCmd::Show { address } => cmd_show(client, &address).await, + StickersCmd::Install { address } => cmd_install(client, &address).await, + StickersCmd::Uninstall { address } => cmd_uninstall(client, &address).await, + StickersCmd::Import { + signal_link_file, + identifier, + title, + skip_missing_signal_stickers, + } => { + let signal_link = read_signal_link(&signal_link_file)?; + cmd_import( + client, + signal_link.as_str(), + identifier, + title, + skip_missing_signal_stickers, + ) + .await + } + StickersCmd::Create { file } => cmd_manifest(client, &file, false).await, + StickersCmd::Update { file } => cmd_manifest(client, &file, true).await, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_rejects_unknown_fields() { + let raw = r#"{ + "identifier":"party", + "title":"Party", + "stickers":[{"shortcode":"wave","file":"wave.webp","wat":1}] + }"#; + assert!(serde_json::from_str::(raw).is_err()); + } + + #[test] + fn manifest_parses_relative_assets() { + let raw = r#"{ + "identifier":"party-pack", + "title":"Party", + "description":"A small pack", + "cover_file":"cover.png", + "stickers":[ + {"shortcode":"wave","file":"wave.webp","emoji":"👋"} + ], + "license":"CC0" + }"#; + let manifest: PackManifest = serde_json::from_str(raw).expect("valid manifest"); + assert_eq!(manifest.identifier, "party-pack"); + assert_eq!(manifest.stickers[0].shortcode, "wave"); + assert_eq!(manifest.cover_file, Some(PathBuf::from("cover.png"))); + } + + #[test] + fn resolve_asset_paths_relative_to_manifest() { + assert_eq!( + resolve_asset_path(Path::new("/tmp/pack"), Path::new("wave.webp")), + PathBuf::from("/tmp/pack/wave.webp") + ); + assert_eq!( + resolve_asset_path(Path::new("/tmp/pack"), Path::new("/assets/wave.webp")), + PathBuf::from("/assets/wave.webp") + ); + } + + #[test] + fn installed_list_preserves_order_and_deduplicates() { + let a = PackAddress::new("a".repeat(64), "one").expect("address"); + let b = PackAddress::new("b".repeat(64), "two").expect("address"); + let list = InstalledPackList::new(vec![a.clone(), b.clone(), a]); + assert_eq!( + list.packs, + vec![PackAddress::new("a".repeat(64), "one").expect("address"), b] + ); + } + + #[test] + fn sticker_sniffer_rejects_unknown_signal_bytes() { + let error = sniff_sticker_asset(vec![1, 2, 3, 4], "Signal sticker asset") + .err() + .expect("unknown bytes rejected"); + assert!(error.to_string().contains("unrecognized image bytes")); + } + + #[test] + fn signal_link_requires_official_https_shape() { + let valid = format!( + "https://signal.art/addstickers/#pack_id={}&pack_key={}", + "a".repeat(32), + "b".repeat(64) + ); + assert!(validate_signal_link(&valid).is_ok()); + assert!(validate_signal_link(&valid.replace("signal.art", "example.com")).is_err()); + assert!(validate_signal_link(&valid.replace("https://", "http://")).is_err()); + assert!(validate_signal_link(&valid.replace("addstickers", "other")).is_err()); + } + + #[test] + fn canonical_head_uses_smallest_id_for_equal_timestamp() { + let keys = nostr::Keys::generate(); + let timestamp = nostr::Timestamp::from(1234); + let a = EventBuilder::new(Kind::TextNote, "a") + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .expect("event a"); + let b = EventBuilder::new(Kind::TextNote, "b") + .custom_created_at(timestamp) + .sign_with_keys(&keys) + .expect("event b"); + let expected = std::cmp::min(a.id, b.id); + assert_eq!(canonical_head(vec![a, b]).expect("head").id, expected); + } + + #[test] + fn catalog_revision_requires_event_id_field() { + let keys = nostr::Keys::generate(); + let coordinate = format!("30031:{}:party", keys.public_key().to_hex()); + let approved_id = "a".repeat(64); + let event = EventBuilder::new(Kind::Custom(13536), "") + .tags([ + nostr::Tag::parse(["a", &coordinate, &approved_id]).expect("revision tag"), + nostr::Tag::parse(["a", &format!("30031:{}:old", "b".repeat(64))]) + .expect("legacy tag"), + ]) + .sign_with_keys(&keys) + .expect("catalog"); + assert!(approved_revisions_from_event(&event).is_none()); + + let valid = EventBuilder::new(Kind::Custom(13536), "") + .tags([ + nostr::Tag::parse(["-"]).expect("protected tag"), + nostr::Tag::parse(["a", &coordinate, &approved_id]).expect("revision tag"), + ]) + .sign_with_keys(&keys) + .expect("catalog"); + let revisions = approved_revisions_from_event(&valid).expect("valid catalog"); + assert_eq!(revisions.get(&coordinate), Some(&approved_id)); + assert_eq!(revisions.len(), 1); + } +} diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d299..8c23e924ab5 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -191,6 +191,9 @@ enum Cmd { /// Manage your custom emoji set (workspace palette is the union of all members' sets) #[command(subcommand)] Emoji(EmojiCmd), + /// Discover, install, import, and author Sonar sticker packs + #[command(subcommand)] + Stickers(StickersCmd), /// List, open, and manage direct messages #[command(subcommand)] Dms(DmsCmd), @@ -770,6 +773,57 @@ pub enum EmojiCmd { }, } +#[derive(Subcommand)] +pub enum StickersCmd { + /// List available workspace packs and whether each is installed + List, + /// Show a pack by its `30031::` coordinate + Show { + /// Exact Sonar pack coordinate + #[arg(long)] + address: String, + }, + /// Add a pack to your ordered installed list (kind 10031) + Install { + /// Exact Sonar pack coordinate + #[arg(long)] + address: String, + }, + /// Remove a pack from your ordered installed list (kind 10031) + Uninstall { + /// Exact Sonar pack coordinate + #[arg(long)] + address: String, + }, + /// Import a Signal pack, upload all assets, and publish it + Import { + /// Read the private Signal link from a file, or `-` for stdin (default) + #[arg(long, default_value = "-")] + signal_link_file: String, + /// Override the generated `signal-` identifier + #[arg(long)] + identifier: Option, + /// Override the title from the Signal manifest + #[arg(long)] + title: Option, + /// Skip Signal sticker assets that cannot be downloaded + #[arg(long, default_value_t = false)] + skip_missing_signal_stickers: bool, + }, + /// Create a pack from a JSON manifest containing local asset paths + Create { + /// Path to the JSON manifest + #[arg(long)] + file: String, + }, + /// Replace one of your packs from a JSON manifest + Update { + /// Path to the JSON manifest + #[arg(long)] + file: String, + }, +} + #[derive(Subcommand)] pub enum DmsCmd { /// List direct message conversations @@ -1812,6 +1866,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await, Cmd::Emoji(sub) => commands::emoji::dispatch(sub, &client).await, + Cmd::Stickers(sub) => commands::stickers::dispatch(sub, &client).await, Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await, Cmd::Users(sub) => commands::users::dispatch(sub, &client, &cli.format).await, Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await, @@ -1886,6 +1941,7 @@ mod tests { "reactions", "repos", "social", + "stickers", "upload", "users", "workflows", @@ -1980,6 +2036,18 @@ mod tests { names(&cmd, "emoji"), vec!["export", "import", "list", "rm", "set"] ); + assert_eq!( + names(&cmd, "stickers"), + vec![ + "create", + "import", + "install", + "list", + "show", + "uninstall", + "update" + ] + ); assert_eq!( names(&cmd, "dms"), vec!["add-member", "hide", "list", "open"] @@ -2078,6 +2146,7 @@ mod tests { ("reactions", 3), ("repos", 5), ("social", 7), + ("stickers", 7), ("upload", 1), ("users", 5), ("workflows", 8), diff --git a/crates/buzz-core/Cargo.toml b/crates/buzz-core/Cargo.toml index c55225adf60..0cc7a4ff06f 100644 --- a/crates/buzz-core/Cargo.toml +++ b/crates/buzz-core/Cargo.toml @@ -26,5 +26,6 @@ subtle = { workspace = true } zeroize = { workspace = true } percent-encoding = "2.3" url = { workspace = true } +sonar-stickers = { workspace = true } # NO tokio, NO sqlx, NO redis, NO axum — zero I/O dependencies diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index b1be7c5038b..c2cf95fd730 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -32,6 +32,10 @@ pub const KIND_NIP65_RELAY_LIST_METADATA: u32 = 10002; pub const KIND_BOOKMARK_LIST: u32 = 10003; /// NIP-51: Emoji list (replaceable) — user preferred emojis and pointers to emoji sets. pub const KIND_EMOJI_LIST: u32 = 10030; +/// Sonar Stickers: ordered list of sticker packs installed by a user. +/// +/// This is a user-owned replaceable event keyed by `(pubkey, kind)`. +pub const KIND_USER_STICKER_PACKS: u32 = 10031; /// NIP-51: Follow set (parameterized replaceable, 30000–39999 range) — named curated lists of pubkeys. /// /// User-owned, keyed by `(pubkey, kind, d_tag)`. Allows multiple named follow lists on top of @@ -50,6 +54,11 @@ pub const KIND_BOOKMARK_SET: u32 = 30003; /// `required_scope_for_kind`), and the generic NIP-33 replace path keeps only the /// latest per `(pubkey, d_tag)`. pub const KIND_EMOJI_SET: u32 = 30030; +/// Sonar Stickers: a parameterized-replaceable sticker pack. +/// +/// The `d` tag is the stable pack identifier. Sticker assets are addressed by +/// immutable plaintext SHA-256 hashes within the pack. +pub const KIND_STICKER_PACK: u32 = 30031; /// NIP-01: Channel metadata (replaceable). Not used by Buzz today. pub const KIND_CHANNEL_METADATA: u32 = 41; /// NIP-09: Event deletion request. @@ -380,9 +389,13 @@ pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031; pub const RELAY_ADMIN_CHANGE_ROLE: u32 = 9032; /// Buzz: Set the workspace profile (icon). Admin/owner-signed command. pub const RELAY_ADMIN_SET_WORKSPACE_PROFILE: u32 = 9033; +/// Buzz: Approve or remove a Sonar sticker pack from the workspace catalog. +pub const RELAY_ADMIN_CURATE_STICKER_PACK: u32 = 9034; // NIP-43 relay membership announcement events (relay-signed) /// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention). pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534; +/// Buzz: Workspace Sonar sticker catalog snapshot (relay-signed, replaceable). +pub const KIND_STICKER_CATALOG: u32 = 13536; /// NIP-43: Member added announcement (relay-signed). pub const KIND_NIP43_MEMBER_ADDED: u32 = 8000; /// NIP-43: Member removed announcement (relay-signed). @@ -628,9 +641,11 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP65_RELAY_LIST_METADATA, KIND_BOOKMARK_LIST, KIND_EMOJI_LIST, + KIND_USER_STICKER_PACKS, KIND_FOLLOW_SET, KIND_BOOKMARK_SET, KIND_EMOJI_SET, + KIND_STICKER_PACK, KIND_CHANNEL_METADATA, KIND_DELETION, KIND_REACTION, @@ -663,7 +678,9 @@ pub const ALL_KINDS: &[u32] = &[ RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + RELAY_ADMIN_CURATE_STICKER_PACK, KIND_NIP43_MEMBERSHIP_LIST, + KIND_STICKER_CATALOG, KIND_NIP43_MEMBER_ADDED, KIND_NIP43_MEMBER_REMOVED, KIND_NIP43_LEAVE_REQUEST, @@ -777,7 +794,7 @@ pub const fn is_workflow_execution_kind(kind: u32) -> bool { } /// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032) -/// or the Buzz workspace-profile admin command (9033). +/// or a Buzz workspace administration command (9033–9034). pub const fn is_relay_admin_kind(kind: u32) -> bool { matches!( kind, @@ -785,6 +802,7 @@ pub const fn is_relay_admin_kind(kind: u32) -> bool { | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE | RELAY_ADMIN_SET_WORKSPACE_PROFILE + | RELAY_ADMIN_CURATE_STICKER_PACK ) } @@ -822,6 +840,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { | KIND_DM_VISIBILITY | KIND_THREAD_SUMMARY | KIND_WINDOW_BOUNDS + | KIND_STICKER_CATALOG ) } @@ -849,6 +868,8 @@ const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 3062 const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_STICKER_PACK)); // 30031 ∈ 30000–39999 +const _: () = assert!(is_replaceable(KIND_USER_STICKER_PACKS)); // 10031 ∈ 10000–19999 // Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. const _: () = assert!( diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d1..a55bc0be27a 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -34,6 +34,8 @@ pub mod pairing; pub mod presence; /// Canonical relay runtime identities. pub mod relay; +/// Sonar sticker event and message-reference validation. +pub mod stickers; /// Tenant identity — the server-resolved community key carried on scoped paths. pub mod tenant; /// Schnorr signature and event ID verification. diff --git a/crates/buzz-core/src/network.rs b/crates/buzz-core/src/network.rs index fb3718d58c5..4915038170d 100644 --- a/crates/buzz-core/src/network.rs +++ b/crates/buzz-core/src/network.rs @@ -88,6 +88,10 @@ pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { && segments[2] == 1) // 64:ff9b:1::/48 local-use NAT64 || (segments[0] == 0x2001 && segments[1] == 0) // 2001::/32 Teredo || segments[0] == 0x2002 // 2002::/16 6to4 + || segments[0] & 0xffc0 == 0xfec0 // fec0::/10 deprecated site-local + || (segments[0] == 0x0100 && segments[1] == 0 && segments[2] == 0 && segments[3] == 0) // 100::/64 discard (RFC 6666) + || (segments[0] == 0x2001 && segments[1] <= 0x01ff) // 2001::/23 IETF protocol-assignment + || segments[0] == 0x3fff || segments[0] == 0x5f00 // RFC 3849 — documentation range, should never appear in production || (segments[0] == 0x2001 && segments[1] == 0x0db8) } diff --git a/crates/buzz-core/src/stickers.rs b/crates/buzz-core/src/stickers.rs new file mode 100644 index 00000000000..61f144af2dd --- /dev/null +++ b/crates/buzz-core/src/stickers.rs @@ -0,0 +1,523 @@ +//! Strict Sonar sticker validation shared by relay and clients. +//! +//! The upstream `sonar-stickers` crate owns the interoperable wire model. Buzz +//! adds envelope limits and canonical-shape checks before an event may replace +//! an existing pack or installed-list head. + +use nostr::Event; +use sonar_stickers::{ + parse_installed_pack_list, parse_pack_event, parse_sticker_ref_tag, InstalledPackList, + StickerPack, StickerRef, +}; +use thiserror::Error; +use url::Url; + +const MAX_EVENT_TAGS: usize = 1_024; +const MAX_TAG_FIELDS: usize = 8; +const MAX_TAG_FIELD_BYTES: usize = 2_048; +const MAX_INSTALLED_PACKS: usize = 200; +const MAX_LICENSE_CHARS: usize = 160; + +/// Maximum number of exact pack revisions in one workspace catalog snapshot. +/// +/// Kind `13536` carries one `a` tag per approved revision plus the required +/// protected-event marker, so this also keeps relay-authored events comfortably +/// below the common event tag limit. +pub const MAX_STICKER_CATALOG_PACKS: usize = 500; + +/// Error returned when a Sonar event or sticker reference is not canonical. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum StickerValidationError { + /// The event content must be empty because all public pack data is tagged. + #[error("content must be empty")] + NonEmptyContent, + /// The event contains too many tags. + #[error("too many tags: {0} > {MAX_EVENT_TAGS}")] + TooManyTags(usize), + /// A tag has an invalid field count or field size. + #[error("invalid {name} tag: {reason}")] + InvalidTag { + /// Tag name. + name: String, + /// Human-readable rejection reason. + reason: String, + }, + /// A singleton tag was missing or repeated. + #[error("expected exactly one {0} tag")] + SingletonTag(&'static str), + /// The upstream Sonar model rejected the event. + #[error("{0}")] + Sonar(String), +} + +/// Validate and parse a canonical Sonar kind `30031` sticker-pack event. +pub fn validate_sticker_pack_event(event: &Event) -> Result { + validate_common_envelope(event)?; + require_singleton(event, "d")?; + require_singleton(event, "title")?; + require_singleton_value(event, "pack_format", sonar_stickers::PACK_FORMAT)?; + require_optional_singleton_value(event, "t", sonar_stickers::PACK_FORMAT)?; + require_at_most_one(event, "description")?; + require_at_most_one(event, "image")?; + require_at_most_one(event, "license")?; + + for tag in event.tags.iter() { + let fields = tag.as_slice(); + let name = fields.first().map(String::as_str).unwrap_or_default(); + match name { + "d" | "title" | "description" | "license" if fields.len() != 2 => { + return Err(invalid_tag(name, "expected exactly one value")); + } + "sticker" if !(6..=8).contains(&fields.len()) => { + return Err(invalid_tag( + name, + "expected shortcode, url, sha256, mime, dim, and optional alt and emoji", + )); + } + "emoji" if fields.len() != 3 => { + return Err(invalid_tag(name, "expected shortcode and URL")); + } + "image" if !(3..=4).contains(&fields.len()) => { + return Err(invalid_tag( + name, + "expected URL, sha256, and optional dimensions", + )); + } + _ => {} + } + + if matches!(name, "sticker" | "image") { + validate_asset_tag(fields)?; + } + } + + if event + .tags + .iter() + .find_map(|tag| { + let fields = tag.as_slice(); + (fields.first().map(String::as_str) == Some("license")) + .then(|| fields.get(1)) + .flatten() + }) + .is_some_and(|license| license.chars().count() > MAX_LICENSE_CHARS) + { + return Err(invalid_tag("license", "must be at most 160 characters")); + } + + let pack = parse_pack_event(event) + .map_err(|error| StickerValidationError::Sonar(error.to_string()))?; + + // NIP-30 compatibility tags are optional, but each one that is present + // must uniquely and exactly name a sticker from this revision. + for compatibility in event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("emoji")) + { + let fields = compatibility.as_slice(); + let matches = pack.stickers.iter().filter(|sticker| { + fields.get(1).map(String::as_str) == Some(sticker.shortcode.as_str()) + && fields.get(2).map(String::as_str) == Some(sticker.url.as_str()) + }); + if matches.count() != 1 { + return Err(invalid_tag( + "emoji", + "compatibility tag must exactly match a sticker shortcode and URL", + )); + } + let duplicate_count = event + .tags + .iter() + .filter(|tag| { + let fields = tag.as_slice(); + fields.first().map(String::as_str) == Some("emoji") + && fields.get(1) == compatibility.as_slice().get(1) + && fields.get(2) == compatibility.as_slice().get(2) + }) + .count(); + if duplicate_count != 1 { + return Err(invalid_tag("emoji", "duplicate compatibility tag")); + } + } + Ok(pack) +} + +/// Validate and parse a canonical Sonar kind `10031` installed-pack list. +pub fn validate_installed_pack_list_event( + event: &Event, +) -> Result { + validate_common_envelope(event)?; + if event.tags.len() > MAX_INSTALLED_PACKS { + return Err(StickerValidationError::InvalidTag { + name: "a".into(), + reason: format!( + "too many installed packs: {} > {MAX_INSTALLED_PACKS}", + event.tags.len() + ), + }); + } + for tag in event.tags.iter() { + let fields = tag.as_slice(); + if fields.len() != 2 || fields.first().map(String::as_str) != Some("a") { + return Err(invalid_tag("a", "expected only exact [a, coordinate] tags")); + } + let coordinate = fields.get(1).map(String::as_str).unwrap_or_default(); + let parsed = sonar_stickers::PackAddress::parse(coordinate) + .map_err(|error| StickerValidationError::Sonar(error.to_string()))?; + if parsed.coordinate() != coordinate { + return Err(invalid_tag( + "a", + "coordinate must use canonical lowercase hex", + )); + } + } + let list = parse_installed_pack_list(event) + .map_err(|error| StickerValidationError::Sonar(error.to_string()))?; + if list.packs.len() != event.tags.len() { + return Err(invalid_tag("a", "duplicate pack coordinate")); + } + Ok(list) +} + +/// Validate the optional sticker reference carried by an ordinary message. +/// +/// Returns `Ok(None)` when the message is not a sticker message. A sticker +/// message may contain exactly one four-field tag: +/// `["sticker", "30031::", "", ""]`. +pub fn validate_message_sticker_ref( + event: &Event, +) -> Result, StickerValidationError> { + let mut refs = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("sticker")); + let Some(tag) = refs.next() else { + return Ok(None); + }; + if refs.next().is_some() { + return Err(invalid_tag("sticker", "at most one reference is allowed")); + } + if tag.as_slice().len() != 4 { + return Err(invalid_tag( + "sticker", + "expected exactly pack coordinate, shortcode, and plaintext sha256", + )); + } + let sticker_ref = parse_sticker_ref_tag(tag) + .map_err(|error| StickerValidationError::Sonar(error.to_string()))?; + if sticker_ref.pack.coordinate() + != tag + .as_slice() + .get(1) + .map(String::as_str) + .unwrap_or_default() + || sticker_ref.plaintext_sha256 + != tag + .as_slice() + .get(3) + .map(String::as_str) + .unwrap_or_default() + { + return Err(invalid_tag( + "sticker", + "coordinate and hash must use canonical lowercase hex", + )); + } + Ok(Some(sticker_ref)) +} + +/// Return the declared APNG frame count when PNG chunks carry a coherent +/// animation-control sequence. +/// +/// A raw search for the bytes `acTL` is unsafe because the same bytes may occur +/// inside compressed pixel data. This bounded parser only recognizes an +/// animation control chunk before image data, a matching number of frame +/// control chunks, and a complete PNG chunk sequence. +pub fn apng_frame_count(bytes: &[u8]) -> Option { + const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + if bytes.get(..PNG_SIGNATURE.len())? != PNG_SIGNATURE { + return None; + } + + let mut offset = PNG_SIGNATURE.len(); + let mut saw_ihdr = false; + let mut saw_idat = false; + let mut declared_frames = None; + let mut frame_controls = 0u32; + let mut saw_iend = false; + + while offset.checked_add(12)? <= bytes.len() { + let length = u32::from_be_bytes(bytes.get(offset..offset + 4)?.try_into().ok()?) as usize; + let data_start = offset.checked_add(8)?; + let data_end = data_start.checked_add(length)?; + let chunk_end = data_end.checked_add(4)?; + if chunk_end > bytes.len() { + return None; + } + let chunk_type = bytes.get(offset + 4..offset + 8)?; + let data = bytes.get(data_start..data_end)?; + + match chunk_type { + b"IHDR" if !saw_ihdr && offset == PNG_SIGNATURE.len() && length == 13 => { + saw_ihdr = true; + } + b"acTL" if saw_ihdr && !saw_idat && declared_frames.is_none() && length == 8 => { + let frames = u32::from_be_bytes(data.get(..4)?.try_into().ok()?); + if frames == 0 { + return None; + } + declared_frames = Some(frames); + } + b"fcTL" if declared_frames.is_some() && length == 26 => { + frame_controls = frame_controls.checked_add(1)?; + } + b"IDAT" if saw_ihdr => saw_idat = true, + b"IEND" if length == 0 => { + saw_iend = true; + offset = chunk_end; + break; + } + _ => {} + } + offset = chunk_end; + } + + declared_frames.filter(|frames| { + saw_ihdr && saw_idat && saw_iend && offset == bytes.len() && frame_controls == *frames + }) +} + +fn validate_common_envelope(event: &Event) -> Result<(), StickerValidationError> { + if !event.content.is_empty() { + return Err(StickerValidationError::NonEmptyContent); + } + if event.tags.len() > MAX_EVENT_TAGS { + return Err(StickerValidationError::TooManyTags(event.tags.len())); + } + for tag in event.tags.iter() { + let fields = tag.as_slice(); + let name = fields.first().cloned().unwrap_or_default(); + if fields.is_empty() || fields.len() > MAX_TAG_FIELDS { + return Err(invalid_tag(&name, "invalid field count")); + } + if fields.iter().any(|field| field.len() > MAX_TAG_FIELD_BYTES) { + return Err(invalid_tag(&name, "field exceeds 2048 bytes")); + } + } + Ok(()) +} + +fn validate_asset_tag(fields: &[String]) -> Result<(), StickerValidationError> { + let (url_index, hash_index) = if fields.first().map(String::as_str) == Some("sticker") { + (2, 3) + } else { + (1, 2) + }; + let url = fields + .get(url_index) + .map(String::as_str) + .unwrap_or_default(); + let hash = fields + .get(hash_index) + .map(String::as_str) + .unwrap_or_default(); + if hash.len() != 64 + || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + || hash.bytes().any(|byte| byte.is_ascii_uppercase()) + { + return Err(invalid_tag( + "asset", + "sha256 must be 64 lowercase hex characters", + )); + } + let parsed = Url::parse(url).map_err(|_| invalid_tag("asset", "invalid URL"))?; + if parsed.scheme() != "https" + || parsed.host_str().is_none() + || parsed.username() != "" + || parsed.password().is_some() + || parsed.port().is_some_and(|port| port != 443) + || !parsed.path().to_ascii_lowercase().contains(hash) + { + return Err(invalid_tag( + "asset", + "URL must be credential-free HTTPS and contain the plaintext sha256 in its path", + )); + } + Ok(()) +} + +fn require_singleton(event: &Event, name: &'static str) -> Result<(), StickerValidationError> { + if tag_count(event, name) != 1 { + return Err(StickerValidationError::SingletonTag(name)); + } + Ok(()) +} + +fn require_singleton_value( + event: &Event, + name: &'static str, + expected: &str, +) -> Result<(), StickerValidationError> { + require_singleton(event, name)?; + let valid = event.tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.len() == 2 + && fields.first().map(String::as_str) == Some(name) + && fields.get(1).map(String::as_str) == Some(expected) + }); + if !valid { + return Err(invalid_tag(name, "unexpected value or field count")); + } + Ok(()) +} + +fn require_optional_singleton_value( + event: &Event, + name: &'static str, + expected: &str, +) -> Result<(), StickerValidationError> { + match tag_count(event, name) { + 0 => Ok(()), + 1 => { + let valid = event.tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.len() == 2 + && fields.first().map(String::as_str) == Some(name) + && fields.get(1).map(String::as_str) == Some(expected) + }); + if valid { + Ok(()) + } else { + Err(invalid_tag(name, "unexpected value or field count")) + } + } + _ => Err(invalid_tag(name, "tag may appear at most once")), + } +} + +fn require_at_most_one(event: &Event, name: &str) -> Result<(), StickerValidationError> { + if tag_count(event, name) > 1 { + return Err(invalid_tag(name, "tag may appear at most once")); + } + Ok(()) +} + +fn tag_count(event: &Event, name: &str) -> usize { + event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some(name)) + .count() +} + +fn invalid_tag(name: &str, reason: &str) -> StickerValidationError { + StickerValidationError::InvalidTag { + name: name.to_owned(), + reason: reason.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, Keys, Kind, Tag, TagKind}; + use sonar_stickers::{build_pack_tags, PackAddress, Sticker, StickerPack}; + + use super::*; + + const HASH: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn pack_event() -> Event { + let keys = Keys::generate(); + let sticker = Sticker::new( + "wave", + format!("https://cdn.example/{HASH}.webp"), + HASH, + "image/webp", + Some(512), + Some(512), + Some("Wave".into()), + Some("👋".into()), + ) + .expect("valid fixture"); + let pack = StickerPack::new( + PackAddress::new(keys.public_key().to_hex(), "waves").expect("valid address"), + "Waves", + None, + None, + vec![sticker], + None, + ) + .expect("valid pack"); + EventBuilder::new(Kind::Custom(sonar_stickers::STICKER_PACK_KIND), "") + .tags(build_pack_tags(&pack)) + .sign_with_keys(&keys) + .expect("sign fixture") + } + + #[test] + fn accepts_canonical_pack() { + assert!(validate_sticker_pack_event(&pack_event()).is_ok()); + } + + #[test] + fn accepts_minimal_pack_without_recommended_tags() { + let original = pack_event(); + let keys = Keys::generate(); + let tags = original + .tags + .iter() + .filter(|tag| { + !matches!( + tag.as_slice().first().map(String::as_str), + Some("t" | "emoji") + ) + }) + .map(|tag| { + if tag.as_slice().first().map(String::as_str) == Some("sticker") { + Tag::parse(tag.as_slice()[..6].iter().map(String::as_str)) + .expect("minimal sticker tag") + } else { + tag.clone() + } + }) + .collect::>(); + let event = EventBuilder::new(Kind::Custom(sonar_stickers::STICKER_PACK_KIND), "") + .tags(tags) + .sign_with_keys(&keys) + .expect("sign fixture"); + assert!(validate_sticker_pack_event(&event).is_ok()); + } + + #[test] + fn rejects_non_empty_pack_content() { + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(sonar_stickers::STICKER_PACK_KIND), "secret") + .tags(pack_event().tags) + .sign_with_keys(&keys) + .expect("sign fixture"); + assert_eq!( + validate_sticker_pack_event(&event), + Err(StickerValidationError::NonEmptyContent) + ); + } + + #[test] + fn rejects_sticker_ref_with_extra_fields() { + let keys = Keys::generate(); + let tag = Tag::custom( + TagKind::Custom("sticker".into()), + [ + format!("30031:{}:waves", keys.public_key().to_hex()), + "wave".to_owned(), + HASH.to_owned(), + "extra".to_owned(), + ], + ); + let event = EventBuilder::new(Kind::TextNote, ":wave:") + .tags([tag]) + .sign_with_keys(&keys) + .expect("sign fixture"); + assert!(validate_message_sticker_ref(&event).is_err()); + } +} diff --git a/crates/buzz-db/Cargo.toml b/crates/buzz-db/Cargo.toml index 6f76a11bc19..826f8bb825c 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -22,6 +22,7 @@ thiserror = { workspace = true } nostr = { workspace = true } rand = { workspace = true } metrics = { workspace = true } +url = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index a670a13402e..380b76d9d05 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -827,6 +827,18 @@ pub async fn soft_delete_by_coordinate( ) -> Result { let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0) .ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?; + + // Serialize with `replace_parameterized_event`. Without this lock, a + // deletion can race a replacement after either operation has selected the + // live head, leaving the wrong revision live or deleting the new one. + let lock_key = + super::event_replacement_lock_key(community_id, kind, pubkey, Some(d_tag.as_bytes())); + let mut tx = pool.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(lock_key) + .execute(&mut *tx) + .await?; + let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ @@ -837,9 +849,11 @@ pub async fn soft_delete_by_coordinate( .bind(pubkey) .bind(d_tag) .bind(deletion_created_at) - .execute(pool) + .execute(&mut *tx) .await?; + tx.commit().await?; + Ok(result.rows_affected() > 0) } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 245e49bb2d7..9d84666ec34 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -45,6 +45,8 @@ pub mod relay_invite; pub mod relay_members; /// Replica freshness fence for keyset-cursor read routing. pub mod replica_fence; +/// Revision-pinned workspace sticker catalog persistence. +pub mod sticker_catalog; /// Thread metadata persistence. pub mod thread; /// Per-community usage rollup queries for Prometheus gauges. diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 6985916bba0..9aae713d723 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 26); + assert_eq!(migrations.len(), 27); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -919,6 +919,15 @@ mod tests { assert!(heartbeat.contains("epoch")); assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)")); assert!(heartbeat.contains("_operator_global_tables")); + + // Sticker curation pins an exact pack revision per community. + // Renumbered to 0027 after 0025_relay_invites and 0026_replica_heartbeat + // landed on main. + assert_eq!(migrations[26].version, 27); + let sticker_catalog = migrations[26].sql.as_str(); + assert!(sticker_catalog.contains("CREATE TABLE sticker_catalog_approvals")); + assert!(sticker_catalog.contains("PRIMARY KEY (community_id, coordinate)")); + assert!(sticker_catalog.contains("approved_event_id BYTEA")); } #[test] diff --git a/crates/buzz-db/src/sticker_catalog.rs b/crates/buzz-db/src/sticker_catalog.rs new file mode 100644 index 00000000000..2b48d68b38c --- /dev/null +++ b/crates/buzz-db/src/sticker_catalog.rs @@ -0,0 +1,517 @@ +//! Revision-pinned, tenant-scoped workspace sticker catalog. + +use nostr::{EventBuilder, Kind, Tag, Timestamp}; +use serde_json::Value; +use sqlx::Row; +use std::collections::HashSet; + +use buzz_core::kind::{KIND_STICKER_CATALOG, KIND_STICKER_PACK}; +use buzz_core::stickers::MAX_STICKER_CATALOG_PACKS; +use buzz_core::{CommunityId, StoredEvent}; + +use crate::{event_replacement_lock_key, Db, DbError, Result}; + +/// Requested mutation of a workspace sticker catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StickerCatalogAction<'a> { + /// Pin the current pack head to this exact event revision. + Approve { + /// Exact current kind:30031 event ID reviewed by the administrator. + event_id: &'a [u8], + }, + /// Remove the coordinate from the workspace catalog. + Remove, +} + +/// Result of a catalog mutation and its relay-authored snapshot publication. +#[derive(Debug)] +pub struct StickerCatalogMutation { + /// Newly built relay-signed kind:13536 snapshot. + pub snapshot: StoredEvent, + /// Whether the snapshot row was inserted. + pub was_inserted: bool, + /// Whether the approval table changed. + pub changed: bool, + /// Number of approved pack revisions in the resulting catalog. + pub approval_count: usize, +} + +fn invalid(message: impl Into) -> DbError { + DbError::InvalidData(message.into()) +} + +/// Validate enough of a stored pack's structure to prevent legacy malformed +/// kind:30031 rows from being promoted into the curated catalog. New writes are +/// subject to the relay's complete Sonar validation before storage. +fn is_structurally_valid_pack(tags: &Value, identifier: &str) -> bool { + let Some(tags) = tags.as_array() else { + return false; + }; + + let exact_count = |name: &str, value: &str| { + tags.iter() + .filter(|tag| { + tag.as_array().is_some_and(|parts| { + parts.len() == 2 + && parts[0].as_str() == Some(name) + && parts[1].as_str() == Some(value) + }) + }) + .count() + }; + let title_count = tags + .iter() + .filter(|tag| { + tag.as_array().is_some_and(|parts| { + parts.len() == 2 + && parts[0].as_str() == Some("title") + && parts[1].as_str().is_some_and(|title| !title.is_empty()) + }) + }) + .count(); + let sticker_tags: Vec<_> = tags + .iter() + .filter(|tag| { + tag.as_array() + .and_then(|parts| parts.first()) + .and_then(Value::as_str) + == Some("sticker") + }) + .collect(); + let mut shortcodes = HashSet::new(); + let mut hashes = HashSet::new(); + let stickers_valid = sticker_tags.iter().all(|tag| { + let Some(parts) = tag.as_array() else { + return false; + }; + if !(6..=8).contains(&parts.len()) { + return false; + } + let fields: Option> = parts.iter().map(Value::as_str).collect(); + let Some(fields) = fields else { + return false; + }; + let shortcode = fields[1]; + let url = fields[2]; + let hash = fields[3]; + let mime = fields[4]; + let dim = fields[5]; + let alt = fields.get(6).copied().unwrap_or(""); + let emoji = fields.get(7).copied(); + let shortcode_valid = !shortcode.is_empty() + && shortcode.len() <= 64 + && shortcode + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + && shortcodes.insert(shortcode); + let hash_valid = hash.len() == 64 + && hash + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + && hashes.insert(hash); + let url_valid = url::Url::parse(url).is_ok_and(|parsed| { + parsed.scheme() == "https" + && parsed.host_str().is_some() + && parsed.username().is_empty() + && parsed.password().is_none() + && parsed.port().is_none_or(|port| port == 443) + && parsed.path().to_ascii_lowercase().contains(hash) + }); + let mime_valid = matches!( + mime.to_ascii_lowercase().as_str(), + "image/webp" | "image/png" | "image/apng" | "image/gif" + ); + let dim_valid = if dim.is_empty() { + true + } else { + dim.split_once('x').is_some_and(|(width, height)| { + width + .parse::() + .is_ok_and(|value| (1..=4096).contains(&value)) + && height + .parse::() + .is_ok_and(|value| (1..=4096).contains(&value)) + }) + }; + shortcode_valid + && hash_valid + && url_valid + && mime_valid + && dim_valid + && alt.chars().count() <= 160 + && emoji.is_none_or(|value| value.chars().count() <= 8) + }); + let compatibility_tags: Vec<_> = tags + .iter() + .filter_map(Value::as_array) + .filter(|parts| parts.first().and_then(Value::as_str) == Some("emoji")) + .collect(); + let compatibility_valid = compatibility_tags.iter().all(|emoji| { + emoji.len() == 3 + && sticker_tags + .iter() + .filter(|sticker| { + sticker.as_array().is_some_and(|sticker| { + emoji[1].as_str() == sticker[1].as_str() + && emoji[2].as_str() == sticker[2].as_str() + }) + }) + .count() + == 1 + && compatibility_tags + .iter() + .filter(|other| other[1] == emoji[1] && other[2] == emoji[2]) + .count() + == 1 + }); + let category_tags: Vec<_> = tags + .iter() + .filter_map(Value::as_array) + .filter(|parts| parts.first().and_then(Value::as_str) == Some("t")) + .collect(); + let category_valid = category_tags.is_empty() + || (category_tags.len() == 1 + && category_tags[0].len() == 2 + && category_tags[0][1].as_str() == Some("sonar-sticker-pack-v1")); + + exact_count("d", identifier) == 1 + && exact_count("pack_format", "sonar-sticker-pack-v1") == 1 + && category_valid + && title_count == 1 + && (1..=200).contains(&sticker_tags.len()) + && stickers_valid + && compatibility_valid +} + +impl Db { + /// Atomically mutate the revision-pinned approval table and replace its + /// relay-signed kind:13536 snapshot. + /// + /// Approval acquires the same per-coordinate advisory lock used by + /// parameterized replacement before checking the live kind:30031 head. + /// It then acquires the catalog snapshot lock, mutates the approval row, + /// and publishes the snapshot in the same transaction. A successful row + /// therefore always names the exact event revision that was current when + /// approved. + #[allow(clippy::too_many_arguments)] + pub async fn mutate_sticker_catalog_locked( + &self, + community_id: CommunityId, + coordinate: &str, + pack_author: &[u8], + identifier: &str, + action: StickerCatalogAction<'_>, + actor: &[u8], + relay_keypair: &nostr::Keys, + ) -> Result { + if pack_author.len() != 32 { + return Err(invalid("sticker pack author must be 32 bytes")); + } + if actor.len() != 32 { + return Err(invalid("catalog actor must be 32 bytes")); + } + + let mut tx = self.pool.begin().await?; + + // Approval must serialize with replacement of this exact pack head. + if matches!(action, StickerCatalogAction::Approve { .. }) { + let pack_lock = event_replacement_lock_key( + community_id, + KIND_STICKER_PACK as i32, + pack_author, + Some(identifier.as_bytes()), + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(pack_lock) + .execute(&mut *tx) + .await?; + } + + let relay_pubkey = relay_keypair.public_key().to_bytes(); + let snapshot_lock = event_replacement_lock_key( + community_id, + KIND_STICKER_CATALOG as i32, + relay_pubkey.as_slice(), + None, + ); + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(snapshot_lock) + .execute(&mut *tx) + .await?; + + let changed = match action { + StickerCatalogAction::Approve { event_id } => { + if event_id.len() != 32 { + return Err(invalid("approved sticker pack event id must be 32 bytes")); + } + + let current = sqlx::query( + "SELECT id, created_at, tags, content, sig FROM events \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND d_tag = $4 AND channel_id IS NULL AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(KIND_STICKER_PACK as i32) + .bind(pack_author) + .bind(identifier) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| invalid("sticker pack head not found in this workspace"))?; + + let current_id: Vec = current.try_get("id")?; + if current_id.as_slice() != event_id { + return Err(invalid( + "approved event id is not the current sticker pack head", + )); + } + let tags: Value = current.try_get("tags")?; + if !is_structurally_valid_pack(&tags, identifier) { + return Err(invalid("current sticker pack head is malformed")); + } + let event_created_at: chrono::DateTime = + current.try_get("created_at")?; + let content: String = current.try_get("content")?; + let signature: Vec = current.try_get("sig")?; + let candidate: nostr::Event = serde_json::from_value(serde_json::json!({ + "id": hex::encode(¤t_id), + "pubkey": hex::encode(pack_author), + "created_at": event_created_at.timestamp(), + "kind": KIND_STICKER_PACK, + "tags": tags, + "content": content, + "sig": hex::encode(signature), + }))?; + buzz_core::stickers::validate_sticker_pack_event(&candidate).map_err(|error| { + invalid(format!("current sticker pack is invalid: {error}")) + })?; + + sqlx::query( + "INSERT INTO sticker_catalog_approvals \ + (community_id, coordinate, approved_event_id, approved_by) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (community_id, coordinate) DO UPDATE SET \ + approved_event_id = EXCLUDED.approved_event_id, \ + approved_by = EXCLUDED.approved_by, \ + approved_at = now(), updated_at = now() \ + WHERE sticker_catalog_approvals.approved_event_id \ + IS DISTINCT FROM EXCLUDED.approved_event_id", + ) + .bind(community_id.as_uuid()) + .bind(coordinate) + .bind(event_id) + .bind(actor) + .execute(&mut *tx) + .await? + .rows_affected() + > 0 + } + StickerCatalogAction::Remove => { + sqlx::query( + "DELETE FROM sticker_catalog_approvals \ + WHERE community_id = $1 AND coordinate = $2", + ) + .bind(community_id.as_uuid()) + .bind(coordinate) + .execute(&mut *tx) + .await? + .rows_affected() + > 0 + } + }; + + let approvals = sqlx::query( + "SELECT coordinate, approved_event_id FROM sticker_catalog_approvals \ + WHERE community_id = $1 ORDER BY coordinate ASC", + ) + .bind(community_id.as_uuid()) + .fetch_all(&mut *tx) + .await?; + let approval_count = approvals.len(); + if approval_count > MAX_STICKER_CATALOG_PACKS { + return Err(invalid(format!( + "workspace sticker catalog exceeds {MAX_STICKER_CATALOG_PACKS} packs" + ))); + } + + let mut tags = Vec::with_capacity(approval_count + 1); + tags.push(Tag::parse(["-"]).map_err(|error| invalid(format!("build '-' tag: {error}")))?); + for row in approvals { + let approved_coordinate: String = row.try_get("coordinate")?; + let approved_event_id: Vec = row.try_get("approved_event_id")?; + let approved_event_hex = hex::encode(approved_event_id); + tags.push( + Tag::parse([ + "a", + approved_coordinate.as_str(), + approved_event_hex.as_str(), + ]) + .map_err(|error| invalid(format!("build catalog a tag: {error}")))?, + ); + } + + // Always advance the relay snapshot timestamp. This avoids an event-ID + // collision when a coordinate is removed and re-approved within one + // wall-clock second, which would otherwise roll back the table change. + let prior_created_at: Option> = sqlx::query_scalar( + "SELECT created_at FROM events \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NULL AND deleted_at IS NULL \ + ORDER BY created_at DESC, id ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .bind(KIND_STICKER_CATALOG as i32) + .bind(relay_pubkey.as_slice()) + .fetch_optional(&mut *tx) + .await?; + let now = Timestamp::now().as_secs(); + let created_at = prior_created_at + .map(|value| (value.timestamp() as u64).saturating_add(1)) + .map_or(now, |next| next.max(now)); + + let event = EventBuilder::new(Kind::Custom(KIND_STICKER_CATALOG as u16), "") + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(relay_keypair) + .map_err(|error| invalid(format!("sign kind:{KIND_STICKER_CATALOG}: {error}")))?; + let received_at = chrono::Utc::now(); + let tags_json = serde_json::to_value(&event.tags)?; + let event_created_at = + chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0) + .ok_or(DbError::InvalidTimestamp(event.created_at.as_secs() as i64))?; + let signature = event.sig.serialize(); + + sqlx::query( + "UPDATE events SET deleted_at = NOW() \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NULL AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(KIND_STICKER_CATALOG as i32) + .bind(relay_pubkey.as_slice()) + .execute(&mut *tx) + .await?; + + let inserted = sqlx::query( + "INSERT INTO events \ + (community_id, id, pubkey, created_at, kind, tags, content, sig, \ + received_at, channel_id, d_tag) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NULL, NULL) \ + ON CONFLICT DO NOTHING", + ) + .bind(community_id.as_uuid()) + .bind(event.id.as_bytes().as_slice()) + .bind(relay_pubkey.as_slice()) + .bind(event_created_at) + .bind(KIND_STICKER_CATALOG as i32) + .bind(&tags_json) + .bind(&event.content) + .bind(signature.as_slice()) + .bind(received_at) + .execute(&mut *tx) + .await?; + let was_inserted = inserted.rows_affected() > 0; + if !was_inserted { + return Err(invalid("failed to insert unique sticker catalog snapshot")); + } + + tx.commit().await?; + + Ok(StickerCatalogMutation { + snapshot: StoredEvent::with_received_at(event, received_at, None, true), + was_inserted, + changed, + approval_count, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn structural_pack_requires_revision_fields_and_stickers() { + let tags = serde_json::json!([ + ["d", "animals"], + ["title", "Animals"], + ["pack_format", "sonar-sticker-pack-v1"], + ["t", "sonar-sticker-pack-v1"], + [ + "sticker", + "wave", + "https://cdn.example/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.webp", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "image/webp", + "256x256", + "Wave", + "👋" + ], + ["emoji", "wave", "https://cdn.example/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.webp"] + ]); + assert!(is_structurally_valid_pack(&tags, "animals")); + assert!(!is_structurally_valid_pack(&tags, "different")); + } + + #[test] + fn structural_pack_rejects_uppercase_hash_and_insecure_url() { + let tags = serde_json::json!([ + ["d", "animals"], + ["title", "Animals"], + ["pack_format", "sonar-sticker-pack-v1"], + ["t", "sonar-sticker-pack-v1"], + [ + "sticker", + "wave", + "http://cdn.example/file.webp", + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "image/webp", + "256x256", + "Wave", + "👋" + ], + ["emoji", "wave", "http://cdn.example/file.webp"] + ]); + assert!(!is_structurally_valid_pack(&tags, "animals")); + } + + #[test] + fn structural_pack_accepts_sticker_without_representative_emoji_field() { + let hash = "a".repeat(64); + let url = format!("https://cdn.example/{hash}.webp"); + let tags = serde_json::json!([ + ["d", "animals"], + ["title", "Animals"], + ["pack_format", "sonar-sticker-pack-v1"], + ["t", "sonar-sticker-pack-v1"], + [ + "sticker", + "wave", + url, + hash, + "image/webp", + "256x256", + "Wave" + ], + [ + "emoji", + "wave", + format!("https://cdn.example/{}.webp", "a".repeat(64)) + ] + ]); + assert!(is_structurally_valid_pack(&tags, "animals")); + } + + #[test] + fn structural_pack_accepts_minimal_sticker_without_recommended_tags() { + let hash = "a".repeat(64); + let url = format!("https://cdn.example/{hash}.webp"); + let tags = serde_json::json!([ + ["d", "animals"], + ["title", "Animals"], + ["pack_format", "sonar-sticker-pack-v1"], + ["sticker", "wave", url, hash, "image/webp", "256x256"] + ]); + assert!(is_structurally_valid_pack(&tags, "animals")); + } +} diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index 67896d4ef22..d0e4451ea4e 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -26,4 +26,7 @@ pub use upload_record::{ parse_port, parse_public_ip, upload_record_key, UploadAttribution, UploadNetworkInfo, UploadRecord, UPLOAD_RECORD_VERSION, }; -pub use validation::{looks_like_iso_bmff, serve_inline, validate_video_file, VideoMeta}; +pub use validation::{ + looks_like_iso_bmff, serve_inline, validate_sticker_content, validate_video_file, + StickerContentMeta, VideoMeta, +}; diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index f1387fc9d6e..8187adf80a9 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -12,7 +12,13 @@ use crate::error::MediaError; /// (`process_video_upload`) with its own magic-byte check. If an MP4 is uploaded /// through the image path (Content-Type spoofing), `infer::get()` detects /// `video/mp4` and `validate_content()` rejects it here. -const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; +const ALLOWED_MIME_TYPES: &[&str] = &[ + "image/jpeg", + "image/png", + "image/apng", + "image/gif", + "image/webp", +]; const MP4_BRANDS: &[[u8; 4]] = &[ *b"isom", *b"iso2", *b"iso3", *b"iso4", *b"iso5", *b"iso6", *b"iso7", *b"iso8", *b"iso9", @@ -230,6 +236,180 @@ pub struct VideoMeta { pub has_audio: bool, } +/// Validated metadata for a Sonar sticker asset. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StickerContentMeta { + /// Sniffed, canonical MIME type. + pub mime: String, + /// Canonical content-addressed object extension. + pub extension: String, + /// Parsed image width in pixels. + pub width: u32, + /// Parsed image height in pixels. + pub height: u32, +} + +const MAX_STICKER_ANIMATION_FRAMES: u32 = 200; +const MAX_STICKER_ANIMATION_PIXELS: u64 = 100_000_000; + +fn skip_gif_sub_blocks(bytes: &[u8], mut offset: usize) -> Option { + loop { + let length = *bytes.get(offset)? as usize; + offset = offset.checked_add(1)?; + if length == 0 { + return Some(offset); + } + offset = offset.checked_add(length)?; + if offset > bytes.len() { + return None; + } + } +} + +/// Count GIF image descriptors without decompressing their pixel data. +fn gif_frame_count(bytes: &[u8]) -> Option { + if bytes.get(..6)? != b"GIF87a" && bytes.get(..6)? != b"GIF89a" { + return None; + } + let packed = *bytes.get(10)?; + let mut offset = 13usize; + if packed & 0x80 != 0 { + let color_table_len = 3usize.checked_mul(1usize << ((packed & 0x07) + 1))?; + offset = offset.checked_add(color_table_len)?; + } + let mut frames = 0u32; + loop { + match *bytes.get(offset)? { + 0x2c => { + let packed = *bytes.get(offset.checked_add(9)?)?; + offset = offset.checked_add(10)?; + if packed & 0x80 != 0 { + let color_table_len = 3usize.checked_mul(1usize << ((packed & 0x07) + 1))?; + offset = offset.checked_add(color_table_len)?; + } + // LZW minimum code size, followed by image data sub-blocks. + offset = offset.checked_add(1)?; + frames = frames.checked_add(1)?; + offset = skip_gif_sub_blocks(bytes, offset)?; + } + 0x21 => { + // Extension introducer and label, followed by sub-blocks. + offset = offset.checked_add(2)?; + offset = skip_gif_sub_blocks(bytes, offset)?; + } + 0x3b => return (frames > 0).then_some(frames), + _ => return None, + } + } +} + +/// Count animated WebP frame chunks without decoding their contents. +fn webp_frame_count(bytes: &[u8]) -> Option { + if bytes.get(..4)? != b"RIFF" || bytes.get(8..12)? != b"WEBP" { + return None; + } + let riff_size = u32::from_le_bytes(bytes.get(4..8)?.try_into().ok()?) as usize; + if riff_size.checked_add(8)? != bytes.len() { + return None; + } + let mut offset = 12usize; + let mut frames = 0u32; + let mut animated = false; + while offset < bytes.len() { + let header_end = offset.checked_add(8)?; + let chunk_type = bytes.get(offset..offset + 4)?; + let chunk_len = + u32::from_le_bytes(bytes.get(offset + 4..header_end)?.try_into().ok()?) as usize; + let data_end = header_end.checked_add(chunk_len)?; + let chunk_end = data_end.checked_add(chunk_len & 1)?; + if chunk_end > bytes.len() { + return None; + } + match chunk_type { + b"ANIM" => animated = true, + b"ANMF" => frames = frames.checked_add(1)?, + _ => {} + } + offset = chunk_end; + } + if animated { + (frames > 0).then_some(frames) + } else if frames == 0 { + Some(1) + } else { + None + } +} + +fn sticker_frame_count(bytes: &[u8], mime: &str) -> Option { + match mime { + "image/apng" => buzz_core::stickers::apng_frame_count(bytes), + "image/gif" => gif_frame_count(bytes), + "image/webp" => webp_frame_count(bytes), + "image/png" => Some(1), + _ => None, + } +} + +/// Validate externally fetched Sonar sticker bytes against their declared MIME. +/// +/// This is stricter than general media uploads: Sonar excludes JPEG, caps each +/// plaintext asset at 4 MiB and each dimension at 4096 pixels, and distinguishes +/// animated PNG from ordinary PNG when `image/apng` is declared. +pub fn validate_sticker_content( + bytes: &[u8], + declared_mime: &str, +) -> Result { + const MAX_STICKER_BYTES: usize = 4 * 1024 * 1024; + const MAX_STICKER_DIMENSION: usize = 4_096; + + if bytes.len() > MAX_STICKER_BYTES { + return Err(MediaError::FileTooLarge { + size: bytes.len() as u64, + max: MAX_STICKER_BYTES as u64, + }); + } + + let sniffed = infer::get(bytes) + .map(|kind| kind.mime_type()) + .ok_or(MediaError::UnknownContentType)?; + let is_apng = sniffed == "image/png" && buzz_core::stickers::apng_frame_count(bytes).is_some(); + let (mime, extension, mime_matches) = match declared_mime { + "image/webp" => ("image/webp", "webp", sniffed == "image/webp"), + "image/png" => ("image/png", "png", sniffed == "image/png" && !is_apng), + "image/apng" => ("image/apng", "png", is_apng), + "image/gif" => ("image/gif", "gif", sniffed == "image/gif"), + other => return Err(MediaError::DisallowedContentType(other.to_owned())), + }; + if !mime_matches { + return Err(MediaError::DisallowedContentType(sniffed.to_owned())); + } + + let size = imagesize::blob_size(bytes).map_err(|_| MediaError::InvalidImage)?; + if size.width == 0 + || size.height == 0 + || size.width > MAX_STICKER_DIMENSION + || size.height > MAX_STICKER_DIMENSION + { + return Err(MediaError::ImageTooLarge); + } + let frames = sticker_frame_count(bytes, mime).ok_or(MediaError::InvalidImage)?; + let animation_pixels = (size.width as u64) + .checked_mul(size.height as u64) + .and_then(|pixels| pixels.checked_mul(u64::from(frames))) + .ok_or(MediaError::ImageTooLarge)?; + if frames > MAX_STICKER_ANIMATION_FRAMES || animation_pixels > MAX_STICKER_ANIMATION_PIXELS { + return Err(MediaError::ImageTooLarge); + } + + Ok(StickerContentMeta { + mime: mime.to_owned(), + extension: extension.to_owned(), + width: size.width as u32, + height: size.height as u32, + }) +} + /// Validate uploaded bytes for the **image** upload path. /// /// Checks magic bytes, MIME allowlist (images only), size, and pixel dimensions. @@ -238,7 +418,15 @@ pub struct VideoMeta { pub fn validate_content(bytes: &[u8], config: &MediaConfig) -> Result { // 1. Magic bytes — never trust Content-Type header let mime = infer::get(bytes) - .map(|t| t.mime_type().to_string()) + .map(|kind| { + if kind.mime_type() == "image/png" + && buzz_core::stickers::apng_frame_count(bytes).is_some() + { + "image/apng".to_owned() + } else { + kind.mime_type().to_owned() + } + }) .ok_or(MediaError::UnknownContentType)?; // 2. Allowlist (SVG, PDF, executables all rejected) @@ -492,7 +680,7 @@ fn check_moov_before_mdat(path: &Path) -> Result<(), MediaError> { fn validate_image_metadata_free(bytes: &[u8], mime: &str) -> Result<(), MediaError> { match mime { "image/jpeg" => validate_jpeg_metadata_free(bytes), - "image/png" => validate_png_metadata_free(bytes), + "image/png" | "image/apng" => validate_png_metadata_free(bytes), "image/webp" => validate_webp_metadata_free(bytes), "image/gif" => validate_gif_metadata_free(bytes), _ => Ok(()), @@ -930,7 +1118,7 @@ fn validate_mp4_metadata_free(path: &Path) -> Result<(), MediaError> { pub fn mime_to_ext(mime: &str) -> &'static str { match mime { "image/jpeg" => "jpg", - "image/png" => "png", + "image/png" | "image/apng" => "png", "image/gif" => "gif", "image/webp" => "webp", "video/mp4" => "mp4", @@ -1554,6 +1742,96 @@ mod tests { } } + #[test] + fn sticker_validation_accepts_matching_png() { + let meta = validate_sticker_content(TINY_PNG, "image/png").expect("valid sticker"); + assert_eq!(meta.mime, "image/png"); + assert_eq!(meta.extension, "png"); + assert_eq!((meta.width, meta.height), (1, 1)); + } + + #[test] + fn sticker_validation_rejects_jpeg_and_mime_confusion() { + assert!(matches!( + validate_sticker_content(TINY_JPEG, "image/jpeg"), + Err(MediaError::DisallowedContentType(_)) + )); + assert!(matches!( + validate_sticker_content(TINY_PNG, "image/webp"), + Err(MediaError::DisallowedContentType(_)) + )); + } + + #[test] + fn sticker_validation_requires_animation_marker_for_apng() { + assert!(matches!( + validate_sticker_content(TINY_PNG, "image/apng"), + Err(MediaError::DisallowedContentType(_)) + )); + } + + #[test] + fn sticker_validation_distinguishes_structural_apng_from_png() { + fn append_chunk(bytes: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { + bytes.extend_from_slice(&(data.len() as u32).to_be_bytes()); + bytes.extend_from_slice(chunk_type); + bytes.extend_from_slice(data); + // Chunk CRC integrity is outside this metadata-only detector. The + // browser/image decoder remains responsible for complete decoding. + bytes.extend_from_slice(&[0; 4]); + } + + // Strip TINY_PNG's trailing IEND (12 bytes: length + type + CRC) so the + // appended animation chunks land before the image ends. + let mut apng = TINY_PNG[..TINY_PNG.len() - 12].to_vec(); + let mut animation_control = Vec::new(); + animation_control.extend_from_slice(&1u32.to_be_bytes()); + animation_control.extend_from_slice(&0u32.to_be_bytes()); + append_chunk(&mut apng, b"acTL", &animation_control); + append_chunk(&mut apng, b"fcTL", &[0; 26]); + append_chunk(&mut apng, b"IDAT", &[]); + append_chunk(&mut apng, b"IEND", &[]); + + assert_eq!(buzz_core::stickers::apng_frame_count(&apng), Some(1)); + assert!(validate_sticker_content(&apng, "image/apng").is_ok()); + assert!(matches!( + validate_sticker_content(&apng, "image/png"), + Err(MediaError::DisallowedContentType(_)) + )); + + let mut false_positive = TINY_PNG[..TINY_PNG.len() - 12].to_vec(); + append_chunk(&mut false_positive, b"tEXt", b"contains acTL bytes"); + append_chunk(&mut false_positive, b"IDAT", &[]); + append_chunk(&mut false_positive, b"IEND", &[]); + assert_eq!(buzz_core::stickers::apng_frame_count(&false_positive), None); + } + + #[test] + fn sticker_validation_bounds_animation_work() { + fn append_chunk(bytes: &mut Vec, chunk_type: &[u8; 4], data: &[u8]) { + bytes.extend_from_slice(&(data.len() as u32).to_be_bytes()); + bytes.extend_from_slice(chunk_type); + bytes.extend_from_slice(data); + bytes.extend_from_slice(&[0; 4]); + } + + let mut apng = TINY_PNG[..TINY_PNG.len() - 12].to_vec(); + let mut animation_control = Vec::new(); + animation_control.extend_from_slice(&201u32.to_be_bytes()); + animation_control.extend_from_slice(&0u32.to_be_bytes()); + append_chunk(&mut apng, b"acTL", &animation_control); + for _ in 0..201 { + append_chunk(&mut apng, b"fcTL", &[0; 26]); + } + append_chunk(&mut apng, b"IDAT", &[]); + append_chunk(&mut apng, b"IEND", &[]); + + assert!(matches!( + validate_sticker_content(&apng, "image/apng"), + Err(MediaError::ImageTooLarge) + )); + } + #[test] fn test_validate_svg_rejected() { let config = test_config(); diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 41bdc3b9e9b..7d402007f53 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -62,6 +62,7 @@ base64 = "0.22" buzz-sdk = { workspace = true } buzz-workflow = { workspace = true, features = ["reqwest"] } buzz-media = { workspace = true } +sonar-stickers = { workspace = true } s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] } tempfile = "3" bytes = "1" diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc262..9c8a9d43c88 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -6,7 +6,7 @@ //! GET /media/{sha256_ext} — BUD-01 serve blob //! HEAD /media/{sha256_ext} — BUD-01 existence check -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; use axum::http::header; @@ -19,10 +19,51 @@ use axum::{ use base64::Engine; use buzz_audit::{AuditAction, NewAuditEntry}; use buzz_core::tenant::TenantContext; +use buzz_db::EventQuery; use buzz_media::{BlobDescriptor, MediaError, UploadAttribution, UploadNetworkInfo}; +use dashmap::DashMap; +use sha2::{Digest, Sha256}; use crate::state::AppState; +const MAX_STICKER_BYTES: usize = 4 * 1024 * 1024; +static STICKER_FETCH_LIMIT: LazyLock = + LazyLock::new(|| tokio::sync::Semaphore::new(8)); +static STICKER_FETCH_LOCKS: LazyLock>>> = + LazyLock::new(DashMap::new); + +#[derive(Debug, thiserror::Error)] +pub(crate) enum StickerAssetError { + #[error("sticker not found")] + NotFound, + #[error("sticker origin unavailable")] + Upstream, + #[error("invalid sticker asset")] + InvalidAsset, + #[error("internal error")] + Internal, +} + +impl IntoResponse for StickerAssetError { + fn into_response(self) -> Response { + let status = match self { + Self::NotFound => StatusCode::NOT_FOUND, + Self::Upstream => StatusCode::BAD_GATEWAY, + Self::InvalidAsset => StatusCode::UNPROCESSABLE_ENTITY, + Self::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, Json(serde_json::json!({"error": self.to_string()}))).into_response() + } +} + +struct ResolvedStickerAsset { + approved_event_id: String, + url: String, + mime: String, + width: Option, + height: Option, +} + /// Axum extractor that validates Blossom auth, the BUD-11 hash binding, and /// relay membership (NIP-43, when enabled) from headers BEFORE the request /// body is read. This prevents unauthenticated clients from forcing the @@ -407,7 +448,7 @@ pub async fn upload_blob( // Normalize MIME to a known set to bound label cardinality. let mime_label = match descriptor.mime_type.as_str() { - "image/jpeg" | "image/png" | "image/gif" | "image/webp" | "video/mp4" => { + "image/jpeg" | "image/png" | "image/apng" | "image/gif" | "image/webp" | "video/mp4" => { &descriptor.mime_type } _ => "other", @@ -522,6 +563,363 @@ fn blob_cache_control(require_auth: bool) -> &'static str { } } +/// GET `/media/sticker/{author}/{identifier}/{shortcode}/{sha256}`. +/// +/// The route never accepts an origin URL. It resolves an exact admin-approved +/// pack revision, checks the shortcode and plaintext hash against that stored +/// event, then serves a content-addressed cached copy or securely materializes +/// one from the pack's HTTPS URL. +pub(crate) async fn get_verified_sticker( + State(state): State>, + Path((author, identifier, shortcode, sha256)): Path<(String, String, String, String)>, + req_headers: HeaderMap, +) -> Result { + // Enforce the same optional Blossom auth + relay-membership gate as every + // other /media/* read before resolving or fetching the asset. + let media_auth = authenticate_media_read(&state, &req_headers, &sha256) + .await + .map_err(|_| StickerAssetError::NotFound)?; + let tenant = media_auth.tenant; + let pack = sonar_stickers::PackAddress::new(author, identifier) + .map_err(|_| StickerAssetError::NotFound)?; + let sticker_ref = sonar_stickers::StickerRef::new(pack, shortcode, sha256) + .map_err(|_| StickerAssetError::NotFound)?; + + let resolved = resolve_approved_sticker(&state, &tenant, &sticker_ref).await?; + let extension = sticker_extension(&resolved.mime).ok_or(StickerAssetError::InvalidAsset)?; + let object_key = format!("{}.{}", sticker_ref.plaintext_sha256, extension); + + // Fast path: serve cached bytes without taking the per-object fetch lock, + // so concurrent reads of a popular cached sticker are not serialized. + let bytes = match read_cached_sticker(&state, &object_key).await? { + Some(bytes) => bytes, + None => { + // Cache miss: serialize upstream fetches for this object key. + let fetch_lock = STICKER_FETCH_LOCKS + .entry(object_key.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone(); + let fetch_result: Result, StickerAssetError> = async { + let _fetch_guard = fetch_lock.lock().await; + // Re-check the cache: another request may have materialized + // the object while we waited on the lock. + if let Some(bytes) = read_cached_sticker(&state, &object_key).await? { + return Ok(bytes); + } + let _network_permit = STICKER_FETCH_LIMIT + .acquire() + .await + .map_err(|_| StickerAssetError::Internal)?; + let bytes = fetch_sticker_bytes(&resolved.url).await?; + verify_sticker_bytes(&bytes, &sticker_ref.plaintext_sha256, &resolved)?; + + // Approval or the current pack head may have changed while the network + // request was in flight. Re-resolve before publishing cache bytes. + let current = resolve_approved_sticker(&state, &tenant, &sticker_ref).await?; + if current.approved_event_id != resolved.approved_event_id + || current.url != resolved.url + { + return Err(StickerAssetError::NotFound); + } + state + .media_storage + .put(&object_key, &bytes, &resolved.mime) + .await + .map_err(|_| StickerAssetError::Internal)?; + Ok(bytes) + } + .await; + // Drop our clone, then evict the lock entry when no other waiter + // holds a reference, so the map does not grow unbounded with the + // sticker catalog. Count == 1 means only the map itself references + // the Arc: a concurrent `entry()` either bumps the count (removal + // is skipped) or inserts a fresh mutex after removal (safe — the + // cache re-check above still deduplicates the upstream fetch). + drop(fetch_lock); + STICKER_FETCH_LOCKS.remove_if(&object_key, |_, m| Arc::strong_count(m) == 1); + fetch_result? + } + }; + + // A CAS hit is still checked: object-store corruption or an incorrect key + // must not bypass the same hash/MIME/dimension guarantees as an origin miss. + verify_sticker_bytes(&bytes, &sticker_ref.plaintext_sha256, &resolved)?; + + let content_length = bytes.len(); + let mut response = Response::new(axum::body::Body::from(bytes)); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_str(&resolved.mime).map_err(|_| StickerAssetError::Internal)?, + ); + headers.insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("private, max-age=0, must-revalidate"), + ); + headers.insert( + header::CONTENT_LENGTH, + header::HeaderValue::from_str(&content_length.to_string()) + .map_err(|_| StickerAssetError::Internal)?, + ); + headers.insert( + header::CONTENT_DISPOSITION, + header::HeaderValue::from_static("inline"), + ); + headers.insert( + header::CONTENT_SECURITY_POLICY, + header::HeaderValue::from_static("default-src 'none'"), + ); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + header::HeaderValue::from_static("nosniff"), + ); + Ok(response) +} + +/// Return the cached bytes for `object_key`, or `None` on a cache miss. +async fn read_cached_sticker( + state: &AppState, + object_key: &str, +) -> Result>, StickerAssetError> { + if state + .media_storage + .head(object_key) + .await + .map_err(|_| StickerAssetError::Internal)? + { + Ok(Some( + state + .media_storage + .get(object_key) + .await + .map_err(|_| StickerAssetError::Internal)?, + )) + } else { + Ok(None) + } +} + +async fn resolve_approved_sticker( + state: &AppState, + tenant: &TenantContext, + sticker_ref: &sonar_stickers::StickerRef, +) -> Result { + let catalog = state + .db + .get_latest_global_replaceable( + tenant.community(), + buzz_core::kind::KIND_STICKER_CATALOG as i32, + &state.relay_keypair.public_key().to_bytes(), + ) + .await + .map_err(|_| StickerAssetError::Internal)? + .ok_or(StickerAssetError::NotFound)?; + let coordinate = sticker_ref.pack.coordinate(); + let approved_event_id = catalog + .event + .tags + .iter() + .find_map(|tag| { + let fields = tag.as_slice(); + (fields.len() == 3 + && fields.first().map(String::as_str) == Some("a") + && fields.get(1).map(String::as_str) == Some(coordinate.as_str())) + .then(|| fields.get(2).cloned()) + .flatten() + }) + .ok_or(StickerAssetError::NotFound)?; + let approved_id_bytes = decode_lower_hex_32(&approved_event_id)?; + let approved = state + .db + .get_event_by_id(tenant.community(), &approved_id_bytes) + .await + .map_err(|_| StickerAssetError::Internal)? + .ok_or(StickerAssetError::NotFound)?; + if approved.event.id.to_hex() != approved_event_id + || approved.event.pubkey.to_hex() != sticker_ref.pack.author_pubkey_hex + || u32::from(approved.event.kind.as_u16()) != buzz_core::kind::KIND_STICKER_PACK + { + return Err(StickerAssetError::NotFound); + } + let pack = buzz_core::stickers::validate_sticker_pack_event(&approved.event) + .map_err(|_| StickerAssetError::NotFound)?; + if pack.address != sticker_ref.pack { + return Err(StickerAssetError::NotFound); + } + + // An edit immediately becomes pending review: the approved event must + // still be the canonical live head for its coordinate. + let mut query = EventQuery::for_community(tenant.community()); + query.kinds = Some(vec![buzz_core::kind::KIND_STICKER_PACK as i32]); + query.pubkey = Some(approved.event.pubkey.to_bytes().to_vec()); + query.d_tag = Some(sticker_ref.pack.identifier.clone()); + query.global_only = true; + query.limit = Some(1); + let current_head = state + .db + .query_events(&query) + .await + .map_err(|_| StickerAssetError::Internal)? + .into_iter() + .next() + .ok_or(StickerAssetError::NotFound)?; + if current_head.event.id != approved.event.id { + return Err(StickerAssetError::NotFound); + } + + let sticker = pack + .sticker(&sticker_ref.shortcode) + .filter(|sticker| sticker.sha256 == sticker_ref.plaintext_sha256) + .ok_or(StickerAssetError::NotFound)?; + Ok(ResolvedStickerAsset { + approved_event_id, + url: sticker.url.clone(), + mime: sticker.mime.clone(), + width: sticker.width, + height: sticker.height, + }) +} + +/// Maximum upstream redirect hops followed when materializing a sticker. +/// Blossom servers commonly 302 to a CDN (e.g. blossom.primal.net → r2a), +/// so zero redirects breaks real packs; a small bound still prevents loops. +const MAX_STICKER_REDIRECTS: usize = 3; + +/// Validate a candidate sticker origin URL (initial or redirect target): +/// HTTPS only, no credentials, default port. Returns the host to pin. +fn validate_sticker_origin(parsed: &reqwest::Url) -> Result<&str, StickerAssetError> { + let host = parsed.host_str().ok_or(StickerAssetError::InvalidAsset)?; + if parsed.scheme() != "https" + || parsed.username() != "" + || parsed.password().is_some() + || parsed.port().is_some_and(|port| port != 443) + { + return Err(StickerAssetError::InvalidAsset); + } + Ok(host) +} + +/// Resolve `host` and pin the first address, rejecting any resolution that +/// includes a private IP (DNS-rebinding guard). Runs on every redirect hop, +/// so a redirect can never smuggle the fetch to a private destination. +async fn pin_sticker_host(host: &str) -> Result { + let addresses: Vec = tokio::net::lookup_host((host, 443)) + .await + .map_err(|_| StickerAssetError::Upstream)? + .collect(); + if addresses.is_empty() + || addresses + .iter() + .any(|address| buzz_core::network::is_private_ip(&address.ip())) + { + return Err(StickerAssetError::InvalidAsset); + } + Ok(addresses[0]) +} + +async fn fetch_sticker_bytes(url: &str) -> Result, StickerAssetError> { + let mut parsed = reqwest::Url::parse(url).map_err(|_| StickerAssetError::InvalidAsset)?; + let mut response = { + let mut hops = 0usize; + loop { + let host = validate_sticker_origin(&parsed)?.to_string(); + let pinned = pin_sticker_host(&host).await?; + let client = reqwest::Client::builder() + // Environment-configured HTTP(S) proxies would bypass the DNS + // pin and let the proxy resolve a different (possibly + // private) destination. + .no_proxy() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(20)) + // Redirects are followed manually below so every hop repeats + // the HTTPS/credential/port validation and DNS pinning. + .redirect(reqwest::redirect::Policy::none()) + .resolve(&host, pinned) + .build() + .map_err(|_| StickerAssetError::Internal)?; + let response = client + .get(parsed.clone()) + .send() + .await + .map_err(|_| StickerAssetError::Upstream)?; + if !response.status().is_redirection() { + break response; + } + hops += 1; + if hops > MAX_STICKER_REDIRECTS { + return Err(StickerAssetError::Upstream); + } + let location = response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or(StickerAssetError::Upstream)?; + parsed = parsed + .join(location) + .map_err(|_| StickerAssetError::InvalidAsset)?; + } + }; + if !response.status().is_success() { + return Err(StickerAssetError::Upstream); + } + if response + .content_length() + .is_some_and(|length| length > MAX_STICKER_BYTES as u64) + { + return Err(StickerAssetError::InvalidAsset); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| StickerAssetError::Upstream)? + { + if bytes.len() + chunk.len() > MAX_STICKER_BYTES { + return Err(StickerAssetError::InvalidAsset); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +fn verify_sticker_bytes( + bytes: &[u8], + expected_hash: &str, + resolved: &ResolvedStickerAsset, +) -> Result<(), StickerAssetError> { + if hex::encode(Sha256::digest(bytes)) != expected_hash { + return Err(StickerAssetError::InvalidAsset); + } + let meta = buzz_media::validate_sticker_content(bytes, &resolved.mime) + .map_err(|_| StickerAssetError::InvalidAsset)?; + if resolved.width.is_some_and(|width| width != meta.width) + || resolved.height.is_some_and(|height| height != meta.height) + { + return Err(StickerAssetError::InvalidAsset); + } + Ok(()) +} + +fn sticker_extension(mime: &str) -> Option<&'static str> { + match mime { + "image/webp" => Some("webp"), + "image/png" | "image/apng" => Some("png"), + "image/gif" => Some("gif"), + _ => None, + } +} + +fn decode_lower_hex_32(value: &str) -> Result, StickerAssetError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + { + return Err(StickerAssetError::NotFound); + } + hex::decode(value).map_err(|_| StickerAssetError::NotFound) +} + /// Whether a path-segment extension is a safe token. /// /// The sidecar's `ext` field is the *authoritative* extension — the serve and @@ -912,6 +1310,39 @@ mod tests { use super::*; use std::sync::Arc; + #[test] + fn sticker_origin_validation_rejects_non_https_and_credentials() { + let ok = reqwest::Url::parse("https://blossom.example.com/abc.webp").unwrap(); + assert_eq!(validate_sticker_origin(&ok).unwrap(), "blossom.example.com"); + + for bad in [ + "http://blossom.example.com/abc.webp", + "https://user@blossom.example.com/abc.webp", + "https://user:pw@blossom.example.com/abc.webp", + "https://blossom.example.com:8443/abc.webp", + ] { + let parsed = reqwest::Url::parse(bad).unwrap(); + assert!( + validate_sticker_origin(&parsed).is_err(), + "must reject {bad}" + ); + } + } + + #[test] + fn sticker_redirect_join_resolves_relative_locations() { + let base = reqwest::Url::parse("https://blossom.example.com/abc.webp").unwrap(); + let joined = base.join("/cdn/abc.webp").unwrap(); + assert_eq!(joined.as_str(), "https://blossom.example.com/cdn/abc.webp"); + let absolute = base + .join("https://r2a.example.net/uploads/abc.webp") + .unwrap(); + assert_eq!( + validate_sticker_origin(&absolute).unwrap(), + "r2a.example.net" + ); + } + use axum::{ body::Body, http::{header, Request, StatusCode}, @@ -1176,6 +1607,19 @@ mod tests { assert!(validate_media_path(VALID_HASH).is_ok()); } + #[test] + fn sticker_asset_path_helpers_are_fail_closed() { + assert_eq!(sticker_extension("image/webp"), Some("webp")); + assert_eq!(sticker_extension("image/apng"), Some("png")); + assert_eq!(sticker_extension("image/jpeg"), None); + assert_eq!( + decode_lower_hex_32(VALID_HASH).expect("valid hash").len(), + 32 + ); + assert!(decode_lower_hex_32(&VALID_HASH.to_ascii_uppercase()).is_err()); + assert!(decode_lower_hex_32("abc").is_err()); + } + #[test] fn test_validate_media_path_hash_ext() { for ext in &["jpg", "png", "gif", "webp", "mp4"] { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a9cdffcdecb..3c84157daab 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -36,9 +36,9 @@ pub(crate) fn bounded_kind_label(kind: u32) -> String { match kind { 0..=9 | 1059 | 1063 => kind.to_string(), 8000..=8003 | 9000..=9022 | 9030..=9036 => kind.to_string(), - 13534..=13535 => kind.to_string(), + 13534..=13536 => kind.to_string(), 20000..=29999 => kind.to_string(), - 30023 | 30315 | 39000..=39003 => kind.to_string(), + 10031 | 30023 | 30031 | 30315 | 39000..=39003 => kind.to_string(), 40002..=40100 => kind.to_string(), 41001 | 41010..=41012 => kind.to_string(), 43001..=43006 => kind.to_string(), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index fcd0d70728f..a94985b2d9c 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -29,11 +29,12 @@ use buzz_core::kind::{ KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, - KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, - KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, - RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_STICKER_PACK, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, + KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, + KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, + KIND_TEAM_CATALOG, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_USER_STICKER_PACKS, + KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_CURATE_STICKER_PACK, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -240,6 +241,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_DELETION | KIND_REACTION @@ -264,7 +267,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result + || k == RELAY_ADMIN_SET_WORKSPACE_PROFILE + || k == RELAY_ADMIN_CURATE_STICKER_PACK => { Ok(Scope::AdminUsers) } @@ -414,6 +418,10 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { // keyed by (pubkey, kind[, d_tag]). A stray `h` tag must not channel-scope them. | KIND_EMOJI_SET | KIND_EMOJI_LIST + // Sonar sticker packs (30031) and personal installed lists + // (10031) are user-owned global replaceable state. + | KIND_STICKER_PACK + | KIND_USER_STICKER_PACKS // NIP-AE agent engrams are addressed by (pubkey_a, kind, d_tag); never channel-scoped. | KIND_AGENT_ENGRAM // NIP-ER event reminders are addressed by (pubkey, kind, d_tag); never channel-scoped. @@ -459,6 +467,7 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE | RELAY_ADMIN_SET_WORKSPACE_PROFILE + | RELAY_ADMIN_CURATE_STICKER_PACK | KIND_NIP43_LEAVE_REQUEST // NIP-IA: identity archive/unarchive requests drive relay-global // archive state (8002/8003/13535) and are audited as global request @@ -2420,6 +2429,23 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + // Sonar events must be fully validated before the generic replace path. + // Otherwise a malformed newer event could eclipse a valid pack/list head. + if kind_u32 == KIND_STICKER_PACK { + buzz_core::stickers::validate_sticker_pack_event(&event) + .map_err(|error| IngestError::Rejected(format!("invalid: sticker pack: {error}")))?; + } + if kind_u32 == KIND_USER_STICKER_PACKS { + buzz_core::stickers::validate_installed_pack_list_event(&event).map_err(|error| { + IngestError::Rejected(format!("invalid: installed sticker packs: {error}")) + })?; + } + if matches!(kind_u32, KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_V2) { + buzz_core::stickers::validate_message_sticker_ref(&event).map_err(|error| { + IngestError::Rejected(format!("invalid: sticker reference: {error}")) + })?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -3334,6 +3360,29 @@ mod tests { } } + #[test] + fn sonar_sticker_state_is_global_and_requires_users_write() { + let dummy = make_dummy_event(); + for kind in [KIND_STICKER_PACK, KIND_USER_STICKER_PACKS] { + assert!(is_global_only_kind(kind)); + assert!(!requires_h_channel_scope(kind)); + assert_eq!( + required_scope_for_kind(kind, &dummy).ok(), + Some(Scope::UsersWrite) + ); + } + } + + #[test] + fn sticker_catalog_command_requires_admin_users() { + let dummy = make_dummy_event(); + assert!(is_global_only_kind(RELAY_ADMIN_CURATE_STICKER_PACK)); + assert_eq!( + required_scope_for_kind(RELAY_ADMIN_CURATE_STICKER_PACK, &dummy).ok(), + Some(Scope::AdminUsers) + ); + } + #[test] fn persona_is_in_scope_allowlist() { let dummy = make_dummy_event(); diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3f58a9c2aa8..89652c114a7 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -1,6 +1,6 @@ -//! NIP-43 relay membership admin command handler (kinds 9030–9032). +//! Relay-level admin command handler (kinds 9030–9034). //! -//! These events are processed directly — they mutate the `relay_members` table +//! These events are processed directly — they mutate normalized relay state //! and return without being stored as regular Nostr events. //! //! ## Permission matrix @@ -11,6 +11,7 @@ //! | 9031 | Remove member | admin or owner | //! | 9032 | Change role | owner only | //! | 9033 | Set workspace profile (icon) | admin or owner | +//! | 9034 | Curate a sticker pack revision | admin or owner | use std::sync::Arc; @@ -18,14 +19,15 @@ use nostr::Event; use tracing::{info, warn}; use buzz_core::kind::{ - RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, - RELAY_ADMIN_SET_WORKSPACE_PROFILE, + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_CURATE_STICKER_PACK, + RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_db::relay_members::RemoveResult; use crate::handlers::side_effects::{ publish_nip43_member_added, publish_nip43_member_removed, publish_nip43_membership_list, + publish_sticker_catalog_mutation, }; use crate::state::AppState; @@ -56,6 +58,111 @@ fn extract_tag_value(event: &Event, name: &str) -> Option { None } +#[derive(Debug, Clone, PartialEq, Eq)] +struct StickerCatalogCommand { + coordinate: String, + pack_author: [u8; 32], + identifier: String, + approved_event_id: Option<[u8; 32]>, +} + +fn decode_lower_hex_32(value: &str, label: &str) -> Result<[u8; 32], String> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(format!("{label} must be 64 lowercase hex characters")); + } + let decoded = hex::decode(value).map_err(|_| format!("invalid {label}"))?; + decoded + .try_into() + .map_err(|_| format!("{label} must decode to 32 bytes")) +} + +/// Parse kind:9034. Approval deliberately uses a three-field `a` tag so the +/// admin pins a reviewed revision; removal uses exactly `["a", coordinate]`. +fn parse_sticker_catalog_command(event: &Event) -> Result { + let action_tags: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().is_some_and(|part| part == "action")) + .collect(); + if action_tags.len() != 1 { + return Err("expected exactly one action tag".to_string()); + } + let action = action_tags[0].as_slice(); + if action.len() != 2 || !matches!(action[1].as_str(), "approve" | "remove") { + return Err("action tag must be exactly [action, approve|remove]".to_string()); + } + + let address_tags: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().is_some_and(|part| part == "a")) + .collect(); + if address_tags.len() != 1 { + return Err("expected exactly one a tag".to_string()); + } + let address = address_tags[0].as_slice(); + let expected_len = if action[1] == "approve" { 3 } else { 2 }; + if address.len() != expected_len { + return Err(if action[1] == "approve" { + "approve requires exactly [a, coordinate, event_id]".to_string() + } else { + "remove requires exactly [a, coordinate]".to_string() + }); + } + + let coordinate = address[1].to_string(); + if coordinate.len() > 512 { + return Err("sticker pack coordinate is too long".to_string()); + } + let mut parts = coordinate.splitn(3, ':'); + if parts.next() != Some("30031") { + return Err("sticker pack coordinate must use kind 30031".to_string()); + } + let author_hex = parts + .next() + .ok_or_else(|| "sticker pack coordinate is missing author".to_string())?; + let identifier = parts + .next() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "sticker pack coordinate is missing identifier".to_string())? + .to_string(); + if identifier.len() > 80 + || !identifier + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err( + "sticker pack identifier must be 1..80 ASCII alnum, dot, underscore, or dash" + .to_string(), + ); + } + let pack_author = decode_lower_hex_32(author_hex, "sticker pack author")?; + // Rebuilding catches extra/missing delimiters and non-canonical spellings. + if coordinate != format!("30031:{author_hex}:{identifier}") { + return Err("sticker pack coordinate is not canonical".to_string()); + } + + let approved_event_id = if action[1] == "approve" { + Some(decode_lower_hex_32( + address[2].as_str(), + "approved event id", + )?) + } else { + None + }; + + Ok(StickerCatalogCommand { + coordinate, + pack_author, + identifier, + approved_event_id, + }) +} + /// Maximum accepted workspace icon https URL length. const MAX_WORKSPACE_ICON_URL_LEN: usize = 2048; @@ -137,7 +244,7 @@ fn admits_relay_admin_command( Ok(()) } -/// Validate and execute a relay admin command (kinds 9030–9033). +/// Validate and execute a relay admin command (kinds 9030–9034). /// /// Admission: rejects a sender under a durable community ban before any /// command runs. The command itself is executed by @@ -251,6 +358,36 @@ async fn execute_relay_admin_command( return Ok(()); } + // kind:9034 — Curate an exact sticker pack revision. Handled before + // p-tag extraction because it targets an addressable event, not a member. + if kind == RELAY_ADMIN_CURATE_STICKER_PACK { + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".to_string()); + } + let command = parse_sticker_catalog_command(event)?; + let actor = event.pubkey.to_bytes(); + let (changed, approval_count) = publish_sticker_catalog_mutation( + tenant, + state, + &command.coordinate, + command.pack_author.as_slice(), + &command.identifier, + command.approved_event_id.as_ref().map(<[u8; 32]>::as_slice), + actor.as_slice(), + ) + .await + .map_err(|error| format!("failed to update sticker catalog: {error}"))?; + + info!( + sender = %sender_hex, + coordinate = %command.coordinate, + changed, + approval_count, + "workspace sticker catalog updated" + ); + return Ok(()); + } + let target_hex = extract_p_tag_hex(event) .ok_or_else(|| "missing or invalid p tag".to_string())? .to_ascii_lowercase(); @@ -591,4 +728,70 @@ mod tests { let long_data = format!("data:image/png;base64,{}", "A".repeat(98_304)); assert!(validate_workspace_icon(&long_data).is_err()); } + + #[test] + fn sticker_catalog_approve_pins_exact_revision() { + let author = "a".repeat(64); + let event_id = "b".repeat(64); + let coordinate = format!("30031:{author}:animals"); + let event = make_test_event( + 9034, + vec![ + vec!["action", "approve"], + vec![ + "a", + Box::leak(coordinate.clone().into_boxed_str()), + Box::leak(event_id.into_boxed_str()), + ], + ], + ); + let parsed = parse_sticker_catalog_command(&event).expect("valid command"); + assert_eq!(parsed.coordinate, coordinate); + assert_eq!(parsed.pack_author, [0xaa; 32]); + assert_eq!(parsed.approved_event_id, Some([0xbb; 32])); + } + + #[test] + fn sticker_catalog_remove_requires_coordinate_only() { + let coordinate = format!("30031:{}:animals", "a".repeat(64)); + let valid = make_test_event( + 9034, + vec![ + vec!["action", "remove"], + vec!["a", Box::leak(coordinate.into_boxed_str())], + ], + ); + assert!(parse_sticker_catalog_command(&valid).is_ok()); + + let invalid = make_test_event( + 9034, + vec![ + vec!["action", "remove"], + vec!["a", "30031:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:animals", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + ], + ); + assert!(parse_sticker_catalog_command(&invalid).is_err()); + } + + #[test] + fn sticker_catalog_rejects_uppercase_author_and_duplicate_tags() { + let uppercase = make_test_event( + 9034, + vec![ + vec!["action", "remove"], + vec!["a", "30031:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:animals"], + ], + ); + assert!(parse_sticker_catalog_command(&uppercase).is_err()); + + let duplicate = make_test_event( + 9034, + vec![ + vec!["action", "remove"], + vec!["action", "remove"], + vec!["a", "30031:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:animals"], + ], + ); + assert!(parse_sticker_catalog_command(&duplicate).is_err()); + } } diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef38..c79e21b3c46 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -11,10 +11,11 @@ use buzz_core::kind::{ KIND_GIT_REPO_ANNOUNCEMENT, KIND_IA_ARCHIVED, KIND_IA_ARCHIVED_LIST, KIND_IA_UNARCHIVED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, - KIND_THREAD_SUMMARY, + KIND_STICKER_CATALOG, KIND_THREAD_SUMMARY, }; use buzz_core::StoredEvent; use buzz_db::channel::{MemberRecord, MemberRole}; +use buzz_db::sticker_catalog::StickerCatalogAction; use super::event::dispatch_persistent_event; use crate::protocol::RelayMessage; @@ -2980,6 +2981,59 @@ async fn publish_nip43_membership_list_inner( Ok(()) } +/// Apply a revision-pinned workspace sticker catalog mutation and fan out the +/// resulting relay-signed kind:13536 snapshot. +/// +/// The DB serializes pack-head verification, approval-row mutation, and +/// snapshot replacement in one transaction. The admin command itself is not +/// persisted; only this normalized relay-authored snapshot is observable. +#[allow(clippy::too_many_arguments)] +pub async fn publish_sticker_catalog_mutation( + tenant: &TenantContext, + state: &Arc, + coordinate: &str, + pack_author: &[u8], + identifier: &str, + approved_event_id: Option<&[u8]>, + actor: &[u8], +) -> anyhow::Result<(bool, usize)> { + let action = approved_event_id.map_or(StickerCatalogAction::Remove, |event_id| { + StickerCatalogAction::Approve { event_id } + }); + let mutation = state + .db + .mutate_sticker_catalog_locked( + tenant.community(), + coordinate, + pack_author, + identifier, + action, + actor, + &state.relay_keypair, + ) + .await?; + + if mutation.was_inserted { + dispatch_persistent_event( + tenant, + state, + &mutation.snapshot, + KIND_STICKER_CATALOG, + &state.relay_keypair.public_key().to_hex(), + None, + ) + .await; + } + + info!( + coordinate, + changed = mutation.changed, + approval_count = mutation.approval_count, + "workspace sticker catalog published" + ); + Ok((mutation.changed, mutation.approval_count)) +} + /// Shared helper: publish a NIP-43 membership delta event (kind 8000 or 8001). /// /// Signs a relay event with `["-"]` (NIP-70) + `["p", target]` tags, stores it diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 2575ddd7baa..6df554f6de3 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -162,7 +162,7 @@ impl RelayInfo { pubkey: None, contact: None, supported_nips, - supported_extensions: Some(vec!["nip-er".to_string()]), + supported_extensions: Some(vec!["nip-er".to_string(), "sonar-stickers-v1".to_string()]), push: None, software: "https://github.com/block/buzz".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe34..741a568ff10 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -39,6 +39,10 @@ pub fn build_router(state: Arc) -> Router { let media_router = Router::new() .route("/upload", put(api::media::upload_blob)) .route("/media/upload", put(api::media::upload_blob)) + .route( + "/media/sticker/{author}/{identifier}/{shortcode}/{sha256}", + get(api::media::get_verified_sticker), + ) .route( "/media/{sha256_ext}", get(api::media::get_blob).head(api::media::head_blob), diff --git a/crates/buzz-sdk/Cargo.toml b/crates/buzz-sdk/Cargo.toml index da8fd0bd0e2..4bef8116a2e 100644 --- a/crates/buzz-sdk/Cargo.toml +++ b/crates/buzz-sdk/Cargo.toml @@ -14,3 +14,4 @@ uuid = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +sonar-stickers = { workspace = true } diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4ee0cd4c882..2d058f00215 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -18,6 +18,10 @@ pub mod nip_oa; pub use builders::*; +/// Re-export the canonical Sonar sticker wire models used by the typed +/// builders in this crate. +pub use sonar_stickers::{InstalledPackList, PackAddress, Sticker, StickerPack, StickerRef}; + /// Re-export kind constants so consumers don't need buzz-core directly. pub use buzz_core::kind; diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 00d3fba3b59..a3e46e03fa6 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1027,6 +1027,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "sonar-stickers", "subtle", "thiserror 2.0.18", "url", @@ -1050,6 +1051,7 @@ dependencies = [ "buzz-persona", "buzz-sdk", "buzz-voice", + "buzz-ws-client", "bytes", "bzip2 0.6.1", "chrono", @@ -1093,6 +1095,7 @@ dependencies = [ "serde_yaml", "sha2 0.11.0", "sherpa-onnx", + "sonar-stickers", "strip-ansi-escapes", "tar", "tauri", @@ -1170,6 +1173,7 @@ dependencies = [ "nostr", "serde", "serde_json", + "sonar-stickers", "thiserror 2.0.18", "uuid", ] @@ -1192,6 +1196,20 @@ dependencies = [ "tokenizers", ] +[[package]] +name = "buzz-ws-client" +version = "0.1.0" +dependencies = [ + "futures-util", + "nostr", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.29.0", + "tracing", + "url", +] + [[package]] name = "by_address" version = "1.2.1" @@ -3874,6 +3892,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -8320,6 +8339,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -9568,6 +9588,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sonar-stickers" +version = "0.1.0" +source = "git+https://github.com/hedwig-corp/bitchat-to-sonar?rev=eea8ada304517d6a3ea6c81d6ffa57ce00504bcd#eea8ada304517d6a3ea6c81d6ffa57ce00504bcd" +dependencies = [ + "aes 0.8.4", + "base64 0.22.1", + "cbc", + "hex", + "hkdf", + "hmac 0.12.1", + "nostr", + "prost 0.13.5", + "reqwest 0.12.28", + "serde", + "sha2 0.10.9", + "thiserror 2.0.18", + "url", +] + [[package]] name = "sorted-index-buffer" version = "0.2.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b80684f9554..a38dbb1cdad 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -97,6 +97,7 @@ url = "2" buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } +buzz-ws-client = { path = "../../crates/buzz-ws-client" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } iroh = { version = "1.0.2", optional = true } @@ -123,6 +124,7 @@ png = "0.18" # target-gated inside arboard; at runtime X11 sessions still fall back to X11. arboard = { version = "3", features = ["wayland-data-control"] } image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] } +sonar-stickers = { git = "https://github.com/hedwig-corp/bitchat-to-sonar", rev = "eea8ada304517d6a3ea6c81d6ffa57ce00504bcd", package = "sonar-stickers", features = ["signal-import"] } zip = "8" flate2 = "1" sherpa-onnx = "1.12" diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac5..2fcded878e2 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -147,7 +147,7 @@ pub(super) fn build_agent_archive_request( .map_err(|_| "owner auth tag must have four elements".to_string())?, ) }; - crate::events::build_archive_identity_request( + crate::identity_archive_events::build_archive_identity_request( agent_pubkey, "", Some("retired"), diff --git a/desktop/src-tauri/src/commands/identity_archive.rs b/desktop/src-tauri/src/commands/identity_archive.rs index d15ee82abc3..2b1203c3c91 100644 --- a/desktop/src-tauri/src/commands/identity_archive.rs +++ b/desktop/src-tauri/src/commands/identity_archive.rs @@ -16,7 +16,6 @@ use tauri::State; use crate::{ app_state::AppState, - events, relay::{ classify_request_error, query_relay, relay_http_base_url, relay_ws_url_with_override, submit_event, SubmitEventResponse, @@ -151,7 +150,7 @@ pub async fn archive_identity( let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; let auth_ref = auth_tag.as_ref(); - let builder = events::build_archive_identity_request( + let builder = crate::identity_archive_events::build_archive_identity_request( &req.target_pubkey, &req.content, req.reason.as_deref(), @@ -170,7 +169,7 @@ pub async fn unarchive_identity( let auth_tag = maybe_owner_auth_tag(&state, &req.target_pubkey).await?; let auth_ref = auth_tag.as_ref(); - let builder = events::build_unarchive_identity_request( + let builder = crate::identity_archive_events::build_unarchive_identity_request( &req.target_pubkey, &req.content, req.reason.as_deref(), diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index ed3b3402388..d239500e906 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -467,7 +467,7 @@ pub(crate) async fn upload_image_bytes( do_upload(body, &mime, state, None).await } -async fn do_upload( +pub(crate) async fn do_upload( body: Vec, mime: &str, state: &AppState, diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index b7c37bec3df..27c21ff484b 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -475,8 +475,6 @@ pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result>>, emoji_tags: Option>>, mention_tags: Option>>, + sticker_tags: Option>>, mention_pubkeys: Option>, kind: Option, state: State<'_, AppState>, @@ -546,10 +544,10 @@ pub async fn send_channel_message( let media = media_tags.unwrap_or_default(); let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); + let sticker_refs = sticker_tags.unwrap_or_default(); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); let mut resolved_root: Option = None; - let builder = match kind_num { buzz_core_pkg::kind::KIND_FORUM_POST => events::build_forum_post( channel_uuid, @@ -590,6 +588,7 @@ pub async fn send_channel_message( &media, &emoji, &mention_refs_only, + &sticker_refs, )? } }; @@ -756,6 +755,7 @@ fn build_managed_agent_channel_message( &[], &[], &[], + &[], client_tags, ) } diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b7..160e49edb5b 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -53,6 +53,7 @@ mod qr_download; mod relay_members; mod relay_reconnect; mod social; +mod stickers; mod team_snapshot; mod teams; mod updater; @@ -104,6 +105,7 @@ pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; +pub use stickers::*; pub use team_snapshot::*; pub use teams::*; pub use updater::*; diff --git a/desktop/src-tauri/src/commands/stickers.rs b/desktop/src-tauri/src/commands/stickers.rs new file mode 100644 index 00000000000..cb3cabfb376 --- /dev/null +++ b/desktop/src-tauri/src/commands/stickers.rs @@ -0,0 +1,624 @@ +//! Sticker authoring commands: validated sticker image upload, Signal +//! sticker-pack import, and Nostr sticker-pack import from public relays. +//! Split out of `media.rs` to keep that module under the 1000-line file-size +//! ceiling. + +use std::time::Duration; + +use buzz_ws_client::{NostrWsConnection, RelayMessage}; +use serde::Serialize; +use serde_json::json; +use tauri::State; +use zeroize::Zeroize; + +use crate::app_state::AppState; +use crate::relay::relay_api_base_url_with_override; + +use super::media::{do_upload, sanitize_filename, BlobDescriptor}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportedStickerDraft { + identifier: String, + title: String, + author: Option, + description: Option, + license: Option, + cover: Option, + stickers: Vec, + skipped_sticker_ids: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportedStickerAsset { + shortcode: String, + url: String, + sha256: String, + mime: String, + // Optional fields must be omitted (not null) so the frontend's + // `=== undefined` checks in publishStickerPack behave. + #[serde(skip_serializing_if = "Option::is_none")] + width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + height: Option, + #[serde(skip_serializing_if = "Option::is_none")] + alt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + emoji: Option, +} + +const MAX_SONAR_STICKER_BYTES: usize = 4 * 1024 * 1024; + +fn require_https_sticker_relay(state: &AppState) -> Result<(), String> { + let base_url = relay_api_base_url_with_override(state); + let parsed = + url::Url::parse(&base_url).map_err(|_| "The active relay URL is invalid.".to_string())?; + if parsed.scheme() != "https" { + return Err("Sonar sticker assets require an HTTPS relay.".to_string()); + } + Ok(()) +} + +fn validate_sticker_image(bytes: &[u8], cover_only: bool) -> Result<(String, u32, u32), String> { + if bytes.is_empty() || bytes.len() > MAX_SONAR_STICKER_BYTES { + return Err("Sticker images must be non-empty and at most 4 MiB.".to_string()); + } + let sniffed = infer::get(bytes) + .map(|kind| kind.mime_type()) + .ok_or_else(|| "Could not recognize that sticker image.".to_string())?; + let mime = + if sniffed == "image/png" && buzz_core_pkg::stickers::apng_frame_count(bytes).is_some() { + "image/apng" + } else { + sniffed + }; + if !matches!( + mime, + "image/webp" | "image/png" | "image/apng" | "image/gif" + ) { + return Err("Sonar stickers must be WebP, PNG, APNG, or GIF.".to_string()); + } + if cover_only && mime != "image/webp" { + return Err( + "Sticker pack covers must be WebP because the Sonar image tag has no MIME field." + .to_string(), + ); + } + // Read geometry from the bounded file header before any pixel allocation. + // The upload path does not need decoded pixels, so avoid a full decode. + let (width, height) = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|_| "Sticker image data is invalid.".to_string())? + .into_dimensions() + .map_err(|_| "Sticker image data is invalid.".to_string())?; + if width == 0 || height == 0 || width > 4096 || height > 4096 { + return Err("Sticker dimensions must be between 1x1 and 4096x4096.".to_string()); + } + Ok((mime.to_string(), width, height)) +} + +fn validate_official_signal_sticker_link(link: &str) -> bool { + url::Url::parse(link).is_ok_and(|parsed| { + parsed.scheme() == "https" + && parsed.host_str() == Some("signal.art") + && parsed.username().is_empty() + && parsed.password().is_none() + && parsed.port().is_none() + && parsed.path().trim_end_matches('/') == "/addstickers" + && sonar_stickers::signal::SignalPackLink::parse(link).is_ok() + }) +} + +async fn upload_imported_signal_sticker( + sticker: sonar_stickers::signal::ImportedSignalSticker, + state: &State<'_, AppState>, + cover_only: bool, +) -> Result { + let (mime, width, height) = validate_sticker_image(&sticker.bytes, cover_only)?; + let descriptor = do_upload(sticker.bytes, &mime, state, None).await?; + Ok(ImportedStickerAsset { + shortcode: sticker.shortcode, + url: descriptor.url, + sha256: descriptor.sha256, + mime, + width: Some(width), + height: Some(height), + alt: Some(format!("Sticker {}", sticker.id)), + emoji: sticker.emoji, + }) +} + +/// Pick and upload one strictly validated Sonar sticker asset. +/// +/// Unlike the generic image picker, this command rejects JPEG, images above +/// 4 MiB or 4096 pixels, and non-WebP covers before any network request. +#[tauri::command] +pub async fn pick_and_upload_sticker_image( + app: tauri::AppHandle, + state: State<'_, AppState>, + cover_only: bool, +) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + require_https_sticker_relay(&state)?; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .add_filter("Sonar sticker images", &["webp", "png", "apng", "gif"]) + .pick_file(move |path| { + let _ = tx.send(path); + }); + let Some(file_path) = rx.await.map_err(|_| "dialog cancelled".to_string())? else { + return Ok(None); + }; + let path = file_path.as_path().ok_or("invalid path")?.to_path_buf(); + let read_path = path.clone(); + let bytes = tokio::task::spawn_blocking(move || -> Result, String> { + use std::io::Read; + + // Inspect and read through the same open handle so a path swap cannot + // change the inode between validation and upload. + let file = std::fs::File::open(&read_path) + .map_err(|error| format!("Could not open sticker image: {error}"))?; + let metadata = file + .metadata() + .map_err(|error| format!("Could not inspect sticker image: {error}"))?; + if !metadata.is_file() || metadata.len() > MAX_SONAR_STICKER_BYTES as u64 { + return Err("Sticker images must be regular files no larger than 4 MiB.".to_string()); + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.take(MAX_SONAR_STICKER_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("Could not read sticker image: {error}"))?; + if bytes.len() > MAX_SONAR_STICKER_BYTES { + return Err("Sticker images must be regular files no larger than 4 MiB.".to_string()); + } + Ok(bytes) + }) + .await + .map_err(|error| format!("Sticker image reader failed: {error}"))??; + let (mime, _, _) = validate_sticker_image(&bytes, cover_only)?; + let mut descriptor = do_upload(bytes, &mime, &state, None).await?; + descriptor.filename = path + .file_name() + .and_then(|name| name.to_str()) + .map(sanitize_filename); + Ok(Some(descriptor)) +} + +/// Import and decrypt a Signal sticker pack entirely in trusted Rust, then +/// upload the authenticated plaintext assets through Buzz's existing Blossom +/// path. The Signal link (including its pack key) is consumed by this command +/// and is never returned, persisted, or logged. +#[tauri::command] +pub async fn import_signal_sticker_pack( + mut signal_link: String, + state: State<'_, AppState>, +) -> Result { + require_https_sticker_relay(&state)?; + if !validate_official_signal_sticker_link(signal_link.trim()) { + signal_link.zeroize(); + return Err("Enter a valid https://signal.art/addstickers/ link.".to_string()); + } + let imported_result = sonar_stickers::signal::import_signal_pack_with_options( + signal_link.trim(), + sonar_stickers::signal::SignalImportOptions { + accept_invalid_certs: false, + skip_failed_stickers: true, + }, + ) + .await; + signal_link.zeroize(); + let imported = imported_result.map_err(|_| { + "Could not import that Signal sticker pack. Check the link and try again.".to_string() + })?; + + let mut stickers = Vec::with_capacity(imported.stickers.len()); + for sticker in imported.stickers { + stickers.push(upload_imported_signal_sticker(sticker, &state, false).await?); + } + let cover = match imported.cover { + Some(cover) => upload_imported_signal_sticker(cover, &state, true) + .await + .ok(), + None => None, + }; + + Ok(ImportedStickerDraft { + identifier: imported.pack_id, + title: imported.title, + author: imported.author, + description: None, + license: None, + cover, + stickers, + skipped_sticker_ids: imported.skipped_sticker_ids, + }) +} + +// ── Nostr pack link import ───────────────────────────────────────────────── + +/// Maximum relay hints accepted from a sticker pack link. +const MAX_PACK_LINK_RELAYS: usize = 8; +/// Per-relay connect + query budget when fetching a pack from public relays. +const PACK_FETCH_TIMEOUT: Duration = Duration::from_secs(10); + +struct NostrStickerPackLink { + address: sonar_stickers::PackAddress, + relays: Vec, +} + +/// Parse a Sonar sticker pack link +/// (`https://…/stickers?a=30031::&relay=wss://…`) or a +/// bare `30031::` coordinate. Relay hints must be ws(s) +/// URLs and are capped so a crafted link cannot fan the app out to dozens of +/// sockets. +fn parse_nostr_sticker_pack_link(input: &str) -> Result { + const INVALID: &str = "Enter a Sonar sticker pack link \ + (https://…/stickers?a=30031:…) or a 30031:: coordinate."; + let trimmed = input.trim(); + if trimmed.starts_with("https://") || trimmed.starts_with("http://") { + let url = url::Url::parse(trimmed).map_err(|_| INVALID.to_string())?; + if url.scheme() != "https" { + return Err("Sticker pack links must use HTTPS.".to_string()); + } + let mut coordinate: Option = None; + let mut relays: Vec = Vec::new(); + for (key, value) in url.query_pairs() { + match key.as_ref() { + "a" => coordinate = Some(value.into_owned()), + "relay" => relays.push(value.into_owned()), + _ => {} + } + } + let coordinate = coordinate.ok_or_else(|| INVALID.to_string())?; + let address = + sonar_stickers::PackAddress::parse(&coordinate).map_err(|_| INVALID.to_string())?; + let mut valid_relays = Vec::with_capacity(relays.len()); + for relay in &relays { + let parsed = + url::Url::parse(relay).map_err(|_| format!("Invalid relay hint: {relay}"))?; + if !matches!(parsed.scheme(), "wss" | "ws") { + return Err(format!("Relay hints must be ws(s) URLs: {relay}")); + } + valid_relays.push(relay.clone()); + } + valid_relays.truncate(MAX_PACK_LINK_RELAYS); + return Ok(NostrStickerPackLink { + address, + relays: valid_relays, + }); + } + let address = sonar_stickers::PackAddress::parse(trimmed).map_err(|_| INVALID.to_string())?; + Ok(NostrStickerPackLink { + address, + relays: Vec::new(), + }) +} + +/// Fetch the newest kind:30031 event for `address` from the link's relay +/// hints. Read-only unauthenticated REQs; the first hint yielding a parseable +/// pack wins, so offline or censoring relays fall through to the next one. +async fn fetch_nostr_sticker_pack( + address: &sonar_stickers::PackAddress, + relays: &[String], +) -> Result { + for relay in relays { + if let Ok(Some(pack)) = fetch_nostr_sticker_pack_from_relay(address, relay).await { + return Ok(pack); + } + } + Err("Could not find that sticker pack on the link's relays.".to_string()) +} + +/// Canonical replaceable-event head ordering: newest `created_at` wins, and +/// on a timestamp tie the lexicographically smallest event ID wins. Mirrors +/// `canonical_head` in `crates/buzz-cli/src/commands/stickers.rs` so imported +/// packs resolve the same head regardless of relay arrival order. +fn replaces_head(candidate: &nostr::Event, current: &nostr::Event) -> bool { + candidate.created_at > current.created_at + || (candidate.created_at == current.created_at && candidate.id < current.id) +} + +async fn fetch_nostr_sticker_pack_from_relay( + address: &sonar_stickers::PackAddress, + relay: &str, +) -> Result, String> { + // One deadline for the whole per-relay fetch (connect + REQ + query + // loop). Without it each hint could burn PACK_FETCH_TIMEOUT twice + // (connect, then a fresh query deadline) plus an unbounded send_raw, + // stalling the import UI for minutes across several slow hints. + let deadline = tokio::time::Instant::now() + PACK_FETCH_TIMEOUT; + let budget = || { + deadline + .checked_duration_since(tokio::time::Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| "relay fetch timed out".to_string()) + }; + let mut conn = tokio::time::timeout(budget()?, NostrWsConnection::connect(relay)) + .await + .map_err(|_| "relay connect timed out".to_string())? + .map_err(|error| error.to_string())?; + let subscription_id = "sonar-sticker-import"; + let request = json!([ + "REQ", + subscription_id, + { + "kinds": [sonar_stickers::STICKER_PACK_KIND], + "authors": [address.author_pubkey_hex], + "#d": [address.identifier], + "limit": 4, + } + ]); + tokio::time::timeout(budget()?, conn.send_raw(&request)) + .await + .map_err(|_| "relay request timed out".to_string())? + .map_err(|error| error.to_string())?; + let mut newest: Option = None; + while let Ok(remaining) = budget() { + let message = match conn.next_event(remaining).await { + Ok(message) => message, + Err(_) => break, + }; + match message { + // NostrWsConnection only deserializes; a hostile relay hint can + // return an event claiming the requested kind+pubkey with a + // forged ID/signature, so `event.verify()` must pass before the + // event is trusted, and only the canonical head is kept. + RelayMessage::Event { + subscription_id: id, + event, + } if id == subscription_id + && event.kind.as_u16() == sonar_stickers::STICKER_PACK_KIND + && event.pubkey.to_hex() == address.author_pubkey_hex + && event.verify().is_ok() + && newest + .as_ref() + .is_none_or(|current| replaces_head(&event, current)) => + { + newest = Some(*event); + } + RelayMessage::Eose { + subscription_id: id, + } if id == subscription_id => break, + RelayMessage::Closed { + subscription_id: id, + .. + } if id == subscription_id => break, + _ => {} + } + } + let _ = conn.disconnect().await; + let Some(event) = newest else { + return Ok(None); + }; + let pack = sonar_stickers::parse_pack_event(&event).map_err(|error| error.to_string())?; + // The relay matched on author + #d loosely; verify the parsed pack is the + // exact address the user asked for. + if pack.address != *address { + return Ok(None); + } + Ok(Some(pack)) +} + +fn imported_asset_from_sonar(sticker: sonar_stickers::Sticker) -> ImportedStickerAsset { + ImportedStickerAsset { + shortcode: sticker.shortcode, + url: sticker.url, + sha256: sticker.sha256, + mime: sticker.mime, + width: sticker.width, + height: sticker.height, + alt: sticker.alt, + emoji: sticker.emoji, + } +} + +/// Import a Sonar/Nostr sticker pack already published on public relays. The +/// link carries no secrets, so unlike the Signal path nothing is zeroized; +/// the pack's content-addressed asset URLs are kept as-is (the relay's +/// approval-gated sticker cache re-fetches and hash-verifies them), and the +/// importer republishes the pack under their own key through the normal +/// publish path. +#[tauri::command] +pub async fn import_nostr_sticker_pack( + link: String, + state: State<'_, AppState>, +) -> Result { + require_https_sticker_relay(&state)?; + let parsed = parse_nostr_sticker_pack_link(&link)?; + if parsed.relays.is_empty() { + return Err( + "That pack link has no relay hints. Use a link that includes relay= parameters." + .to_string(), + ); + } + let pack = fetch_nostr_sticker_pack(&parsed.address, &parsed.relays).await?; + Ok(ImportedStickerDraft { + identifier: pack.address.identifier, + title: pack.title, + author: Some(pack.address.author_pubkey_hex), + description: pack.description, + license: pack.license, + cover: pack.cover.map(imported_asset_from_sonar), + stickers: pack + .stickers + .into_iter() + .map(imported_asset_from_sonar) + .collect(), + skipped_sticker_ids: Vec::new(), + }) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const AUTHOR: &str = "7215b2db8754494fd3452b7f2d28b56e23863b95446bf68d79f980a7ad5ec7cd"; + + fn signed_pack_event(keys: &nostr::Keys, created_at: u64, content: &str) -> nostr::Event { + nostr::EventBuilder::new( + nostr::Kind::Custom(sonar_stickers::STICKER_PACK_KIND), + content, + ) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(keys) + .expect("sign event") + } + + #[test] + fn canonical_head_prefers_newest_timestamp() { + let keys = nostr::Keys::generate(); + let older = signed_pack_event(&keys, 100, "old-pack"); + let newer = signed_pack_event(&keys, 200, "new-pack"); + assert!(replaces_head(&newer, &older)); + assert!(!replaces_head(&older, &newer)); + assert!(!replaces_head(&older, &older)); + } + + #[test] + fn canonical_head_ties_break_to_smallest_event_id() { + let keys = nostr::Keys::generate(); + let a = signed_pack_event(&keys, 100, "pack-a"); + let b = signed_pack_event(&keys, 100, "pack-b"); + // Distinct signatures ⇒ distinct IDs; whichever is smaller must win. + assert_ne!(a.id, b.id); + let (small, large) = if a.id < b.id { (a, b) } else { (b, a) }; + assert!(replaces_head(&small, &large)); + assert!(!replaces_head(&large, &small)); + } + + #[test] + fn tampered_event_fails_verification() { + let keys = nostr::Keys::generate(); + let mut event = signed_pack_event(&keys, 100, "pack"); + assert!(event.verify().is_ok()); + // Forging the content (or any field) invalidates the Schnorr + // signature over the event ID — the import path must skip it. + event.content = "forged pack".into(); + assert!(event.verify().is_err()); + } + + #[test] + fn imported_asset_omits_none_fields_so_frontend_undefined_checks_pass() { + let asset = ImportedStickerAsset { + shortcode: "s0".into(), + url: "https://blossom.example/abc.webp".into(), + sha256: "a".repeat(64), + mime: "image/webp".into(), + width: None, + height: None, + alt: None, + emoji: None, + }; + let value = serde_json::to_value(&asset).expect("serialize"); + let object = value.as_object().expect("object"); + // publishStickerPack validates with `=== undefined`; serializing None + // as null fails `null !== undefined && null < 1` for dimension-less + // stickers (the common case for packs without dim fields). + for key in ["width", "height", "alt", "emoji"] { + assert!(!object.contains_key(key), "key {key} must be omitted"); + } + } + + #[test] + fn nostr_pack_link_parses_coordinate_and_relay_hints() { + let link = format!( + "https://sonarprivacy.xyz/stickers?a=30031:{AUTHOR}:signal-abc&relay=wss%3A%2F%2Frelay.damus.io&relay=wss%3A%2F%2Fnos.lol" + ); + let parsed = parse_nostr_sticker_pack_link(&link).expect("valid link"); + assert_eq!(parsed.address.author_pubkey_hex, AUTHOR); + assert_eq!(parsed.address.identifier, "signal-abc"); + assert_eq!( + parsed.relays, + vec![ + "wss://relay.damus.io".to_string(), + "wss://nos.lol".to_string() + ] + ); + } + + #[test] + fn nostr_pack_link_parses_bare_coordinate_without_relays() { + let parsed = parse_nostr_sticker_pack_link(&format!("30031:{AUTHOR}:my-pack")) + .expect("valid coordinate"); + assert_eq!(parsed.address.identifier, "my-pack"); + assert!(parsed.relays.is_empty()); + } + + #[test] + fn nostr_pack_link_rejects_bad_inputs() { + // http (not https) pack links + assert!( + parse_nostr_sticker_pack_link("http://sonarprivacy.xyz/stickers?a=30031:x:y").is_err() + ); + // missing ?a= coordinate + assert!(parse_nostr_sticker_pack_link( + "https://sonarprivacy.xyz/stickers?relay=wss://nos.lol" + ) + .is_err()); + // non-30031 coordinate + assert!(parse_nostr_sticker_pack_link(&format!( + "https://sonarprivacy.xyz/stickers?a=30030:{AUTHOR}:x" + )) + .is_err()); + // non-ws relay hint + assert!(parse_nostr_sticker_pack_link(&format!( + "https://sonarprivacy.xyz/stickers?a=30031:{AUTHOR}:x&relay=https%3A%2F%2Fevil.example" + )) + .is_err()); + // garbage + assert!(parse_nostr_sticker_pack_link("hello world").is_err()); + } + + #[test] + fn nostr_pack_link_caps_relay_hints() { + let relays = (0..12) + .map(|index| format!("relay=wss%3A%2F%2Fr{index}.example")) + .collect::>() + .join("&"); + let link = format!("https://sonarprivacy.xyz/stickers?a=30031:{AUTHOR}:x&{relays}"); + let parsed = parse_nostr_sticker_pack_link(&link).expect("valid link"); + assert_eq!(parsed.relays.len(), MAX_PACK_LINK_RELAYS); + } + + #[test] + fn sonar_sticker_validator_rejects_jpeg_and_non_webp_cover() { + let mut png = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgba8(1, 1) + .write_to(&mut png, image::ImageFormat::Png) + .expect("encode png"); + assert_eq!( + validate_sticker_image(png.get_ref(), false) + .expect("ordinary png sticker") + .0, + "image/png" + ); + assert!(validate_sticker_image(png.get_ref(), true).is_err()); + + let mut jpeg = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(1, 1) + .write_to(&mut jpeg, image::ImageFormat::Jpeg) + .expect("encode jpeg"); + assert!(validate_sticker_image(jpeg.get_ref(), false).is_err()); + } + + #[test] + fn signal_sticker_link_requires_official_https_host() { + let valid = format!( + "https://signal.art/addstickers/#pack_id={}&pack_key={}", + "a".repeat(32), + "b".repeat(64) + ); + assert!(validate_official_signal_sticker_link(&valid)); + assert!(!validate_official_signal_sticker_link( + &valid.replace("signal.art", "example.com") + )); + assert!(!validate_official_signal_sticker_link( + &valid.replace("https://", "http://") + )); + } +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 777d56d02ef..14fd18d701a 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -9,10 +9,11 @@ //! Each function validates inputs and returns a nostr::EventBuilder. //! Signing and submission happen in relay::submit_event. -use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; +use crate::sticker_events::sticker_tags; + // ── Constants ──────────────────────────────────────────────────────────────── /// Maximum content size — matches buzz-sdk (64 KiB). @@ -26,11 +27,11 @@ const MAX_EMOJI_CHARS: usize = 64; // ── Helpers ────────────────────────────────────────────────────────────────── -fn tag(parts: Vec<&str>) -> Result { +pub(crate) fn tag(parts: Vec<&str>) -> Result { Tag::parse(parts).map_err(|e| format!("invalid tag: {e}")) } -fn check_content(content: &str) -> Result<(), String> { +pub(crate) fn check_content(content: &str) -> Result<(), String> { if content.len() > MAX_CONTENT_BYTES { return Err(format!( "content exceeds maximum size of {} bytes (got {})", @@ -127,7 +128,7 @@ fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), Str } /// Validate a hex pubkey is exactly 64 hex characters. -fn check_pubkey(pubkey: &str) -> Result<(), String> { +pub(crate) fn check_pubkey(pubkey: &str) -> Result<(), String> { if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { return Err(format!( "pubkey must be a 64-character hex string (got {} chars)", @@ -295,6 +296,7 @@ pub fn build_remove_member(channel_id: Uuid, target_pubkey: &str) -> Result], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], + sticker_ref_tags: &[Vec], ) -> Result { build_message_with_client_tags( channel_id, @@ -312,6 +315,7 @@ pub fn build_message( media_tags, custom_emoji_tags, mention_ref_tags, + sticker_ref_tags, &[], ) } @@ -330,6 +334,7 @@ pub fn build_message_with_client_tags( media_tags: &[Vec], custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], + sticker_ref_tags: &[Vec], client_tags: &[Vec], ) -> Result { check_content(content)?; @@ -341,6 +346,7 @@ pub fn build_message_with_client_tags( imeta_tags(media_tags, &mut tags)?; emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; + sticker_tags(sticker_ref_tags, &mut tags)?; append_client_tags(client_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } @@ -629,127 +635,6 @@ pub fn build_relay_admin_change_role( Ok(EventBuilder::new(Kind::Custom(9032), "").tags(tags)) } -// ── NIP-IA identity archival ───────────────────────────────────────────────── -// -// kind:9035 archive request, kind:9036 unarchive request. -// Both protected by NIP-70 (`["-"]`), p-tag the target, and may carry -// optional `reason` (machine-readable code), `replaced-by` (9035 only), -// and a NIP-OA `auth` tag for owner-of-agent requests. -// -// See docs/nips/NIP-IA.md §Event Formats. The relay verifies; the desktop's -// job is to produce a well-formed, signed request — consent path is selected -// by the relay, not declared here. - -fn check_reason(reason: &str) -> Result<(), String> { - // Reason codes are machine-readable strings; the spec doesn't cap length - // but we keep them short to discourage stuffing prose where `content` goes. - if reason.len() > 64 { - return Err(format!( - "reason code exceeds maximum length of 64 chars (got {})", - reason.len() - )); - } - if reason.chars().any(|c| c.is_control()) { - return Err("reason code must not contain control characters".into()); - } - Ok(()) -} - -fn identity_archive_tags( - target_pubkey: &str, - reason: Option<&str>, - replaced_by: Option<&str>, - auth_tag: Option<&[String; 4]>, -) -> Result, String> { - check_pubkey(target_pubkey)?; - let target_lower = target_pubkey.to_ascii_lowercase(); - - let mut tags = Vec::with_capacity(5); - // NIP-70: mark as protected administrative state. - tags.push(tag(vec!["-"])?); - tags.push(tag(vec!["p", &target_lower])?); - - if let Some(r) = reason { - check_reason(r)?; - tags.push(tag(vec!["reason", r])?); - } - - if let Some(rb) = replaced_by { - check_pubkey(rb)?; - let rb_lower = rb.to_ascii_lowercase(); - if rb_lower == target_lower { - return Err("replaced-by must differ from the target".into()); - } - tags.push(tag(vec!["replaced-by", &rb_lower])?); - } - - if let Some(auth) = auth_tag { - // Structural check only — the relay performs full NIP-OA verification. - // We require the label, a 64-hex owner pubkey, and a 128-hex signature. - if auth[0] != "auth" { - return Err(format!( - "auth tag label must be \"auth\" (got \"{}\")", - auth[0] - )); - } - check_pubkey(&auth[1])?; - if auth[3].len() != 128 || !auth[3].chars().all(|c| c.is_ascii_hexdigit()) { - return Err("auth tag signature must be 128-character hex".into()); - } - tags.push(tag(vec!["auth", &auth[1], &auth[2], &auth[3]])?); - } - - Ok(tags) -} - -/// Kind 9035 — NIP-IA archive request. -/// -/// `content` is an optional human-readable reason (clients MUST NOT parse -/// authorization semantics from it). `reason` is the machine-readable code -/// (`rotated`, `retired`, `bot-rebuilt`, `left-organization`, `spam`, ...). -/// `replaced_by` is the rotation pointer. `auth` is a NIP-OA owner-attestation -/// tag required only for the owner-of-agent consent path. -/// -/// `.allow_self_tagging()` is required: NIP-IA's self path has `actor==target`, -/// which means the request's `["p", target]` matches the signer. nostr 0.44 -/// strips matching `p` tags by default — we need the wire form intact. -pub fn build_archive_identity_request( - target_pubkey: &str, - content: &str, - reason: Option<&str>, - replaced_by: Option<&str>, - auth: Option<&[String; 4]>, -) -> Result { - check_content(content)?; - let tags = identity_archive_tags(target_pubkey, reason, replaced_by, auth)?; - Ok( - EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16), content) - .tags(tags) - .allow_self_tagging(), - ) -} - -/// Kind 9036 — NIP-IA unarchive request. -/// -/// Same shape as 9035 minus `replaced-by` (which has no defined meaning on -/// unarchive per spec). `auth` is used for owner-of-agent unarchive paths. -/// See `build_archive_identity_request` for the rationale on -/// `.allow_self_tagging()`. -pub fn build_unarchive_identity_request( - target_pubkey: &str, - content: &str, - reason: Option<&str>, - auth: Option<&[String; 4]>, -) -> Result { - check_content(content)?; - let tags = identity_archive_tags(target_pubkey, reason, None, auth)?; - Ok( - EventBuilder::new(Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16), content) - .tags(tags) - .allow_self_tagging(), - ) -} - /// Maximum contacts per contact list event. const MAX_CONTACTS: usize = 10_000; @@ -856,80 +741,6 @@ mod tests { 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] - fn archive_identity_request_matches_spec_vector_1_layout() { - const OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; - const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - const CONDITIONS: &str = "kind=1&created_at<1713957000"; - const SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369"; - - let auth: [String; 4] = [ - "auth".into(), - OWNER_HEX.into(), - CONDITIONS.into(), - SIG.into(), - ]; - let builder = build_archive_identity_request( - TARGET_HEX, - "Archiving zombie agent after rebuild.", - Some("bot-rebuilt"), - None, - Some(&auth), - ) - .expect("build_archive_identity_request"); - - let owner_secret = nostr::SecretKey::from_hex( - "0000000000000000000000000000000000000000000000000000000000000001", - ) - .unwrap(); - let owner_keys = Keys::new(owner_secret); - let event = builder.sign_with_keys(&owner_keys).unwrap(); - - let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); - - assert_eq!(event.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16)); - // Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...] - assert_eq!(tags[0], vec!["-"]); - assert_eq!(tags[1], vec!["p", TARGET_HEX]); - assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]); - assert_eq!(tags[3], vec!["auth", OWNER_HEX, CONDITIONS, SIG]); - } - - #[test] - fn archive_request_rejects_replaced_by_equal_target() { - const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - let err = build_archive_identity_request(TARGET_HEX, "", None, Some(TARGET_HEX), None) - .unwrap_err(); - assert!(err.contains("replaced-by")); - } - - #[test] - fn unarchive_request_layout_self_path() { - const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; - let builder = build_unarchive_identity_request( - TARGET_HEX, - "I am active again.", - Some("returned"), - None, - ) - .unwrap(); - let target_secret = nostr::SecretKey::from_hex( - "0000000000000000000000000000000000000000000000000000000000000002", - ) - .unwrap(); - let event = builder.sign_with_keys(&Keys::new(target_secret)).unwrap(); - let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); - assert_eq!(event.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16)); - // Self-unarchive: the `p` tag MUST point at the signer. Verifies our - // `.allow_self_tagging()` call survives nostr 0.44's default scrub. - assert_eq!(tags[0], vec!["-"]); - assert_eq!(tags[1], vec!["p", TARGET_HEX]); - assert_eq!(tags[2], vec!["reason", "returned"]); - assert_eq!(tags.len(), 3, "self unarchive must not carry auth tag"); - assert_eq!(event.pubkey.to_hex(), TARGET_HEX); - } // ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── // diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index fba5464a69f..0759e1a9b0b 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -620,7 +620,7 @@ pub(crate) fn spawn_transcription_task( let p_tags: Vec<&str> = agent_pubkeys.iter().map(|s| s.as_str()).collect(); let builder = - match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[]) { + match events::build_message(channel_uuid, &t, None, &p_tags, &[], &[], &[], &[]) { Ok(b) => b, Err(e) => { eprintln!("buzz-desktop: STT build_message: {e}"); diff --git a/desktop/src-tauri/src/identity_archive_events.rs b/desktop/src-tauri/src/identity_archive_events.rs new file mode 100644 index 00000000000..0f98c1784ec --- /dev/null +++ b/desktop/src-tauri/src/identity_archive_events.rs @@ -0,0 +1,211 @@ +//! NIP-IA identity archival event builders. +//! +//! Split out of `events.rs` to keep that module under the desktop file-size +//! ceiling; see `desktop/scripts/check-file-sizes.mjs`. + +use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; +use nostr::{EventBuilder, Kind, Tag}; + +use crate::events::{check_content, check_pubkey, tag}; + +// ── NIP-IA identity archival ───────────────────────────────────────────────── +// +// kind:9035 archive request, kind:9036 unarchive request. +// Both protected by NIP-70 (`["-"]`), p-tag the target, and may carry +// optional `reason` (machine-readable code), `replaced-by` (9035 only), +// and a NIP-OA `auth` tag for owner-of-agent requests. +// +// See docs/nips/NIP-IA.md §Event Formats. The relay verifies; the desktop's +// job is to produce a well-formed, signed request — consent path is selected +// by the relay, not declared here. + +fn check_reason(reason: &str) -> Result<(), String> { + // Reason codes are machine-readable strings; the spec doesn't cap length + // but we keep them short to discourage stuffing prose where `content` goes. + if reason.len() > 64 { + return Err(format!( + "reason code exceeds maximum length of 64 chars (got {})", + reason.len() + )); + } + if reason.chars().any(|c| c.is_control()) { + return Err("reason code must not contain control characters".into()); + } + Ok(()) +} + +fn identity_archive_tags( + target_pubkey: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + auth_tag: Option<&[String; 4]>, +) -> Result, String> { + check_pubkey(target_pubkey)?; + let target_lower = target_pubkey.to_ascii_lowercase(); + + let mut tags = Vec::with_capacity(5); + // NIP-70: mark as protected administrative state. + tags.push(tag(vec!["-"])?); + tags.push(tag(vec!["p", &target_lower])?); + + if let Some(r) = reason { + check_reason(r)?; + tags.push(tag(vec!["reason", r])?); + } + + if let Some(rb) = replaced_by { + check_pubkey(rb)?; + let rb_lower = rb.to_ascii_lowercase(); + if rb_lower == target_lower { + return Err("replaced-by must differ from the target".into()); + } + tags.push(tag(vec!["replaced-by", &rb_lower])?); + } + + if let Some(auth) = auth_tag { + // Structural check only — the relay performs full NIP-OA verification. + // We require the label, a 64-hex owner pubkey, and a 128-hex signature. + if auth[0] != "auth" { + return Err(format!( + "auth tag label must be \"auth\" (got \"{}\")", + auth[0] + )); + } + check_pubkey(&auth[1])?; + if auth[3].len() != 128 || !auth[3].chars().all(|c| c.is_ascii_hexdigit()) { + return Err("auth tag signature must be 128-character hex".into()); + } + tags.push(tag(vec!["auth", &auth[1], &auth[2], &auth[3]])?); + } + + Ok(tags) +} + +/// Kind 9035 — NIP-IA archive request. +/// +/// `content` is an optional human-readable reason (clients MUST NOT parse +/// authorization semantics from it). `reason` is the machine-readable code +/// (`rotated`, `retired`, `bot-rebuilt`, `left-organization`, `spam`, ...). +/// `replaced_by` is the rotation pointer. `auth` is a NIP-OA owner-attestation +/// tag required only for the owner-of-agent consent path. +/// +/// `.allow_self_tagging()` is required: NIP-IA's self path has `actor==target`, +/// which means the request's `["p", target]` matches the signer. nostr 0.44 +/// strips matching `p` tags by default — we need the wire form intact. +pub fn build_archive_identity_request( + target_pubkey: &str, + content: &str, + reason: Option<&str>, + replaced_by: Option<&str>, + auth: Option<&[String; 4]>, +) -> Result { + check_content(content)?; + let tags = identity_archive_tags(target_pubkey, reason, replaced_by, auth)?; + Ok( + EventBuilder::new(Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16), content) + .tags(tags) + .allow_self_tagging(), + ) +} + +/// Kind 9036 — NIP-IA unarchive request. +/// +/// Same shape as 9035 minus `replaced-by` (which has no defined meaning on +/// unarchive per spec). `auth` is used for owner-of-agent unarchive paths. +/// See `build_archive_identity_request` for the rationale on +/// `.allow_self_tagging()`. +pub fn build_unarchive_identity_request( + target_pubkey: &str, + content: &str, + reason: Option<&str>, + auth: Option<&[String; 4]>, +) -> Result { + check_content(content)?; + let tags = identity_archive_tags(target_pubkey, reason, None, auth)?; + Ok( + EventBuilder::new(Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16), content) + .tags(tags) + .allow_self_tagging(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + + /// Builder layout regression for the NIP-IA owner-of-agent archive flow. + /// Compares against `docs/nips/NIP-IA.md` §Vector 1. + #[test] + fn archive_identity_request_matches_spec_vector_1_layout() { + const OWNER_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const CONDITIONS: &str = "kind=1&created_at<1713957000"; + const SIG: &str = "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369"; + + let auth: [String; 4] = [ + "auth".into(), + OWNER_HEX.into(), + CONDITIONS.into(), + SIG.into(), + ]; + let builder = build_archive_identity_request( + TARGET_HEX, + "Archiving zombie agent after rebuild.", + Some("bot-rebuilt"), + None, + Some(&auth), + ) + .expect("build_archive_identity_request"); + + let owner_secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + ) + .unwrap(); + let owner_keys = Keys::new(owner_secret); + let event = builder.sign_with_keys(&owner_keys).unwrap(); + + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + assert_eq!(event.kind, Kind::Custom(KIND_IA_ARCHIVE_REQUEST as u16)); + // Spec layout: ["-"], ["p", target], ["reason", code], ["auth", ...] + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "bot-rebuilt"]); + assert_eq!(tags[3], vec!["auth", OWNER_HEX, CONDITIONS, SIG]); + } + + #[test] + fn archive_request_rejects_replaced_by_equal_target() { + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + let err = build_archive_identity_request(TARGET_HEX, "", None, Some(TARGET_HEX), None) + .unwrap_err(); + assert!(err.contains("replaced-by")); + } + + #[test] + fn unarchive_request_layout_self_path() { + const TARGET_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + let builder = build_unarchive_identity_request( + TARGET_HEX, + "I am active again.", + Some("returned"), + None, + ) + .unwrap(); + let target_secret = nostr::SecretKey::from_hex( + "0000000000000000000000000000000000000000000000000000000000000002", + ) + .unwrap(); + let event = builder.sign_with_keys(&Keys::new(target_secret)).unwrap(); + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + assert_eq!(event.kind, Kind::Custom(KIND_IA_UNARCHIVE_REQUEST as u16)); + // Self-unarchive: the `p` tag MUST point at the signer. Verifies our + // `.allow_self_tagging()` call survives nostr 0.44's default scrub. + assert_eq!(tags[0], vec!["-"]); + assert_eq!(tags[1], vec!["p", TARGET_HEX]); + assert_eq!(tags[2], vec!["reason", "returned"]); + assert_eq!(tags.len(), 3, "self unarchive must not carry auth tag"); + assert_eq!(event.pubkey.to_hex(), TARGET_HEX); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6814008f0da..34d193f1c30 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod egress_guard; mod event_sync; mod events; mod huddle; +mod identity_archive_events; mod identity_storage; mod key_backup; mod linux_media; @@ -31,6 +32,7 @@ mod relay_admission; mod reset; mod secret_store; mod shutdown; +mod sticker_events; mod templates; #[cfg(target_os = "macos")] mod tray_menu; @@ -161,10 +163,8 @@ pub fn run() { // Keep the runtime alive for the process lifetime; dropping it // would shut down the workers Tauri now depends on. std::mem::forget(runtime); - eprintln!( - "buzz-mesh: installed tokio runtime with {} MiB worker stacks", - crate::mesh_llm::MESH_WORKER_STACK_SIZE / (1024 * 1024) - ); + let stack_mib = crate::mesh_llm::MESH_WORKER_STACK_SIZE / (1024 * 1024); + eprintln!("buzz-mesh: installed tokio runtime with {stack_mib} MiB worker stacks"); } Err(error) => { // Fall back to Tauri's default runtime: the app still works, @@ -780,6 +780,9 @@ pub fn run() { upload_media, pick_and_upload_media, pick_and_upload_image, + pick_and_upload_sticker_image, + import_signal_sticker_pack, + import_nostr_sticker_pack, upload_media_bytes, download_image, save_png_data_url, diff --git a/desktop/src-tauri/src/sticker_events.rs b/desktop/src-tauri/src/sticker_events.rs new file mode 100644 index 00000000000..86fb54b5ebc --- /dev/null +++ b/desktop/src-tauri/src/sticker_events.rs @@ -0,0 +1,34 @@ +//! Sonar sticker reference validation for message builders. + +use nostr::Tag; + +/// Validate and append exactly one Sonar message sticker reference. Keeping +/// this as a dedicated lane prevents renderer-controlled arbitrary tag +/// injection through the media/emoji inputs. +pub(crate) fn sticker_tags( + sticker_tags: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + if sticker_tags.len() > 1 { + return Err("a message may reference at most one sticker".into()); + } + for sticker in sticker_tags { + if sticker.len() != 4 || sticker.first().map(String::as_str) != Some("sticker") { + return Err("sticker tag must contain coordinate, shortcode, and sha256".into()); + } + let pack = sonar_stickers::PackAddress::parse(&sticker[1]) + .map_err(|_| "invalid sticker pack coordinate".to_string())?; + let reference = + sonar_stickers::StickerRef::new(pack, sticker[2].clone(), sticker[3].clone()) + .map_err(|_| "invalid sticker reference".to_string())?; + if reference.pack.coordinate() != sticker[1] + || reference.shortcode != sticker[2] + || reference.plaintext_sha256 != sticker[3] + { + return Err("sticker reference must use canonical lowercase hex".to_string()); + } + let parts: Vec<&str> = sticker.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid sticker tag: {e}"))?); + } + Ok(()) +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4eb0a42bbe9..14a2f27e14f 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -52,7 +52,7 @@ import { useUserStatusQuery, useUserStatusSubscription, } from "@/features/user-status/hooks"; -import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; +import { useLiveUpdates } from "@/app/useLiveUpdates"; import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; import { useObserverArchiveReconciliation } from "@/features/local-archive/useObserverArchiveSeed"; import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed"; @@ -200,7 +200,7 @@ export function AppShell() { useRelayAutoHeal(); usePresenceSubscription(); useUserStatusSubscription(); - useCommunityEmojiLiveUpdates(); + useLiveUpdates(); useMembershipNotifications(identityQuery.data?.pubkey); const presenceSession = usePresenceSession(deferredPubkey); const selfStatusQuery = useUserStatusQuery( diff --git a/desktop/src/app/useLiveUpdates.ts b/desktop/src/app/useLiveUpdates.ts new file mode 100644 index 00000000000..76ac9c8d33a --- /dev/null +++ b/desktop/src/app/useLiveUpdates.ts @@ -0,0 +1,12 @@ +import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; +import { useStickerLiveUpdates } from "@/features/stickers/hooks"; + +/** + * Subscribes the app shell to all workspace-scoped live-update streams + * (community custom emoji, sticker catalog). Adding a new stream here keeps + * AppShell's hook list flat. + */ +export function useLiveUpdates(): void { + useCommunityEmojiLiveUpdates(); + useStickerLiveUpdates(); +} diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 062b0ee40b4..c25ae73d830 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -458,6 +458,7 @@ export function useSendMessageMutation( mediaTags: imetaTags, emojiTags, mentionTags, + stickerTags, } = splitOutgoingTags(mediaTags); const recipientPubkeys = messageMentionPubkeys( effectiveChannel, @@ -468,7 +469,12 @@ export function useSendMessageMutation( // Messages carrying media OR custom-emoji tags MUST go through REST so // the relay's tag validation runs. The WebSocket path emits no extra // tags, so emoji-only messages would otherwise lose their emoji tag. - if (parentEventId || imetaTags.length > 0 || emojiTags.length > 0) { + if ( + parentEventId || + imetaTags.length > 0 || + emojiTags.length > 0 || + stickerTags.length > 0 + ) { const cachedMessages = queryClient.getQueryData( channelMessagesKey(effectiveChannel.id), @@ -482,6 +488,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + stickerTags, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -519,6 +526,7 @@ export function useSendMessageMutation( ...imetaTags, ...emojiTags, ...mentionTags, + ...stickerTags, ], content: content.trim(), sig: "", diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs index a2edaa6f8cf..dc27ca902ad 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.test.mjs @@ -667,16 +667,17 @@ const MENTION_REF = [ "1111111111111111111111111111111111111111111111111111111111111111", ]; -test("splitOutgoingTags: undefined input yields three empty arrays", () => { +test("splitOutgoingTags: undefined input yields four empty arrays", () => { assert.deepEqual(splitOutgoingTags(undefined), { mediaTags: [], emojiTags: [], mentionTags: [], + stickerTags: [], }); }); test("splitOutgoingTags: separates emoji tags from imeta tags", () => { - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ + const { mediaTags, emojiTags, mentionTags, stickerTags } = splitOutgoingTags([ IMETA, EMOJI_A, EMOJI_B, @@ -684,17 +685,21 @@ test("splitOutgoingTags: separates emoji tags from imeta tags", () => { assert.deepEqual(mediaTags, [IMETA]); assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]); assert.deepEqual(mentionTags, []); + assert.deepEqual(stickerTags, []); }); test("splitOutgoingTags: emoji-only set leaves mediaTags empty", () => { - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([EMOJI_A]); + const { mediaTags, emojiTags, mentionTags, stickerTags } = splitOutgoingTags([ + EMOJI_A, + ]); assert.deepEqual(mediaTags, []); assert.deepEqual(emojiTags, [EMOJI_A]); assert.deepEqual(mentionTags, []); + assert.deepEqual(stickerTags, []); }); test("splitOutgoingTags: separates reference-only mention tags", () => { - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ + const { mediaTags, emojiTags, mentionTags, stickerTags } = splitOutgoingTags([ IMETA, MENTION_REF, EMOJI_A, @@ -702,25 +707,41 @@ test("splitOutgoingTags: separates reference-only mention tags", () => { assert.deepEqual(mediaTags, [IMETA]); assert.deepEqual(emojiTags, [EMOJI_A]); assert.deepEqual(mentionTags, [MENTION_REF]); + assert.deepEqual(stickerTags, []); }); test("splitOutgoingTags: unknown prefixes stay with mediaTags (injection defense)", () => { // A forged ["p", ...] must NOT be misrouted to the emoji channel; it stays on // mediaTags where the server-side imeta guard rejects it. const forged = ["p", "deadbeef"]; - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags([ + const { mediaTags, emojiTags, mentionTags, stickerTags } = splitOutgoingTags([ forged, EMOJI_A, ]); assert.deepEqual(mediaTags, [forged]); assert.deepEqual(emojiTags, [EMOJI_A]); assert.deepEqual(mentionTags, []); + assert.deepEqual(stickerTags, []); }); test("splitOutgoingTags is the inverse of mergeOutgoingTags", () => { const merged = mergeOutgoingTags([IMETA], [EMOJI_A, EMOJI_B]); - const { mediaTags, emojiTags, mentionTags } = splitOutgoingTags(merged); + const { mediaTags, emojiTags, mentionTags, stickerTags } = + splitOutgoingTags(merged); assert.deepEqual(mediaTags, [IMETA]); assert.deepEqual(emojiTags, [EMOJI_A, EMOJI_B]); assert.deepEqual(mentionTags, []); + assert.deepEqual(stickerTags, []); +}); + +test("splitOutgoingTags routes Sonar sticker references to the dedicated lane", () => { + const sticker = [ + "sticker", + `30031:${"a".repeat(64)}:pack`, + "wave", + "b".repeat(64), + ]; + const split = splitOutgoingTags([IMETA, sticker]); + assert.deepEqual(split.mediaTags, [IMETA]); + assert.deepEqual(split.stickerTags, [sticker]); }); diff --git a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts index 3e16cb332d0..c5768f6678c 100644 --- a/desktop/src/features/messages/lib/imetaMediaMarkdown.ts +++ b/desktop/src/features/messages/lib/imetaMediaMarkdown.ts @@ -342,9 +342,11 @@ export function buildOutgoingMessage( export function mergeOutgoingTags( mediaTags: string[][] | undefined, emojiTags: string[][], + stickerTags: string[][] = [], ): string[][] | undefined { - if (!mediaTags && emojiTags.length === 0) return undefined; - return [...(mediaTags ?? []), ...emojiTags]; + if (!mediaTags && emojiTags.length === 0 && stickerTags.length === 0) + return undefined; + return [...(mediaTags ?? []), ...emojiTags, ...stickerTags]; } /** @@ -359,18 +361,22 @@ export function splitOutgoingTags(tags: string[][] | undefined): { mediaTags: string[][]; emojiTags: string[][]; mentionTags: string[][]; + stickerTags: string[][]; } { const mediaTags: string[][] = []; const emojiTags: string[][] = []; const mentionTags: string[][] = []; + const stickerTags: string[][] = []; for (const tag of tags ?? []) { if (tag[0] === "emoji") { emojiTags.push(tag); } else if (tag[0] === "mention") { mentionTags.push(tag); + } else if (tag[0] === "sticker") { + stickerTags.push(tag); } else { mediaTags.push(tag); } } - return { mediaTags, emojiTags, mentionTags }; + return { mediaTags, emojiTags, mentionTags, stickerTags }; } diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 69e4ec67b5d..426c4cceaf9 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -10,12 +10,9 @@ import { resolveSentDraftKey } from "@/features/messages/ui/draftSubmitKey"; import { useEmojiAutocomplete } from "@/features/messages/lib/useEmojiAutocomplete"; import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; -import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; import { - buildOutgoingMessage, findSpoileredImetaMediaUrls, type ImetaMedia, - mergeOutgoingTags, restoreImetaMediaDisplayLabels, stripImetaMediaLines, } from "@/features/messages/lib/imetaMediaMarkdown"; @@ -23,7 +20,6 @@ import { import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; import { useIdentityQuery } from "@/shared/api/hooks"; import { @@ -50,6 +46,15 @@ import { type MentionSuggestion, } from "./MentionAutocomplete"; import { ComposerDockToolbar } from "./ComposerDockToolbar"; +import type { StickerSelection } from "@/features/stickers/ui/ComposerStickerPicker"; +import { + captureStickerDraft, + shouldRestoreStickerDraft, + stickerSendOverrides, +} from "./stickerComposerSend"; +import { buildMessageEditPayload } from "./messageEditPayload"; +import { resolveEmojiInsertion } from "./composerEmojiInsert"; +import { applyAutocompleteKeyResult } from "./composerAutocompleteKeys"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionHydration"; @@ -205,7 +210,9 @@ function MessageComposerImpl({ channelLinks.isChannelOpen || emojiAutocomplete.isEmojiAutocompleteOpen; - const submitMessageRef = React.useRef<() => void>(() => {}); + const submitMessageRef = React.useRef< + (immediateSticker?: StickerSelection) => void + >(() => {}); const composerScrollRef = React.useRef(null); // Set after `useLinkEditor` exists below; the editor's link-click handler @@ -441,32 +448,18 @@ function MessageComposerImpl({ const insertEmoji = React.useCallback( (emoji: string) => { if (!richText.editor) return; - // A `:shortcode:` for a known custom emoji becomes a selectable atom - // node (same as the input rule / autocomplete), so it can be selected, - // copied, and deleted as one unit. Everything else (native unicode) - // inserts as plain content. - const match = /^:([^:\s]+):$/.exec(emoji); - const shortcode = match?.[1]?.toLowerCase(); - const known = - shortcode && - customEmoji.some((e) => e.shortcode.toLowerCase() === shortcode); - if (known && shortcode) { - richText.editor - .chain() - .focus() + const insertion = resolveEmojiInsertion(emoji, customEmoji); + const chain = richText.editor.chain().focus(); + if (insertion.kind === "custom-node") { + chain .insertContent({ type: CUSTOM_EMOJI_NODE_NAME, - attrs: { - shortcode, - src: - customEmoji.find((e) => e.shortcode.toLowerCase() === shortcode) - ?.url ?? "", - }, + attrs: { shortcode: insertion.shortcode, src: insertion.src }, }) .insertContent(" ") .run(); } else { - richText.editor.chain().focus().insertContent(emoji).run(); + chain.insertContent(insertion.text).run(); } setIsEmojiPickerOpen(false); mentions.clearMentions(); @@ -506,139 +499,146 @@ function MessageComposerImpl({ ]); // ── Submit message ────────────────────────────────────────────────── - const submitMessage = React.useCallback(async () => { - const trimmed = syncComposerContentFromEditor().trim(); + const submitMessage = React.useCallback( + async (immediateSticker?: StickerSelection) => { + const trimmed = syncComposerContentFromEditor().trim(); + + // Edit mode + if (editTargetRef.current && onEditSaveRef.current) { + if (isSendingRef.current || isUploadingRef.current) return; + const currentPendingImeta = media.pendingImetaRef.current; + const hasMedia = currentPendingImeta.length > 0; + // Empty text + zero attachments is a no-op (don't let edit become an + // effective deletion). + if (!trimmed && !hasMedia) return; + + // Derived before the composer state is cleared below. + const { finalContent, outgoingTags, addedMentionPubkeys } = + buildMessageEditPayload({ + trimmed, + pendingImeta: currentPendingImeta, + spoileredAttachmentUrls, + customEmoji, + previousBody: editTargetRef.current.body, + extractMentionPubkeys: extractMentionPubkeysRef.current, + ownerPubkey: ownerPubkeyRef.current ?? "", + }); + + const savedContent = trimmed; + const savedImeta = [...currentPendingImeta]; + const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls); + setComposerContent(""); + richText.clearContent(); + media.setPendingImeta([]); + setSpoileredAttachmentUrls(new Set()); + mentions.clearMentions(); + channelLinks.clearChannels(); + emojiAutocomplete.clearEmojis(); + setIsEmojiPickerOpen(false); + + try { + await onEditSaveRef.current( + finalContent, + outgoingTags, + addedMentionPubkeys, + ); + } catch { + setComposerContent(savedContent); + richText.setContent(savedContent); + media.setPendingImeta(savedImeta); + setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls); + } + return; + } - // Edit mode - if (editTargetRef.current && onEditSaveRef.current) { - if (isSendingRef.current || isUploadingRef.current) return; + // Normal send const currentPendingImeta = media.pendingImetaRef.current; - // No empty-edit guard here: clearing an edit to empty (no text, no - // attachments) flows through to onEditSave as empty content, which - // deletes the message instead of publishing it (see handleEditSave). - - // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` - // because edit semantics use `[]` as the explicit "wipe all - // attachments" signal — the receiver overlay drops imeta when the - // edit carries an empty (but defined) set. - const { content: finalContent, mediaTags } = buildOutgoingMessage( - trimmed, + const hasMedia = currentPendingImeta.length > 0; + const hasSticker = immediateSticker !== undefined; + if ( + (!trimmed && !hasMedia && !hasSticker) || + disabledRef.current || + isSendingRef.current || + isUploadingRef.current || + mentionSendFlow.isPreparingMentionSend + ) { + return; + } + + const capturedThreadContext = onCaptureSendContext?.() ?? null; + if ( + capturedThreadContext !== null && + !capturedThreadContext.parentEventId + ) { + return; + } + + // A sticker send is the sticker alone; snapshot the draft so the send + // flow's composer clear does not eat it. See stickerComposerSend.ts. + const stickerDraft = captureStickerDraft( + immediateSticker, + syncComposerContentFromEditor(), currentPendingImeta, spoileredAttachmentUrls, ); - // NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the - // edited body, exactly like the send path. Without this an edited message - // ships with no emoji tags, so the receiver can't resolve a `:shortcode:` - // and renders the literal text. `?? []` preserves edit semantics (a - // defined-but-empty media set means "wipe attachments"). - const outgoingTags = - mergeOutgoingTags( - mediaTags, - buildCustomEmojiTags(finalContent, customEmoji), - ) ?? []; - - // Notify only mentions this edit *newly adds* (see - // diffAddedMentionPubkeys): a typo-fix edit that leaves the mention set - // unchanged emits no `p` tags and re-wakes nobody. Computed before the - // composer state is cleared below. - const addedMentionPubkeys = diffAddedMentionPubkeys( - extractMentionPubkeysRef.current(editTargetRef.current.body), - extractMentionPubkeysRef.current(finalContent), - ownerPubkeyRef.current ?? "", - ); - - const savedContent = trimmed; - const savedImeta = [...currentPendingImeta]; - const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls); - setComposerContent(""); - richText.clearContent(); - media.setPendingImeta([]); - setSpoileredAttachmentUrls(new Set()); - mentions.clearMentions(); - channelLinks.clearChannels(); - emojiAutocomplete.clearEmojis(); - setIsEmojiPickerOpen(false); - + onPreparingMentionSendChange?.(true); + persistentMentionHydration.beginSubmit(); try { - await onEditSaveRef.current( - finalContent, - outgoingTags, - addedMentionPubkeys, - ); - } catch { - setComposerContent(savedContent); - richText.setContent(savedContent); - media.setPendingImeta(savedImeta); - setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls); + await mentionSendFlow.sendMessageWithMentionFlow({ + capturedChannelId: channelId, + capturedThreadContext, + sentDraftKey: resolveSentDraftKey( + effectiveDraftKeyRef.current, + drafts.loadDraft, + ), + audienceGeneration: persistentAudience.generation, + audienceRevision: audienceScope ? persistentAudience.revision : null, + ...stickerSendOverrides( + immediateSticker, + currentPendingImeta, + spoileredAttachmentUrls, + trimmed, + ), + }); + } finally { + persistentMentionHydration.endSubmit(); + onPreparingMentionSendChange?.(false); } - return; - } - - // Normal send - const currentPendingImeta = media.pendingImetaRef.current; - const hasMedia = currentPendingImeta.length > 0; - if ( - (!trimmed && !hasMedia) || - disabledRef.current || - isSendingRef.current || - isUploadingRef.current || - mentionSendFlow.isPreparingMentionSend - ) { - return; - } - - const capturedThreadContext = onCaptureSendContext?.() ?? null; - if ( - capturedThreadContext !== null && - !capturedThreadContext.parentEventId - ) { - return; - } - onPreparingMentionSendChange?.(true); - persistentMentionHydration.beginSubmit(); - try { - await mentionSendFlow.sendMessageWithMentionFlow({ - capturedChannelId: channelId, - capturedThreadContext, - pendingImeta: currentPendingImeta, - sentDraftKey: resolveSentDraftKey( - effectiveDraftKeyRef.current, - drafts.loadDraft, - ), - spoileredAttachmentUrls, - trimmed, - audienceGeneration: persistentAudience.generation, - audienceRevision: audienceScope ? persistentAudience.revision : null, - }); - } finally { - persistentMentionHydration.endSubmit(); - onPreparingMentionSendChange?.(false); - } - }, [ - channelId, - channelLinks.clearChannels, - customEmoji, - drafts.loadDraft, - emojiAutocomplete.clearEmojis, - media.pendingImetaRef, - media.setPendingImeta, - mentionSendFlow.isPreparingMentionSend, - mentionSendFlow.sendMessageWithMentionFlow, - mentions.clearMentions, - richText.clearContent, - richText.setContent, - setComposerContent, - spoileredAttachmentUrls, - syncComposerContentFromEditor, - onCaptureSendContext, - onPreparingMentionSendChange, - audienceScope, - persistentMentionHydration, - persistentAudience.generation, - persistentAudience.revision, - ]); + if ( + shouldRestoreStickerDraft(stickerDraft, syncComposerContentFromEditor()) + ) { + setComposerContent(stickerDraft.content); + richText.setContent(stickerDraft.content); + media.setPendingImeta(stickerDraft.imeta); + setSpoileredAttachmentUrls(stickerDraft.spoileredAttachmentUrls); + } + }, + [ + channelId, + channelLinks.clearChannels, + customEmoji, + drafts.loadDraft, + emojiAutocomplete.clearEmojis, + media.pendingImetaRef, + media.setPendingImeta, + mentionSendFlow.isPreparingMentionSend, + mentionSendFlow.sendMessageWithMentionFlow, + mentions.clearMentions, + richText.clearContent, + richText.setContent, + setComposerContent, + spoileredAttachmentUrls, + syncComposerContentFromEditor, + onCaptureSendContext, + onPreparingMentionSendChange, + audienceScope, + persistentMentionHydration, + persistentAudience.generation, + persistentAudience.revision, + ], + ); submitMessageRef.current = submitMessage; // ── Auto-submit on draft send ──────────────────────────────────────────── @@ -693,30 +693,21 @@ function MessageComposerImpl({ // handles autocomplete arrow/enter keys and Escape for edit mode. const handleEditorKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - // Let autocomplete handle keys first - const emojiResult = emojiAutocomplete.handleEmojiKeyDown(event); - if (emojiResult.handled) { - if (emojiResult.suggestion) { - applyEmojiInsert(emojiResult.suggestion); - } - return; - } - - const channelResult = channelLinks.handleChannelKeyDown(event); - if (channelResult.handled) { - if (channelResult.suggestion) { - applyChannelInsert(channelResult.suggestion); - } - return; - } - - const { handled, suggestion } = mentions.handleMentionKeyDown(event); - if (handled) { - if (suggestion) { - applyMentionInsert(suggestion); - } - return; - } + // Let autocomplete handle keys first, in priority order. + const consumed = + applyAutocompleteKeyResult( + emojiAutocomplete.handleEmojiKeyDown(event), + applyEmojiInsert, + ) || + applyAutocompleteKeyResult( + channelLinks.handleChannelKeyDown(event), + applyChannelInsert, + ) || + applyAutocompleteKeyResult( + mentions.handleMentionKeyDown(event), + applyMentionInsert, + ); + if (consumed) return; if (event.key === "Tab" && !event.shiftKey && linkEditor.isCardOpen) { event.preventDefault(); @@ -837,6 +828,14 @@ function MessageComposerImpl({ ], ); + // Sticker click sends immediately — Signal-style, no pending/attach step. + const handleStickerSelect = React.useCallback( + (selection: StickerSelection) => { + void submitMessageRef.current(selection); + }, + [], + ); + const handleCaptureSelection = React.useCallback(() => { // No-op for Tiptap — selection is managed by ProseMirror. }, []); @@ -1001,6 +1000,7 @@ function MessageComposerImpl({ onLinkButton={linkEditor.openFromToolbar} onOpenMentionPicker={openMentionPicker} onPaperclip={handlePaperclipClick} + onStickerSelect={handleStickerSelect} sendDisabled={sendDisabled} /> diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 1c8087e9920..998bc65d995 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -8,6 +8,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { ComposerEmojiPicker } from "./ComposerEmojiPicker"; import { FormattingToolbar } from "./FormattingToolbar"; import { SelectionFormattingTray } from "./SelectionFormattingTray"; +import { + ComposerStickerPicker, + type StickerSelection, +} from "@/features/stickers/ui/ComposerStickerPicker"; /** Spring for enter/exit of button groups — all fire simultaneously. */ const presenceSpring = { @@ -33,6 +37,7 @@ export const MessageComposerToolbar = React.memo( onLinkButton, onOpenMentionPicker, onPaperclip, + onStickerSelect, sendDisabled, }: { composerDisabled: boolean; @@ -50,6 +55,7 @@ export const MessageComposerToolbar = React.memo( onLinkButton: () => void; onOpenMentionPicker: () => void; onPaperclip: () => void; + onStickerSelect?: (selection: StickerSelection) => void; sendDisabled: boolean; }) { return ( @@ -200,6 +206,12 @@ export const MessageComposerToolbar = React.memo( onTriggerMouseDown={onCaptureSelection} open={isEmojiPickerOpen} /> + {onStickerSelect ? ( + + ) : null} import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); @@ -305,8 +307,17 @@ export const MessageRow = React.memo( }, [collapseDepthGuideActions]); const getTag = (name: string) => message.tags?.find((tag) => tag[0] === name)?.[1]; + const stickerReference = parseStickerReference(message.tags); const renderBody = () => { + if (stickerReference) { + return ( + + ); + } switch (message.kind) { case KIND_STREAM_MESSAGE_DIFF: return ( @@ -497,7 +508,7 @@ export const MessageRow = React.memo( isUnread={isUnread} message={message} onDelete={onDelete} - onEdit={onEdit} + onEdit={stickerReference ? undefined : onEdit} onFollowThread={onFollowThread} onMarkUnread={onMarkUnread} onMarkRead={onMarkRead} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 6234af22d1b..edf3a05d669 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -43,6 +43,7 @@ import { ComposerDockBackdrop } from "./ComposerDockBackdrop"; import { MessageComposer } from "./MessageComposer"; import { ThreadMessageSkeleton } from "./MessageThreadPanelSkeleton"; import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow"; +import { parseStickerReference } from "@/shared/api/stickers"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { UnreadDivider } from "./UnreadDivider"; @@ -581,6 +582,7 @@ export function MessageThreadPanel({ } onEdit={ onEdit && + !parseStickerReference(threadHead.tags) && canManageMessageForCurrentUser( threadHead, currentPubkey, @@ -744,6 +746,7 @@ export function MessageThreadPanel({ } onEdit={ onEdit && + !parseStickerReference(entry.message.tags) && canManageMessageForCurrentUser( entry.message, currentPubkey, diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index b724d995eb5..c71853801e1 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -31,6 +31,7 @@ import type { ChannelType } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { DayDivider } from "./DayDivider"; import { MessageRow } from "./MessageRow"; +import { parseStickerReference } from "@/shared/api/stickers"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { SystemMessageRow } from "./SystemMessageRow"; import { UnreadDivider } from "./UnreadDivider"; @@ -753,7 +754,10 @@ function MessageRowItem({ profiles, ); const canDelete = canManage && onDelete ? onDelete : undefined; - const canEdit = canManage && onEdit ? onEdit : undefined; + const canEdit = + canManage && onEdit && !parseStickerReference(message.tags) + ? onEdit + : undefined; if (summary && onReply) { const isHighlighted = message.id === highlightedMessageId; diff --git a/desktop/src/features/messages/ui/composerAutocompleteKeys.ts b/desktop/src/features/messages/ui/composerAutocompleteKeys.ts new file mode 100644 index 00000000000..668fbf297ba --- /dev/null +++ b/desktop/src/features/messages/ui/composerAutocompleteKeys.ts @@ -0,0 +1,26 @@ +/** + * Shared key-dispatch shape for the composer's three autocompletes (emoji, + * channel link, mention). + * + * Split out of `MessageComposer.tsx` to keep that module under the desktop + * file-size ceiling (see `desktop/scripts/check-file-sizes.mjs`). + * + * Each autocomplete's `handle*KeyDown` returns the same contract: whether it + * consumed the key, and optionally the suggestion the key committed. A + * consumed key must stop dispatch even when no suggestion was committed — + * arrow keys move the highlight without selecting anything. + */ +export type AutocompleteKeyResult = { + handled: boolean; + suggestion?: T | null; +}; + +/** Returns true when the autocomplete consumed the key and dispatch should stop. */ +export function applyAutocompleteKeyResult( + result: AutocompleteKeyResult, + apply: (suggestion: T) => void, +): boolean { + if (!result.handled) return false; + if (result.suggestion) apply(result.suggestion); + return true; +} diff --git a/desktop/src/features/messages/ui/composerEmojiInsert.ts b/desktop/src/features/messages/ui/composerEmojiInsert.ts new file mode 100644 index 00000000000..89e3422518a --- /dev/null +++ b/desktop/src/features/messages/ui/composerEmojiInsert.ts @@ -0,0 +1,31 @@ +/** + * Decides how a picked emoji enters the composer document. + * + * Split out of `MessageComposer.tsx` to keep that module under the desktop + * file-size ceiling (see `desktop/scripts/check-file-sizes.mjs`). + * + * A `:shortcode:` for a known custom emoji becomes a selectable atom node (the + * same node the input rule and autocomplete produce), so it can be selected, + * copied, and deleted as one unit. Everything else — native unicode, or a + * shortcode with no matching palette entry — inserts as plain content. + */ +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; + +export type EmojiInsertion = + | { kind: "custom-node"; shortcode: string; src: string } + | { kind: "text"; text: string }; + +export function resolveEmojiInsertion( + emoji: string, + customEmoji: ReadonlyArray, +): EmojiInsertion { + const shortcode = /^:([^:\s]+):$/.exec(emoji)?.[1]?.toLowerCase(); + if (!shortcode) return { kind: "text", text: emoji }; + + const entry = customEmoji.find( + (candidate) => candidate.shortcode.toLowerCase() === shortcode, + ); + if (!entry) return { kind: "text", text: emoji }; + + return { kind: "custom-node", shortcode, src: entry.url ?? "" }; +} diff --git a/desktop/src/features/messages/ui/messageEditPayload.ts b/desktop/src/features/messages/ui/messageEditPayload.ts new file mode 100644 index 00000000000..ac350cdf406 --- /dev/null +++ b/desktop/src/features/messages/ui/messageEditPayload.ts @@ -0,0 +1,61 @@ +/** + * Builds the wire payload for a message edit submitted from `MessageComposer`. + * + * Split out of `MessageComposer.tsx` to keep that module under the desktop + * file-size ceiling (see `desktop/scripts/check-file-sizes.mjs`). Pure — + * everything the edit path derives before it touches composer state lives + * here, so the ordering rules below are testable without a mounted editor. + */ +import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import { + buildOutgoingMessage, + type ImetaMedia, + mergeOutgoingTags, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { diffAddedMentionPubkeys } from "@/features/messages/lib/threading"; + +export type MessageEditPayload = { + finalContent: string; + outgoingTags: string[][]; + addedMentionPubkeys: string[]; +}; + +export function buildMessageEditPayload(params: { + trimmed: string; + pendingImeta: ImetaMedia[]; + spoileredAttachmentUrls: Set; + customEmoji: ReadonlyArray; + previousBody: string; + extractMentionPubkeys: (body: string) => string[]; + ownerPubkey: string; +}): MessageEditPayload { + // Coerce `mediaTags ?? []` because edit semantics use `[]` as the explicit + // "wipe all attachments" signal — the receiver overlay drops imeta when the + // edit carries an empty (but defined) set. + const { content: finalContent, mediaTags } = buildOutgoingMessage( + params.trimmed, + params.pendingImeta, + params.spoileredAttachmentUrls, + ); + + // NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the + // edited body, exactly like the send path. Without this an edited message + // ships with no emoji tags, so the receiver can't resolve a `:shortcode:` + // and renders the literal text. + const outgoingTags = + mergeOutgoingTags( + mediaTags, + buildCustomEmojiTags(finalContent, params.customEmoji), + ) ?? []; + + // Notify only mentions this edit *newly adds*: a typo-fix edit that leaves + // the mention set unchanged emits no `p` tags and re-wakes nobody. + const addedMentionPubkeys = diffAddedMentionPubkeys( + params.extractMentionPubkeys(params.previousBody), + params.extractMentionPubkeys(finalContent), + params.ownerPubkey, + ); + + return { finalContent, outgoingTags, addedMentionPubkeys }; +} diff --git a/desktop/src/features/messages/ui/stickerComposerSend.ts b/desktop/src/features/messages/ui/stickerComposerSend.ts new file mode 100644 index 00000000000..1a466dc8328 --- /dev/null +++ b/desktop/src/features/messages/ui/stickerComposerSend.ts @@ -0,0 +1,99 @@ +/** + * Sticker-send helpers for `MessageComposer`. + * + * Split out of `MessageComposer.tsx` to keep that module under the desktop + * file-size ceiling (see `desktop/scripts/check-file-sizes.mjs`), and to give + * the sticker send path a unit-testable seam. + * + * A sticker send is *not* a normal send with an extra tag: renderers + * (`MessageRow.renderBody` on desktop, `MessageContent` on mobile) take the + * sticker branch and drop the markdown body entirely. So any typed text or + * pending attachment that rode along would be invisible to recipients. These + * helpers therefore send the sticker alone and hand the untouched draft back to + * the composer afterwards. + */ +import type { StickerSelection } from "@/features/stickers/ui/ComposerStickerPicker"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { stickerReferenceTag } from "@/shared/api/stickers"; + +/** Draft state captured before a sticker send clears the composer. */ +export type StickerDraftSnapshot = { + content: string; + imeta: ImetaMedia[]; + spoileredAttachmentUrls: Set; +}; + +/** The subset of send-flow arguments a sticker overrides. */ +export type StickerSendOverrides = { + pendingImeta: ImetaMedia[]; + spoileredAttachmentUrls: Set; + trimmed: string; + stickerTags: string[][]; +}; + +/** + * Build the send-flow arguments for this submit. + * + * With no sticker this is the identity case (the caller's own draft state). + * With a sticker the message becomes the sticker alone: the shortcode is the + * text fallback for clients that cannot resolve the pack, and attachments are + * withheld rather than silently hidden behind the sticker branch. + */ +export function stickerSendOverrides( + immediateSticker: StickerSelection | undefined, + currentPendingImeta: ImetaMedia[], + spoileredAttachmentUrls: Set, + trimmed: string, +): StickerSendOverrides { + if (!immediateSticker) { + return { + pendingImeta: currentPendingImeta, + spoileredAttachmentUrls, + trimmed, + stickerTags: [], + }; + } + return { + pendingImeta: [], + spoileredAttachmentUrls: new Set(), + trimmed: `:${immediateSticker.sticker.shortcode}:`, + stickerTags: [ + stickerReferenceTag(immediateSticker.pack, immediateSticker.sticker), + ], + }; +} + +/** + * Capture the draft a sticker send is about to displace, or `null` when this + * submit is not a sticker send (nothing to restore afterwards). + */ +export function captureStickerDraft( + immediateSticker: StickerSelection | undefined, + content: string, + currentPendingImeta: ImetaMedia[], + spoileredAttachmentUrls: Set, +): StickerDraftSnapshot | null { + if (!immediateSticker) return null; + return { + content, + imeta: [...currentPendingImeta], + spoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + }; +} + +/** + * Whether the pre-send draft should be restored into the composer. + * + * The send flow clears the composer on success, so a sticker click would + * otherwise eat an unsent draft. But the editor stays interactive while the + * request is in flight (hosts such as `InboxDetailPane` keep it enabled and + * track sending separately), so an unconditional restore would clobber + * whatever the user typed meanwhile. Restore only when the composer is still + * in the state the send left it — empty. + */ +export function shouldRestoreStickerDraft( + snapshot: StickerDraftSnapshot | null, + liveContent: string, +): snapshot is StickerDraftSnapshot { + return snapshot !== null && liveContent.trim() === ""; +} diff --git a/desktop/src/features/messages/ui/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index 6d4e007cd4f..e8958020db7 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -45,6 +45,7 @@ type PendingNonMemberMentionSend = { savedContent: string; savedImeta: ImetaMedia[]; savedSpoileredAttachmentUrls: Set; + savedStickerTags: string[][]; sentDraftKey: string | null | undefined; audienceGeneration: number; audienceRevision: number | null; @@ -65,6 +66,7 @@ type SendMessageWithMentionFlowInput = { trimmed: string; audienceGeneration?: number; audienceRevision?: number | null; + stickerTags?: string[][]; }; type UseMentionSendFlowOptions = { @@ -98,6 +100,7 @@ type UseMentionSendFlowOptions = { setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + setPendingStickerTags?: (tags: string[][]) => void; setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; @@ -161,6 +164,7 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + setPendingStickerTags, setSpoileredAttachmentUrls, onSuccessfulExplicitAgentAudience, resolvePostSendContent, @@ -398,6 +402,7 @@ export function useMentionSendFlow({ mentions.cancelMentionAutocomplete(); } else richText.clearContent(); setPendingImeta([]); + setPendingStickerTags?.([]); setSpoileredAttachmentUrls?.(new Set()); if (!postSendContent) mentions.clearMentions(); channelLinks.clearChannels(); @@ -415,6 +420,7 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + setPendingStickerTags, setSpoileredAttachmentUrls, ], ); @@ -550,6 +556,7 @@ export function useMentionSendFlow({ contentRef.current = draft.savedContent; richText.setContent(draft.savedContent); setPendingImeta(draft.savedImeta); + setPendingStickerTags?.(draft.savedStickerTags); setSpoileredAttachmentUrls?.( new Set(draft.savedSpoileredAttachmentUrls), ); @@ -576,6 +583,7 @@ export function useMentionSendFlow({ richText.setContent, setContent, setPendingImeta, + setPendingStickerTags, setSpoileredAttachmentUrls, ], ); @@ -644,6 +652,7 @@ export function useMentionSendFlow({ pendingImeta, sentDraftKey, spoileredAttachmentUrls = new Set(), + stickerTags = [], trimmed, audienceGeneration = 0, audienceRevision = null, @@ -711,6 +720,7 @@ export function useMentionSendFlow({ const outgoingTags = mergeOutgoingTags( mediaTags, buildCustomEmojiTags(finalContent, customEmoji), + stickerTags, ); const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); let promptNonMemberPubkeys = nonMemberPubkeys.filter( @@ -746,6 +756,7 @@ export function useMentionSendFlow({ savedContent: trimmed, savedImeta: [...pendingImeta], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + savedStickerTags: stickerTags, sentDraftKey, audienceGeneration, audienceRevision, diff --git a/desktop/src/features/settings/lib/settingsSections.test.mjs b/desktop/src/features/settings/lib/settingsSections.test.mjs new file mode 100644 index 00000000000..d2d2a3d8e3a --- /dev/null +++ b/desktop/src/features/settings/lib/settingsSections.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + DEFAULT_SETTINGS_SECTION, + isSettingsSection, +} from "./settingsSections.ts"; + +// Every section that has a nav descriptor and a render case in +// SettingsPanels.tsx must be accepted by isSettingsSection — otherwise the +// /settings?section= route search validation strips the param and the +// panel never opens (regression: "stickers" was routable in the nav but +// rejected by the guard, so clicking Stickers fell back to the default +// section). +const EXPECTED_SECTIONS = [ + "profile", + "notifications", + "experimental", + "agents", + "channel-templates", + "compute", + "appearance", + "shortcuts", + "hosted-communities", + "community-members", + "moderation", + "custom-emoji", + "stickers", + "local-archive", + "mobile", + "updates", +]; + +test("isSettingsSection accepts every section with a nav entry", () => { + for (const section of EXPECTED_SECTIONS) { + assert.equal(isSettingsSection(section), true, `section "${section}"`); + } +}); + +test("isSettingsSection rejects unknown and non-string values", () => { + assert.equal(isSettingsSection("nope"), false); + assert.equal(isSettingsSection(""), false); + assert.equal(isSettingsSection(undefined), false); + assert.equal(isSettingsSection(42), false); +}); + +test("default settings section is a valid section", () => { + assert.equal(isSettingsSection(DEFAULT_SETTINGS_SECTION), true); +}); diff --git a/desktop/src/features/settings/lib/settingsSections.ts b/desktop/src/features/settings/lib/settingsSections.ts new file mode 100644 index 00000000000..c4da39364ef --- /dev/null +++ b/desktop/src/features/settings/lib/settingsSections.ts @@ -0,0 +1,47 @@ +export type SettingsSection = + | "profile" + | "notifications" + | "voice" + | "experimental" + | "agents" + | "channel-templates" + | "compute" + | "appearance" + | "shortcuts" + | "hosted-communities" + | "community-members" + | "moderation" + | "custom-emoji" + | "stickers" + | "local-archive" + | "mobile" + | "updates"; + +export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; + +const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ + "profile", + "notifications", + "voice", + "experimental", + "agents", + "channel-templates", + "compute", + "appearance", + "shortcuts", + "hosted-communities", + "community-members", + "moderation", + "custom-emoji", + "stickers", + "local-archive", + "mobile", + "updates", +]; + +export function isSettingsSection(value: unknown): value is SettingsSection { + return ( + typeof value === "string" && + (SETTINGS_SECTION_VALUES as readonly string[]).includes(value) + ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 5c997efbc42..927d5bc64df 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -17,6 +17,7 @@ import { ShieldAlert, Smartphone, Smile, + Sticker, Sun, SunMoon, Ticket, @@ -37,6 +38,7 @@ import { useThreadViewMode, type ThreadViewMode, } from "@/features/channels/lib/threadViewModePreference"; +import { StickerSettingsCard } from "@/features/stickers/ui/StickerSettingsCard"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { @@ -86,51 +88,12 @@ import { UpdateChecker } from "../UpdateChecker"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { VoiceSettingsCard } from "./VoiceSettingsCard"; -export type SettingsSection = - | "profile" - | "notifications" - | "voice" - | "experimental" - | "agents" - | "channel-templates" - | "compute" - | "appearance" - | "shortcuts" - | "hosted-communities" - | "community-members" - | "moderation" - | "custom-emoji" - | "local-archive" - | "mobile" - | "updates"; - -export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile"; - -const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ - "profile", - "notifications", - "voice", - "experimental", - "agents", - "channel-templates", - "compute", - "appearance", - "shortcuts", - "hosted-communities", - "community-members", - "moderation", - "custom-emoji", - "local-archive", - "mobile", - "updates", -]; - -export function isSettingsSection(value: unknown): value is SettingsSection { - return ( - typeof value === "string" && - (SETTINGS_SECTION_VALUES as readonly string[]).includes(value) - ); -} +import type { SettingsSection } from "../lib/settingsSections"; +export { + DEFAULT_SETTINGS_SECTION, + isSettingsSection, + type SettingsSection, +} from "../lib/settingsSections"; export type SettingsSectionDescriptor = { value: SettingsSection; @@ -224,6 +187,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ icon: Smile, featureGate: "custom-emoji", }, + { + value: "stickers", + label: "Stickers", + icon: Sticker, + }, { value: "local-archive", label: "Local archive", @@ -844,6 +812,8 @@ export function renderSettingsSection( ); case "moderation": return ; + case "stickers": + return ; case "custom-emoji": return ; case "local-archive": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 8613880571c..99690056851 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -61,6 +61,7 @@ const settingsNavGroups: Array<{ "voice", "shortcuts", "custom-emoji", + "stickers", "local-archive", ], }, diff --git a/desktop/src/features/stickers/hooks.ts b/desktop/src/features/stickers/hooks.ts new file mode 100644 index 00000000000..9cdf07728b6 --- /dev/null +++ b/desktop/src/features/stickers/hooks.ts @@ -0,0 +1,168 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { relayClient } from "@/shared/api/relayClient"; +import { + fetchInstalledPackCoordinates, + fetchAllStickerPacks, + fetchOwnStickerPacks, + fetchStickerCatalog, + setStickerCatalogApproval, + setStickerPackInstalled, + type StickerPack, +} from "@/shared/api/stickers"; +import { + KIND_STICKER_CATALOG, + KIND_STICKER_PACK, + KIND_USER_STICKER_PACKS, +} from "@/shared/constants/kinds"; + +export const stickerCatalogQueryKey = ["sticker-catalog"] as const; +export const installedStickerPacksQueryKey = [ + "sticker-packs-installed", +] as const; +export const ownStickerPacksQueryKey = ["sticker-packs-own"] as const; +export const allStickerPacksQueryKey = ["sticker-packs-all"] as const; + +export function useStickerCatalogQuery() { + return useQuery({ + queryKey: stickerCatalogQueryKey, + queryFn: fetchStickerCatalog, + staleTime: 60_000, + refetchInterval: 120_000, + }); +} + +export function useInstalledStickerCoordinatesQuery() { + return useQuery({ + queryKey: installedStickerPacksQueryKey, + queryFn: fetchInstalledPackCoordinates, + staleTime: 60_000, + }); +} + +export function useOwnStickerPacksQuery() { + return useQuery({ + queryKey: ownStickerPacksQueryKey, + queryFn: fetchOwnStickerPacks, + staleTime: 60_000, + }); +} + +export function useAllStickerPacksQuery(enabled = true) { + return useQuery({ + queryKey: allStickerPacksQueryKey, + queryFn: fetchAllStickerPacks, + staleTime: 60_000, + enabled, + }); +} + +export function useInstalledStickerPacks(): StickerPack[] { + const catalog = useStickerCatalogQuery().data ?? []; + const installed = useInstalledStickerCoordinatesQuery().data ?? []; + const packsByCoordinate = React.useMemo( + () => new Map(catalog.map((pack) => [pack.coordinate, pack])), + [catalog], + ); + return React.useMemo( + () => + installed.flatMap((coordinate) => { + const pack = packsByCoordinate.get(coordinate); + // Superseded placeholders carry no stickers — exclude them from + // member-facing install/picker flows (admins still see them in + // settings for catalog removal). + return pack && !pack.superseded ? [pack] : []; + }), + [installed, packsByCoordinate], + ); +} + +export function useSetStickerPackInstalledMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + coordinate, + installed, + }: { + coordinate: string; + installed: boolean; + }) => setStickerPackInstalled(coordinate, installed), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: installedStickerPacksQueryKey, + }), + }); +} + +export function useSetStickerCatalogApprovalMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + coordinate, + eventId, + approved, + }: { + coordinate: string; + eventId: string; + approved: boolean; + }) => setStickerCatalogApproval(coordinate, eventId, approved), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: stickerCatalogQueryKey }), + }); +} + +/** Keep catalog and installed-list queries coherent across live events/reconnects. */ +export function useStickerLiveUpdates(): void { + const queryClient = useQueryClient(); + React.useEffect(() => { + let disposed = false; + const cleanups: Array<() => void> = []; + void Promise.all([ + relayClient.subscribeLive( + { kinds: [KIND_STICKER_CATALOG, KIND_STICKER_PACK], limit: 0 }, + () => { + void queryClient.invalidateQueries({ + queryKey: stickerCatalogQueryKey, + }); + void queryClient.invalidateQueries({ + queryKey: ownStickerPacksQueryKey, + }); + void queryClient.invalidateQueries({ + queryKey: allStickerPacksQueryKey, + }); + }, + ), + relayClient.subscribeLive( + { kinds: [KIND_USER_STICKER_PACKS], limit: 0 }, + () => { + void queryClient.invalidateQueries({ + queryKey: installedStickerPacksQueryKey, + }); + }, + ), + ]) + .then((subscriptions) => { + if (disposed) { + for (const unsubscribe of subscriptions) void unsubscribe(); + } else { + for (const unsubscribe of subscriptions) + cleanups.push(() => void unsubscribe()); + } + }) + .catch(() => { + // Polling remains the backstop if a live subscription cannot be opened. + }); + const reconnect = relayClient.subscribeToReconnects(() => { + void queryClient.invalidateQueries({ queryKey: stickerCatalogQueryKey }); + void queryClient.invalidateQueries({ + queryKey: installedStickerPacksQueryKey, + }); + }); + return () => { + disposed = true; + reconnect(); + for (const cleanup of cleanups) cleanup(); + }; + }, [queryClient]); +} diff --git a/desktop/src/features/stickers/ui/ComposerStickerPicker.tsx b/desktop/src/features/stickers/ui/ComposerStickerPicker.tsx new file mode 100644 index 00000000000..35deae5f4bf --- /dev/null +++ b/desktop/src/features/stickers/ui/ComposerStickerPicker.tsx @@ -0,0 +1,188 @@ +import * as React from "react"; +import { PackageOpen, Settings, Sticker as StickerIcon } from "lucide-react"; + +import { + useInstalledStickerPacks, + useSetStickerPackInstalledMutation, + useStickerCatalogQuery, +} from "@/features/stickers/hooks"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { toast } from "sonner"; +import { + stickerAssetCacheUrl, + type StickerAsset, + type StickerPack, +} from "@/shared/api/stickers"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { Button } from "@/shared/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +export type StickerSelection = { pack: StickerPack; sticker: StickerAsset }; + +export const ComposerStickerPicker = React.memo(function ComposerStickerPicker({ + disabled, + onSelect, +}: { + disabled?: boolean; + onSelect: (selection: StickerSelection) => void; +}) { + const packs = useInstalledStickerPacks(); + const catalogPacks = useStickerCatalogQuery().data ?? []; + // Superseded placeholders are not installable. + const installableCatalogPacks = catalogPacks.filter( + (pack) => !pack.superseded, + ); + const setInstalled = useSetStickerPackInstalledMutation(); + const [installingPack, setInstallingPack] = React.useState( + null, + ); + const { goSettings } = useAppNavigation(); + const installPack = React.useCallback( + async (coordinate: string) => { + setInstallingPack(coordinate); + try { + await setInstalled.mutateAsync({ coordinate, installed: true }); + } finally { + setInstallingPack(null); + } + }, + [setInstalled], + ); + const [open, setOpen] = React.useState(false); + const [selectedCoordinate, setSelectedCoordinate] = React.useState(""); + const selectedPack = + packs.find((pack) => pack.coordinate === selectedCoordinate) ?? packs[0]; + return ( + + + + + + + + Choose sticker + + + {packs.length === 0 ? ( +
+
+ +

No stickers installed

+

+ Install a curated pack, or create your own. +

+
+ {installableCatalogPacks.length > 0 ? ( +
+ {installableCatalogPacks.map((pack) => { + const installing = installingPack === pack.coordinate; + return ( +
+ + {pack.title} + + +
+ ); + })} +
+ ) : null} + +
+ ) : ( +
+ + {selectedPack ? ( +
+
+ {selectedPack.stickers.map((sticker) => ( + + ))} +
+
+ ) : null} +
+ )} +
+
+ ); +}); diff --git a/desktop/src/features/stickers/ui/StickerMessage.tsx b/desktop/src/features/stickers/ui/StickerMessage.tsx new file mode 100644 index 00000000000..7b85502403d --- /dev/null +++ b/desktop/src/features/stickers/ui/StickerMessage.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; + +import { stickerCacheUrl, type StickerReference } from "@/shared/api/stickers"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; + +export const StickerMessage = React.memo(function StickerMessage({ + fallback, + reference, +}: { + fallback: string; + reference: StickerReference; +}) { + const [failed, setFailed] = React.useState(false); + if (failed) { + return ( +
+ + 🧩 + + + Sticker unavailable + + {fallback} +
+ ); + } + return ( + {fallback setFailed(true)} + src={rewriteRelayUrl(stickerCacheUrl(reference))} + /> + ); +}); diff --git a/desktop/src/features/stickers/ui/StickerSettingsCard.tsx b/desktop/src/features/stickers/ui/StickerSettingsCard.tsx new file mode 100644 index 00000000000..eba9a8fd105 --- /dev/null +++ b/desktop/src/features/stickers/ui/StickerSettingsCard.tsx @@ -0,0 +1,586 @@ +import * as React from "react"; +import { + Check, + ImagePlus, + PackagePlus, + Pencil, + ShieldCheck, + Trash2, +} from "lucide-react"; +import { toast } from "sonner"; + +import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; +import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import { + allStickerPacksQueryKey, + ownStickerPacksQueryKey, + stickerCatalogQueryKey, + useAllStickerPacksQuery, + useInstalledStickerCoordinatesQuery, + useOwnStickerPacksQuery, + useSetStickerCatalogApprovalMutation, + useSetStickerPackInstalledMutation, + useStickerCatalogQuery, +} from "@/features/stickers/hooks"; +import { + importNostrStickerPack, + importSignalStickerPack, + pickAndUploadStickerImage, +} from "@/shared/api/stickersTauri"; +import { + publishStickerPack, + stickerAssetCacheUrl, + type StickerAsset, + type StickerPack, +} from "@/shared/api/stickers"; +import { useQueryClient } from "@tanstack/react-query"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Textarea } from "@/shared/ui/textarea"; + +function PackPreview({ + pack, + previewAvailable = true, +}: { + pack: StickerPack; + previewAvailable?: boolean; +}) { + return ( +
+
+ {previewAvailable && pack.stickers[0] ? ( + + ) : ( + + )} +
+
+

{pack.title}

+

+ {pack.stickers.length} sticker{pack.stickers.length === 1 ? "" : "s"} +

+ {!previewAvailable ? ( +

+ Preview available after approval +

+ ) : null} +
+
+ ); +} + +function PackAuthorForm({ onPublished }: { onPublished: () => void }) { + const ownPacks = useOwnStickerPacksQuery().data ?? []; + const [identifier, setIdentifier] = React.useState(""); + const [title, setTitle] = React.useState(""); + const [description, setDescription] = React.useState(""); + const [license, setLicense] = React.useState(""); + const [cover, setCover] = React.useState(); + const [stickers, setStickers] = React.useState([]); + const [packLink, setPackLink] = React.useState(""); + const [isWorking, setIsWorking] = React.useState(false); + + const loadPack = React.useCallback((pack: StickerPack) => { + setIdentifier(pack.identifier); + setTitle(pack.title); + setDescription(pack.description ?? ""); + setLicense(pack.license ?? ""); + setCover(pack.cover); + setStickers(pack.stickers); + }, []); + + const addNativeAsset = React.useCallback(async () => { + setIsWorking(true); + try { + const blob = await pickAndUploadStickerImage(); + if (!blob) return; + if ( + !["image/png", "image/webp", "image/gif", "image/apng"].includes( + blob.type, + ) + ) { + toast.error("Sonar stickers must be PNG, WebP, APNG, or GIF."); + return; + } + if (!blob.url.startsWith("https://")) { + toast.error("Sonar sticker assets require an HTTPS relay URL."); + return; + } + const base = + (blob.filename ?? `sticker-${stickers.length + 1}`) + .replace(/\.[^.]+$/, "") + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/^_+|_+$/g, "") || `sticker_${stickers.length + 1}`; + const [width, height] = blob.dim?.split("x").map(Number) ?? []; + setStickers((current) => [ + ...current, + { + shortcode: base, + url: blob.url, + sha256: blob.sha256, + mime: blob.type, + ...(width && height ? { width, height } : {}), + alt: base.replace(/[_-]+/g, " "), + }, + ]); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not add sticker.", + ); + } finally { + setIsWorking(false); + } + }, [stickers.length]); + + const chooseCover = React.useCallback(async () => { + setIsWorking(true); + try { + const blob = await pickAndUploadStickerImage(true); + if (!blob) return; + setCover({ + url: blob.url, + sha256: blob.sha256, + ...(blob.dim ? { dim: blob.dim } : {}), + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not add pack cover.", + ); + } finally { + setIsWorking(false); + } + }, []); + + const importPack = React.useCallback(async () => { + const link = packLink.trim(); + if (!link) return; + // Signal links carry a secret pack key; Sonar links are public metadata. + const isSignal = link.includes("signal.art/addstickers"); + setIsWorking(true); + try { + const imported = isSignal + ? await importSignalStickerPack(link) + : await importNostrStickerPack(link); + setIdentifier(imported.identifier); + setTitle(imported.title); + setDescription( + imported.description ?? + (isSignal + ? imported.author + ? `Imported from Signal · ${imported.author}` + : "Imported from Signal" + : ""), + ); + setLicense(imported.license ?? ""); + setCover( + imported.cover + ? { + url: imported.cover.url, + sha256: imported.cover.sha256, + ...(imported.cover.width && imported.cover.height + ? { dim: `${imported.cover.width}x${imported.cover.height}` } + : {}), + } + : undefined, + ); + setStickers(imported.stickers); + if (!isSignal) { + toast.success( + `Imported ${imported.stickers.length} sticker(s) from "${imported.title}". Publish the pack to add it to this community.`, + ); + } + if (imported.skippedStickerIds.length > 0) { + toast.warning( + `Skipped ${imported.skippedStickerIds.length} unavailable sticker(s).`, + ); + } + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : `Could not import ${isSignal ? "Signal" : "Nostr"} pack.`, + ); + } finally { + // The link exists only for the duration of this invoke (Signal links + // carry a secret pack key and are zeroized in trusted Rust). + setPackLink(""); + setIsWorking(false); + } + }, [packLink]); + + const publish = React.useCallback(async () => { + setIsWorking(true); + try { + await publishStickerPack({ + identifier, + title, + description, + license, + cover, + stickers, + }); + toast.success("Sticker pack published."); + onPublished(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Could not publish pack.", + ); + } finally { + setIsWorking(false); + } + }, [cover, description, identifier, license, onPublished, stickers, title]); + + return ( + +
+

Create or edit a pack

+

+ Choose one of your packs to edit, upload images, or import a Signal + pack. +

+
+ {ownPacks.length > 0 ? ( +
+ {ownPacks.map((pack) => ( + + ))} +
+ ) : null} +
+ setIdentifier(event.target.value)} + placeholder="pack-id" + value={identifier} + /> + setTitle(event.target.value)} + placeholder="Pack title" + value={title} + /> +
+