From 62d1c3d1627e9fde3e647498c8b39bfa7e8ee311 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 15 Jul 2026 23:58:05 +0200 Subject: [PATCH] feat(stickers): add Sonar sticker packs Signed-off-by: Vincenzo Palazzo --- Cargo.lock | 79 +- Cargo.toml | 3 + crates/buzz-cli/Cargo.toml | 3 + crates/buzz-cli/src/client.rs | 19 +- 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 | 51 +- crates/buzz-core/src/stickers.rs | 523 +++++++++ crates/buzz-db/Cargo.toml | 1 + crates/buzz-db/src/event.rs | 15 +- crates/buzz-db/src/lib.rs | 2 + crates/buzz-db/src/migration.rs | 14 +- crates/buzz-db/src/sticker_catalog.rs | 517 +++++++++ crates/buzz-media/src/lib.rs | 4 +- crates/buzz-media/src/validation.rs | 282 ++++- crates/buzz-relay/Cargo.toml | 1 + crates/buzz-relay/src/api/media.rs | 335 +++++- 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/builders.rs | 239 +++- crates/buzz-sdk/src/lib.rs | 4 + desktop/src-tauri/Cargo.lock | 73 +- desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/commands/media.rs | 251 +++- desktop/src-tauri/src/commands/messages.rs | 8 +- desktop/src-tauri/src/events.rs | 33 + desktop/src-tauri/src/huddle/pipeline.rs | 2 +- desktop/src-tauri/src/lib.rs | 2 + desktop/src/app/AppShell.tsx | 2 + desktop/src/features/messages/hooks.ts | 10 +- .../messages/lib/imetaMediaMarkdown.test.mjs | 33 +- .../messages/lib/imetaMediaMarkdown.ts | 12 +- .../features/messages/ui/MessageComposer.tsx | 116 +- .../messages/ui/MessageComposerProps.ts | 52 + .../messages/ui/MessageComposerToolbar.tsx | 12 + .../src/features/messages/ui/MessageRow.tsx | 13 +- .../messages/ui/MessageThreadPanel.tsx | 3 + .../messages/ui/TimelineMessageList.tsx | 6 +- .../messages/ui/useMentionSendFlow.ts | 11 + .../features/settings/ui/SettingsPanels.tsx | 11 + .../src/features/settings/ui/SettingsView.tsx | 1 + desktop/src/features/stickers/hooks.ts | 165 +++ .../stickers/ui/ComposerStickerPicker.tsx | 108 ++ .../features/stickers/ui/StickerMessage.tsx | 37 + .../stickers/ui/StickerSettingsCard.tsx | 564 +++++++++ .../features/stickers/useComposerSticker.tsx | 83 ++ desktop/src/shared/api/mediaTypes.ts | 14 + desktop/src/shared/api/stickers.test.mjs | 129 +++ desktop/src/shared/api/stickers.ts | 594 ++++++++++ desktop/src/shared/api/stickersTauri.ts | 17 + desktop/src/shared/api/tauri.ts | 19 +- desktop/src/shared/constants/kinds.ts | 8 + desktop/src/shared/lib/mediaUrl.ts | 20 +- desktop/src/testing/e2eBridge.ts | 25 +- .../2026-07-15-sonar-stickers-in-buzz.md | 105 ++ migrations/0020_sticker_catalog.sql | 15 + .../features/channels/message_content.dart | 24 +- mobile/lib/shared/relay/nostr_models.dart | 3 + .../lib/shared/stickers/sticker_preview.dart | 94 ++ .../shared/stickers/sticker_reference.dart | 108 ++ .../shared/stickers/sticker_preview_test.dart | 148 +++ .../stickers/sticker_reference_test.dart | 93 ++ 71 files changed, 6382 insertions(+), 183 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/features/messages/ui/MessageComposerProps.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/features/stickers/useComposerSticker.tsx create mode 100644 desktop/src/shared/api/mediaTypes.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/0020_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 diff --git a/Cargo.lock b/Cargo.lock index 6091f52b632..a58d7ab9544 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -891,16 +891,19 @@ dependencies = [ "clap", "diffy", "hex", + "imagesize", "infer", "nostr", "reqwest 0.13.4", "serde", "serde_json", "sha2 0.11.0", + "sonar-stickers", "thiserror 2.0.18", "tokio", "url", "uuid", + "zeroize", ] [[package]] @@ -927,6 +930,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "sonar-stickers", "subtle", "thiserror 2.0.18", "url", @@ -949,6 +953,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "url", "uuid", ] @@ -1156,6 +1161,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2 0.11.0", + "sonar-stickers", "sqlx", "subtle", "tempfile", @@ -1202,6 +1208,7 @@ dependencies = [ "nostr", "serde", "serde_json", + "sonar-stickers", "thiserror 2.0.18", "uuid", ] @@ -4544,7 +4551,7 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", + "prost 0.14.3", "rand 0.10.1", "rustls", "serde", @@ -4683,7 +4690,7 @@ dependencies = [ "opentelemetry 0.31.0", "opentelemetry-otlp 0.31.1", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "rand 0.10.1", "regex-lite", "reqwest 0.12.28", @@ -4771,7 +4778,7 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292 dependencies = [ "anyhow", "async-trait", - "prost", + "prost 0.14.3", "prost-build", "protoc-bin-vendored", "rmcp", @@ -4807,7 +4814,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.3", "serde_json", "sha2 0.10.9", ] @@ -5934,7 +5941,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -5949,7 +5956,7 @@ dependencies = [ "opentelemetry 0.32.0", "opentelemetry-proto 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "thiserror 2.0.18", "tokio", "tonic", @@ -5966,7 +5973,7 @@ dependencies = [ "const-hex", "opentelemetry 0.31.0", "opentelemetry_sdk 0.31.0", - "prost", + "prost 0.14.3", "serde", "serde_json", "tonic", @@ -5981,7 +5988,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry 0.32.0", "opentelemetry_sdk 0.32.1", - "prost", + "prost 0.14.3", "tonic", "tonic-prost", ] @@ -6618,6 +6625,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.3" @@ -6625,7 +6642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", ] [[package]] @@ -6640,13 +6657,26 @@ dependencies = [ "multimap", "petgraph", "prettyplease", - "prost", + "prost 0.14.3", "prost-types", "regex", "syn 2.0.117", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "prost-derive" version = "0.14.3" @@ -6666,7 +6696,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost", + "prost 0.14.3", ] [[package]] @@ -7237,6 +7267,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -8167,7 +8198,7 @@ name = "skippy-protocol" version = "0.73.1" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ - "prost", + "prost 0.14.3", "prost-build", "protoc-bin-vendored", "serde", @@ -8260,6 +8291,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", + "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" @@ -9165,7 +9216,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.3", "tonic", ] @@ -9175,7 +9226,7 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" dependencies = [ - "prost", + "prost 0.14.3", "prost-types", "tonic", ] diff --git a/Cargo.toml b/Cargo.toml index 3499285f917..d7e85bb2934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,6 +133,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 d154faa81ec..a345e37b14f 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" @@ -59,6 +60,8 @@ hex = { workspace = true } # 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 b56ca79e2b3..9a723e50f9e 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -63,6 +63,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", @@ -325,6 +326,22 @@ impl BuzzClient { return Err(CliError::Usage(format!("unsupported file type: {mime}"))); } + self.upload_bytes(bytes, &mime).await + } + + /// Upload already-buffered media bytes to the relay's Blossom endpoint. + /// + /// Callers that accept a narrower media set (for example Sonar stickers) + /// must validate that policy before calling this shared transport helper. + 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 @@ -394,7 +411,7 @@ impl BuzzClient { .put(&url) .timeout(upload_timeout) .header("Authorization", &auth_header) - .header("Content-Type", &mime) + .header("Content-Type", mime) .header("X-SHA-256", &sha256); let resp = self.with_auth_tag(req).body(bytes).send().await?; diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 6480874a07b..e6982f61b63 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -14,6 +14,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 51e6822698f..63c62be2f30 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -179,6 +179,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), @@ -693,6 +696,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 @@ -1630,6 +1684,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, @@ -1678,6 +1733,7 @@ mod tests { "reactions", "repos", "social", + "stickers", "upload", "users", "workflows", @@ -1763,6 +1819,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"] @@ -1835,6 +1903,7 @@ mod tests { ("reactions", 3), ("repos", 3), ("social", 7), + ("stickers", 7), ("upload", 1), ("users", 4), ("workflows", 8), diff --git a/crates/buzz-core/Cargo.toml b/crates/buzz-core/Cargo.toml index 489df360dd5..5d0a4f332a3 100644 --- a/crates/buzz-core/Cargo.toml +++ b/crates/buzz-core/Cargo.toml @@ -25,5 +25,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 3d671129e6a..46de586a7ca 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. @@ -257,9 +266,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). @@ -496,9 +509,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, @@ -530,7 +545,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, @@ -643,7 +660,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, @@ -651,6 +668,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 ) } @@ -687,6 +705,7 @@ pub const fn is_relay_only_kind(kind: u32) -> bool { | KIND_DM_VISIBILITY | KIND_THREAD_SUMMARY | KIND_WINDOW_BOUNDS + | KIND_STICKER_CATALOG ) } @@ -712,6 +731,8 @@ const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 303 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 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 6139ee3aed5..b4949ca66da 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -30,6 +30,8 @@ pub mod observer; pub mod pairing; /// Presence status types shared across crates. pub mod presence; +/// 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 1e52d4c104c..21debcfe13e 100644 --- a/crates/buzz-core/src/network.rs +++ b/crates/buzz-core/src/network.rs @@ -15,6 +15,9 @@ /// - IPv4 broadcast 255.255.255.255 /// - IPv4 CGNAT 100.64.0.0/10 (RFC 6598) β€” cloud metadata risk /// - IPv4 benchmarking 198.18.0.0/15 (RFC 2544) +/// - IPv4 documentation 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 +/// - IPv4 multicast 224.0.0.0/4 +/// - IPv4 reserved 192.0.0.0/24, 192.88.99.0/24, 240.0.0.0/4 /// - IPv6 loopback ::1 /// - IPv6 unspecified :: /// - IPv6 ULA fc00::/7 @@ -36,6 +39,14 @@ pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { || (octets[0] == 100 && (octets[1] & 0xC0) == 64) // Benchmarking (RFC 2544) β€” 198.18.0.0/15 || (octets[0] == 198 && (octets[1] & 0xFE) == 18) + // IETF protocol assignments + documentation (RFC 6890 / RFC 5737) + || (octets[0] == 192 && octets[1] == 0 && octets[2] <= 2) + // Deprecated 6to4 relay anycast (RFC 7526) + || (octets[0] == 192 && octets[1] == 88 && octets[2] == 99) + || (octets[0] == 198 && octets[1] == 51 && octets[2] == 100) + || (octets[0] == 203 && octets[1] == 0 && octets[2] == 113) + // Multicast and future/reserved space. + || octets[0] >= 224 } std::net::IpAddr::V6(v6) => { // Check IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) against IPv4 rules. @@ -46,7 +57,20 @@ pub fn is_private_ip(ip: &std::net::IpAddr) -> bool { || v6.is_unspecified() || v6.segments()[0] & 0xfe00 == 0xfc00 // fc00::/7 ULA || v6.segments()[0] & 0xffc0 == 0xfe80 // fe80::/10 link-local + || v6.segments()[0] & 0xffc0 == 0xfec0 // fec0::/10 deprecated site-local || v6.segments()[0] & 0xff00 == 0xff00 // ff00::/8 multicast + // 100::/64 discard-only (RFC 6666) + || (v6.segments()[0] == 0x0100 + && v6.segments()[1] == 0 + && v6.segments()[2] == 0 + && v6.segments()[3] == 0) + // 2001::/23 IETF protocol-assignment space (Teredo, benchmarking, + // ORCHID and documentation subranges). Sticker fetches fail closed. + || (v6.segments()[0] == 0x2001 && v6.segments()[1] <= 0x01ff) + // 6to4 and documentation/special-purpose ranges. + || v6.segments()[0] == 0x2002 + || v6.segments()[0] == 0x3fff + || v6.segments()[0] == 0x5f00 // RFC 3849 β€” documentation range, should never appear in production || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8) } @@ -194,7 +218,30 @@ mod tests { } #[test] fn test_ipv6_not_multicast() { - // fe00:: β€” just below ff00::/8 (not multicast, not link-local, not ULA) - assert!(!is_private_ip(&"fe00::1".parse::().unwrap())); + // 2606:4700::/32 is public Cloudflare address space. + assert!(!is_private_ip(&"2606:4700::1".parse::().unwrap())); + } + + #[test] + fn blocks_ipv4_documentation_multicast_and_reserved_ranges() { + for value in [ + "192.0.2.1", + "198.51.100.8", + "203.0.113.9", + "224.0.0.1", + "239.255.255.250", + "240.0.0.1", + ] { + assert!(is_private_ip(&value.parse::().unwrap()), "{value}"); + } + } + + #[test] + fn blocks_ipv6_special_purpose_ranges() { + for value in [ + "100::1", "2001::1", "2002::1", "3fff::1", "5f00::1", "fec0::1", + ] { + assert!(is_private_ip(&value.parse::().unwrap()), "{value}"); + } } } 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 23ea3c9d8fe..5abbc41a30a 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -20,6 +20,7 @@ sha2 = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } nostr = { 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 9a1cc5b6640..bdb961be824 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -733,6 +733,17 @@ pub async fn soft_delete_by_coordinate( pubkey: &[u8], d_tag: &str, ) -> Result { + // 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", @@ -741,9 +752,11 @@ pub async fn soft_delete_by_coordinate( .bind(kind) .bind(pubkey) .bind(d_tag) - .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 07c67a8241c..0800cb04774 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -39,6 +39,8 @@ pub mod push; pub mod reaction; /// Relay-level membership persistence (NIP-43). pub mod relay_members; +/// 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 1d8c961a29a..1f1f2cd91e0 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -549,7 +549,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 19); + assert_eq!(migrations.len(), 20); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -812,6 +812,18 @@ mod tests { .sql .as_str() .contains("purge_soft_deleted_buzz_mesh_status")); + + // Sticker curation pins an exact pack revision per community. Keeping + // this in an additive table preserves the initial migration checksum. + assert_eq!(migrations[19].version, 20); + let sticker_catalog = migrations[19].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")); + assert!(!migrations[0] + .sql + .as_str() + .contains("sticker_catalog_approvals")); } #[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 e62972d587a..8f56283253e 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -21,4 +21,6 @@ pub use upload_record::{ parse_port, parse_public_ip, upload_record_key, UploadAttribution, UploadNetworkInfo, UploadRecord, UPLOAD_RECORD_VERSION, }; -pub use validation::{serve_inline, validate_video_file, VideoMeta}; +pub use validation::{ + 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 74b4ab0d9bf..08403561723 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", +]; /// MIME types blocked from the generic file-upload path. /// @@ -164,6 +170,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. @@ -172,7 +352,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) @@ -398,7 +586,7 @@ fn check_moov_before_mdat(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", @@ -463,6 +651,94 @@ mod tests { assert_eq!(result.unwrap(), "image/png"); } + #[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]); + } + + let mut apng = TINY_PNG.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.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.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 36cc71dcb0e..714b38517c4 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 b7b6db369e3..c5358afac17 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -5,7 +5,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; @@ -18,10 +18,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 @@ -353,7 +394,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", @@ -431,6 +472,283 @@ async fn bind_media_read_tenant( .map_err(|_| MediaError::NotFound) } +/// 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 { + let tenant = bind_media_read_tenant(&state, &req_headers) + .await + .map_err(|_| StickerAssetError::NotFound)?; + 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); + + let fetch_lock = STICKER_FETCH_LOCKS + .entry(object_key.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone(); + let _fetch_guard = fetch_lock.lock().await; + + let bytes = if state + .media_storage + .head(&object_key) + .await + .map_err(|_| StickerAssetError::Internal)? + { + state + .media_storage + .get(&object_key) + .await + .map_err(|_| StickerAssetError::Internal)? + } else { + 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)?; + bytes + }; + + // 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) +} + +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, + }) +} + +async fn fetch_sticker_bytes(url: &str) -> Result, StickerAssetError> { + let parsed = reqwest::Url::parse(url).map_err(|_| StickerAssetError::InvalidAsset)?; + 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); + } + + 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); + } + let pinned = addresses[0]; + 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)) + .redirect(reqwest::redirect::Policy::none()) + .resolve(host, pinned) + .build() + .map_err(|_| StickerAssetError::Internal)?; + let mut response = client + .get(parsed) + .send() + .await + .map_err(|_| StickerAssetError::Upstream)?; + 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 @@ -892,6 +1210,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 169615f0b56..f0c89435b93 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -36,9 +36,9 @@ 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 cd331184064..371baa5e8fb 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_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_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_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; @@ -206,6 +207,8 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_DELETION | KIND_REACTION @@ -230,7 +233,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) } @@ -377,6 +381,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. @@ -416,6 +424,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 @@ -1947,6 +1956,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; @@ -2805,6 +2831,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 840d9dcfe58..0aa9bbced98 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; @@ -94,7 +201,7 @@ fn validate_workspace_icon(icon: &str) -> Result<(), String> { Ok(()) } -/// Validate and execute a relay admin command (kinds 9030–9033). +/// Validate and execute a relay admin command (kinds 9030–9034). /// /// The handler: /// 1. Extracts the target pubkey from the `["p", ...]` tag. @@ -165,6 +272,36 @@ pub async fn handle_relay_admin_event( 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(); @@ -465,4 +602,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 c3c0d6d3bc5..9f9cc6ffc95 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; @@ -2793,6 +2794,59 @@ pub async fn publish_nip43_membership_list( 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 a8e397dd21f..5fb20b31b0a 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -157,7 +157,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 bb8de555690..6663afe20ed 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -37,6 +37,10 @@ pub fn build_router(state: Arc) -> Router { .max(state.config.media.max_video_bytes) as usize; let media_router = Router::new() .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/builders.rs b/crates/buzz-sdk/src/builders.rs index 31493c8985a..d5be44858b6 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,14 +11,19 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, - KIND_PRESENCE_UPDATE, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_PRESENCE_UPDATE, KIND_STICKER_PACK, KIND_USER_STICKER_PACKS, KIND_WORKFLOW_DEF, + KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY, }, }; -use nostr::{EventBuilder, Kind, Tag}; +use nostr::{EventBuilder, Kind, PublicKey, Tag}; +use sonar_stickers::{ + build_installed_packs_tags, build_pack_tags, build_sticker_ref_tag, InstalledPackList, + PackAddress, StickerPack, StickerRef, +}; use uuid::Uuid; use crate::{ @@ -236,6 +241,162 @@ pub fn build_message( Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } +/// Build a Sonar sticker message as an ordinary Buzz stream message (kind 9). +/// +/// The sticker reference is carried in the exact interoperable +/// `["sticker", coordinate, shortcode, plaintext_sha256]` tag. `content` +/// must remain non-empty so clients without sticker support have a textual +/// fallback, normally `:shortcode:` or the sticker alt text. +#[allow(clippy::too_many_arguments)] +pub fn build_sticker_message( + channel_id: Uuid, + content: &str, + thread_ref: Option<&ThreadRef>, + mentions: &[&str], + broadcast: bool, + media_tags: &[Vec], + sticker_ref: &StickerRef, +) -> Result { + if content.trim().is_empty() { + return Err(SdkError::InvalidInput( + "sticker message content must contain a textual fallback".into(), + )); + } + check_content(content, 64 * 1024)?; + let sticker_ref = canonical_sticker_ref(sticker_ref)?; + + let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; + if let Some(thread_ref) = thread_ref { + thread_tags(thread_ref, &mut tags)?; + } + mention_tags(mentions, &mut tags)?; + if broadcast { + tags.push(tag(&["broadcast", "1"])?); + } + imeta_tags(media_tags, &mut tags)?; + tags.push(build_sticker_ref_tag(&sticker_ref)); + Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) +} + +/// Build a Sonar sticker-pack event (kind 30031). +/// +/// `signer` must be the public key that will sign the returned builder. Passing +/// it explicitly prevents callers from accidentally publishing a pack whose +/// coordinate names a different author. +pub fn build_sticker_pack( + pack: &StickerPack, + signer: &PublicKey, +) -> Result { + canonical_pack_address(&pack.address)?; + if pack.address.author_pubkey_hex != signer.to_hex() { + return Err(SdkError::InvalidInput( + "sticker pack address author must match the signing public key".into(), + )); + } + pack.validate() + .map_err(|error| SdkError::InvalidInput(format!("invalid sticker pack: {error}")))?; + if pack + .license + .as_ref() + .is_some_and(|license| license.chars().count() > 160) + { + return Err(SdkError::InvalidInput( + "sticker pack license must be at most 160 characters".into(), + )); + } + if pack + .cover + .as_ref() + .is_some_and(|cover| cover.mime != "image/webp") + { + return Err(SdkError::InvalidInput( + "sticker pack cover must be WebP because the Sonar image tag has no MIME field".into(), + )); + } + Ok(EventBuilder::new(Kind::Custom(KIND_STICKER_PACK as u16), "").tags(build_pack_tags(pack))) +} + +/// Build a user's ordered installed-sticker-pack list (kind 10031). +pub fn build_installed_sticker_packs( + installed: &InstalledPackList, +) -> Result { + let mut seen = std::collections::HashSet::with_capacity(installed.packs.len()); + for address in &installed.packs { + canonical_pack_address(address)?; + if !seen.insert(address.coordinate()) { + return Err(SdkError::InvalidInput( + "installed sticker list contains a duplicate coordinate".into(), + )); + } + } + if installed.packs.len() > 200 { + return Err(SdkError::InvalidInput( + "installed sticker list exceeds 200 packs".into(), + )); + } + Ok( + EventBuilder::new(Kind::Custom(KIND_USER_STICKER_PACKS as u16), "") + .tags(build_installed_packs_tags(installed)), + ) +} + +/// Build an admin command approving an exact sticker-pack event revision. +pub fn build_approve_sticker_pack( + address: &PackAddress, + approved_event_id: nostr::EventId, +) -> Result { + let coordinate = canonical_pack_address(address)?.coordinate(); + let event_id = approved_event_id.to_hex(); + Ok(EventBuilder::new( + Kind::Custom(buzz_core::kind::RELAY_ADMIN_CURATE_STICKER_PACK as u16), + "", + ) + .tags([ + tag(&["action", "approve"])?, + tag(&["a", &coordinate, &event_id])?, + ])) +} + +/// Build an admin command removing a sticker pack from the workspace catalog. +pub fn build_remove_sticker_pack(address: &PackAddress) -> Result { + let coordinate = canonical_pack_address(address)?.coordinate(); + Ok(EventBuilder::new( + Kind::Custom(buzz_core::kind::RELAY_ADMIN_CURATE_STICKER_PACK as u16), + "", + ) + .tags([tag(&["action", "remove"])?, tag(&["a", &coordinate])?])) +} + +fn canonical_pack_address(address: &PackAddress) -> Result { + let canonical = PackAddress::new( + address.author_pubkey_hex.clone(), + address.identifier.clone(), + ) + .map_err(|error| SdkError::InvalidInput(format!("invalid sticker pack address: {error}")))?; + if canonical != *address { + return Err(SdkError::InvalidInput( + "sticker pack address must use canonical lowercase hex".into(), + )); + } + Ok(canonical) +} + +fn canonical_sticker_ref(sticker_ref: &StickerRef) -> Result { + let pack = canonical_pack_address(&sticker_ref.pack)?; + let canonical = StickerRef::new( + pack, + sticker_ref.shortcode.clone(), + sticker_ref.plaintext_sha256.clone(), + ) + .map_err(|error| SdkError::InvalidInput(format!("invalid sticker reference: {error}")))?; + if canonical != *sticker_ref { + return Err(SdkError::InvalidInput( + "sticker reference must use canonical lowercase hex".into(), + )); + } + Ok(canonical) +} + /// Build an encrypted agent observer frame (kind 24200). /// /// `recipient_pubkey` is the cleartext `p` tag used by the relay for owner-only @@ -1704,6 +1865,80 @@ mod tests { assert!(has_tag(&ev, "h", &cid.to_string())); } + #[test] + fn sticker_message_uses_exact_reference_tag_and_fallback() { + let author = keys(); + let address = PackAddress::new(author.public_key().to_hex(), "waves").unwrap(); + let sticker_ref = StickerRef::new(address.clone(), "wave", "b".repeat(64)).unwrap(); + let ev = sign( + build_sticker_message(uuid(), ":wave:", None, &[], false, &[], &sticker_ref).unwrap(), + ); + let tag = ev + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("sticker")) + .expect("sticker tag"); + assert_eq!( + tag.as_slice(), + &[ + "sticker".to_owned(), + address.coordinate(), + "wave".to_owned(), + "b".repeat(64), + ] + ); + assert!(build_sticker_message(uuid(), " ", None, &[], false, &[], &sticker_ref).is_err()); + } + + #[test] + fn sticker_state_builders_emit_sonar_kinds() { + let author = keys(); + let address = PackAddress::new(author.public_key().to_hex(), "waves").unwrap(); + let sticker = sonar_stickers::Sticker::new( + "wave", + format!("https://cdn.example/{}.webp", "b".repeat(64)), + "b".repeat(64), + "image/webp", + Some(256), + Some(256), + Some("Wave".into()), + Some("πŸ‘‹".into()), + ) + .unwrap(); + let pack = StickerPack::new( + address.clone(), + "Waves", + None, + Some(sticker.clone()), + vec![sticker], + Some("CC0".into()), + ) + .unwrap(); + let pack_event = build_sticker_pack(&pack, &author.public_key()) + .unwrap() + .sign_with_keys(&author) + .unwrap(); + assert_eq!(pack_event.kind.as_u16(), KIND_STICKER_PACK as u16); + assert!(build_sticker_pack(&pack, &keys().public_key()).is_err()); + + let installed = InstalledPackList::new(vec![address.clone()]); + let installed_event = sign(build_installed_sticker_packs(&installed).unwrap()); + assert_eq!( + installed_event.kind.as_u16(), + KIND_USER_STICKER_PACKS as u16 + ); + assert!(has_tag(&installed_event, "a", &address.coordinate())); + + let approve = sign(build_approve_sticker_pack(&address, pack_event.id).unwrap()); + assert_eq!( + approve.kind.as_u16(), + buzz_core::kind::RELAY_ADMIN_CURATE_STICKER_PACK as u16 + ); + assert!(has_tag(&approve, "action", "approve")); + let remove = sign(build_remove_sticker_pack(&address).unwrap()); + assert!(has_tag(&remove, "action", "remove")); + } + #[test] fn agent_observer_frame_happy_path() { let sender = keys(); diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index bbad8494c4b..4df01ffe961 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 49f6c9939c5..ed39910b1c7 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -942,6 +942,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "sonar-stickers", "subtle", "thiserror 2.0.18", "url", @@ -1000,6 +1001,7 @@ dependencies = [ "serde_yaml", "sha2 0.11.0", "sherpa-onnx", + "sonar-stickers", "strip-ansi-escapes", "tar", "tauri", @@ -1045,6 +1047,7 @@ dependencies = [ "nostr", "serde", "serde_json", + "sonar-stickers", "thiserror 2.0.18", "uuid", ] @@ -3656,6 +3659,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -4806,7 +4810,7 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "rustls", "serde", @@ -4945,7 +4949,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "regex-lite", "reqwest 0.12.28", @@ -5033,7 +5037,7 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292 dependencies = [ "anyhow", "async-trait", - "prost", + "prost 0.14.4", "prost-build", "protoc-bin-vendored", "rmcp", @@ -5069,7 +5073,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.4", "serde_json", "sha2 0.10.9", ] @@ -6404,7 +6408,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -6419,7 +6423,7 @@ dependencies = [ "const-hex", "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "serde", "serde_json", "tonic", @@ -7176,6 +7180,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.4" @@ -7183,7 +7197,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.4", ] [[package]] @@ -7198,13 +7212,26 @@ dependencies = [ "multimap", "petgraph", "prettyplease", - "prost", + "prost 0.14.4", "prost-types", "regex", "syn 2.0.118", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "prost-derive" version = "0.14.4" @@ -7224,7 +7251,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost", + "prost 0.14.4", ] [[package]] @@ -7720,6 +7747,9 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -7727,6 +7757,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -8786,7 +8817,7 @@ name = "skippy-protocol" version = "0.73.1" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" dependencies = [ - "prost", + "prost 0.14.4", "prost-build", "protoc-bin-vendored", "serde", @@ -8898,6 +8929,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" @@ -10440,7 +10491,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.4", "tonic", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8d2539c18ce..f6adb75e1d2 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -106,6 +106,7 @@ uuid = { version = "1", features = ["v4"] } png = "0.18" arboard = "3" 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" sherpa-onnx = "1.12" regex = "1" diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 9838d068550..8997de1ef88 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -3,6 +3,7 @@ use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tauri::State; +use zeroize::Zeroize; use crate::app_state::AppState; use crate::relay::{ @@ -190,7 +191,7 @@ fn sign_blossom_upload_auth( // Current approach works for videos up to ~100MB but will OOM on 500MB files. // Fix: use reqwest's Body::wrap_stream() to stream from the temp file directly. // The server already supports streaming upload via process_video_upload. -async fn do_upload( +pub(crate) async fn do_upload( body: Vec, mime: &str, state: &State<'_, AppState>, @@ -261,6 +262,217 @@ async fn do_upload( parse_json_response::(resp).await } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportedStickerDraft { + identifier: String, + title: String, + author: 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, + width: Option, + height: Option, + alt: Option, + 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, + cover, + stickers, + skipped_sticker_ids: imported.skipped_sticker_ids, + }) +} + // ── Commands ───────────────────────────────────────────────────────────────── /// Upload a file that is already in the OS temp directory. @@ -603,6 +815,43 @@ mod tests { assert_eq!(detect_and_validate_mime(&jpeg).unwrap(), "image/jpeg"); } + #[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://") + )); + } + #[test] fn test_detect_and_validate_mime_accepts_text_as_octet_stream() { // Plain text has no magic bytes β€” infer returns None, so it's accepted diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 3a057eaa18c..5ef280c9eb6 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -422,8 +422,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>, @@ -493,10 +491,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, @@ -537,6 +535,7 @@ pub async fn send_channel_message( &media, &emoji, &mention_refs_only, + &sticker_refs, )? } }; @@ -734,6 +733,7 @@ pub async fn send_managed_agent_channel_message( &[], &[], &[], + &[], &client_tags, )?; let result = diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 650efffbd30..2f2b57913d9 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -126,6 +126,34 @@ fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), Str Ok(()) } +/// 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. +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(()) +} + /// Validate a hex pubkey is exactly 64 hex characters. fn check_pubkey(pubkey: &str) -> Result<(), String> { if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { @@ -287,6 +315,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, @@ -304,6 +334,7 @@ pub fn build_message( media_tags, custom_emoji_tags, mention_ref_tags, + sticker_ref_tags, &[], ) } @@ -322,6 +353,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)?; @@ -333,6 +365,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)) } diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index bc9a1abd415..9353abc70a2 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -298,7 +298,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/lib.rs b/desktop/src-tauri/src/lib.rs index 7f4897b7d25..0a299a98300 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -816,6 +816,8 @@ pub fn run() { upload_media, pick_and_upload_media, pick_and_upload_image, + pick_and_upload_sticker_image, + import_signal_sticker_pack, upload_media_bytes, download_image, download_file, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index d667c57c35b..c1af17259b2 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -53,6 +53,7 @@ import { useUserStatusSubscription, } from "@/features/user-status/hooks"; import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks"; +import { useStickerLiveUpdates } from "@/features/stickers/hooks"; import { useArchiveSync } from "@/features/local-archive/archiveSyncManager"; import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed"; import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed"; @@ -201,6 +202,7 @@ export function AppShell() { usePresenceSubscription(); useUserStatusSubscription(); useCommunityEmojiLiveUpdates(); + useStickerLiveUpdates(); useMembershipNotifications(identityQuery.data?.pubkey); const presenceSession = usePresenceSession(deferredPubkey); const selfStatusQuery = useUserStatusQuery( diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 321c21414f9..7b2386f05fb 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -447,6 +447,7 @@ export function useSendMessageMutation( mediaTags: imetaTags, emojiTags, mentionTags, + stickerTags, } = splitOutgoingTags(mediaTags); const recipientPubkeys = messageMentionPubkeys( effectiveChannel, @@ -457,7 +458,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), @@ -471,6 +477,7 @@ export function useSendMessageMutation( undefined, emojiTags, mentionTags, + stickerTags, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -508,6 +515,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 d988db9dd50..512fc1265cc 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 2cd9a7b6c24..059973f175b 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -21,12 +21,8 @@ import { } from "@/features/messages/lib/imetaMediaMarkdown"; import { useAttachmentEditing } from "@/features/messages/lib/useAttachmentEditing"; -import { - type MediaUploadController, - useMediaUpload, -} from "@/features/messages/lib/useMediaUpload"; +import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; import { useMentions } from "@/features/messages/lib/useMentions"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { hasMentionClipboardHtml, normalizeMentionClipboardHtml, @@ -42,7 +38,6 @@ import { useComposerSpoilerParticles } from "@/features/messages/lib/useComposer import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast"; import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard"; import { cn } from "@/shared/lib/cn"; -import type { ChannelType } from "@/shared/api/types"; import { ChannelAutocomplete } from "./ChannelAutocomplete"; import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner"; import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments"; @@ -52,89 +47,15 @@ import { type MentionSuggestion, } from "./MentionAutocomplete"; import { MessageComposerToolbar } from "./MessageComposerToolbar"; +import { + ComposerStickerPreview, + useComposerSticker, +} from "@/features/stickers/useComposerSticker"; import { NonMemberMentionDialog } from "./NonMemberMentionDialog"; import { useMentionSendFlow } from "./useMentionSendFlow"; import { useComposerContentState } from "./useComposerContentState"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; - -type MessageComposerProps = { - channelId?: string | null; - channelName: string; - channelType?: ChannelType | null; - containerClassName?: string; - disabled?: boolean; - draftKey?: string; - /** - * When provided, the composer fires `submitMessage` once on mount after - * the draft matching this key has been loaded into the editor. This powers - * the "Send message" confirm-dialog flow in the Drafts panel. The callback - * `onAutoSubmitComplete` must clear the trigger (e.g. remove `?autoSend` - * from the URL) β€” it is called synchronously before `submitMessage` fires - * so the param is gone before any navigation the send might cause. - * - * Fires at most once per mount: a stable key value that persists across - * re-renders does NOT re-fire. - */ - autoSubmitDraftKey?: string | null; - /** Called when the auto-submit fires so the parent can clear the trigger. */ - onAutoSubmitComplete?: () => void; - editTarget?: { - author: string; - body: string; - id: string; - /** - * NIP-92 imeta attachments on the original event, in tag order. Loaded - * into the composer's pending-imeta state on edit-open so the user sees - * them as removable thumbnails (just like the send path) and can add - * more. The submit path emits a fresh full imeta tag set on the edit - * event; the receiver overlays it. - */ - imetaMedia?: ImetaMedia[]; - } | null; - isSending?: boolean; - mediaController?: MediaUploadController; - onCancelEdit?: () => void; - onCancelReply?: () => void; - /** - * Invoked when the user presses ↑ in an empty composer that is not already - * in edit mode. The owner should locate the most recent message authored by - * the current user within this composer's scope (main timeline, DM, or - * thread) and enter edit mode for it. Return `true` if a target was found - * and edit mode was entered, so the composer can swallow the keystroke; - * return `false` to let the arrow key fall through normally. - */ - onEditLastOwnMessage?: () => boolean; - onEditSave?: (content: string, mediaTags?: string[][]) => Promise; - /** Captures send context synchronously before awaits can change navigation. */ - onCaptureSendContext?: () => { - parentEventId: string | null; - threadHeadId: string | null; - } | null; - /** Resolves the channel required to prepare mentions before sending. */ - onPrepareSendChannel?: (pubkeys?: string[]) => Promise; - onPreparingMentionSendChange?: (isPreparing: boolean) => void; - onSend: ( - content: string, - mentionPubkeys: string[], - mediaTags?: string[][], - channelId?: string | null, - threadContext?: { - parentEventId: string | null; - threadHeadId: string | null; - } | null, - ) => Promise; - placeholder?: string; - profiles?: UserProfileLookup; - replyTarget?: { - author: string; - body: string; - id: string; - } | null; - showTopBorder?: boolean; - toolbarExtraActions?: React.ReactNode; - typingParentEventId?: string | null; - typingRootEventId?: string | null; -}; +import type { MessageComposerProps } from "./MessageComposerProps"; function MessageComposerImpl({ channelId = null, @@ -174,6 +95,7 @@ function MessageComposerImpl({ } = useComposerContentState(); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); const [isFormattingOpen, setIsFormattingOpen] = React.useState(false); + const sticker = useComposerSticker(); const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState< Set >(() => new Set()); @@ -240,6 +162,7 @@ function MessageComposerImpl({ React.useEffect(() => { media.setUploadState({ status: "idle" }); setIsEmojiPickerOpen(false); + sticker.clear(); mentions.clearMentions(); channelLinks.clearChannels(); emojiAutocomplete.clearEmojis(); @@ -353,6 +276,7 @@ function MessageComposerImpl({ setContent: setComposerContent, setIsEmojiPickerOpen, setPendingImeta: media.setPendingImeta, + setPendingStickerTags: sticker.setPendingTags, setSpoileredAttachmentUrls, }); @@ -598,8 +522,9 @@ function MessageComposerImpl({ // Normal send const currentPendingImeta = media.pendingImetaRef.current; const hasMedia = currentPendingImeta.length > 0; + const hasSticker = sticker.selection !== null; if ( - (!trimmed && !hasMedia) || + (!trimmed && !hasMedia && !hasSticker) || disabledRef.current || isSendingRef.current || isUploadingRef.current || @@ -627,7 +552,8 @@ function MessageComposerImpl({ drafts.loadDraft, ), spoileredAttachmentUrls, - trimmed, + stickerTags: sticker.tags, + trimmed: trimmed || sticker.fallback, }); } finally { onPreparingMentionSendChange?.(false); @@ -650,6 +576,9 @@ function MessageComposerImpl({ syncComposerContentFromEditor, onCaptureSendContext, onPreparingMentionSendChange, + sticker.fallback, + sticker.selection, + sticker.tags, ]); submitMessageRef.current = submitMessage; @@ -839,13 +768,16 @@ function MessageComposerImpl({ disabled || media.isUploading || mentionSendFlow.isPreparingMentionSend || - (isContentEmpty && media.pendingImeta.length === 0), + (isContentEmpty && + media.pendingImeta.length === 0 && + sticker.selection === null), [ disabled, media.isUploading, mentionSendFlow.isPreparingMentionSend, isContentEmpty, media.pendingImeta.length, + sticker.selection, ], ); @@ -982,6 +914,13 @@ function MessageComposerImpl({ )} + {sticker.selection ? ( + + ) : null} + {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
diff --git a/desktop/src/features/messages/ui/MessageComposerProps.ts b/desktop/src/features/messages/ui/MessageComposerProps.ts new file mode 100644 index 00000000000..5228b003516 --- /dev/null +++ b/desktop/src/features/messages/ui/MessageComposerProps.ts @@ -0,0 +1,52 @@ +import type * as React from "react"; + +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { MediaUploadController } from "@/features/messages/lib/useMediaUpload"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelType } from "@/shared/api/types"; + +export type MessageComposerProps = { + channelId?: string | null; + channelName: string; + channelType?: ChannelType | null; + containerClassName?: string; + disabled?: boolean; + draftKey?: string; + autoSubmitDraftKey?: string | null; + onAutoSubmitComplete?: () => void; + editTarget?: { + author: string; + body: string; + id: string; + imetaMedia?: ImetaMedia[]; + } | null; + isSending?: boolean; + mediaController?: MediaUploadController; + onCancelEdit?: () => void; + onCancelReply?: () => void; + onEditLastOwnMessage?: () => boolean; + onEditSave?: (content: string, mediaTags?: string[][]) => Promise; + onCaptureSendContext?: () => { + parentEventId: string | null; + threadHeadId: string | null; + } | null; + onPrepareSendChannel?: (pubkeys?: string[]) => Promise; + onPreparingMentionSendChange?: (isPreparing: boolean) => void; + onSend: ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + channelId?: string | null, + threadContext?: { + parentEventId: string | null; + threadHeadId: string | null; + } | null, + ) => Promise; + placeholder?: string; + profiles?: UserProfileLookup; + replyTarget?: { author: string; body: string; id: string } | null; + showTopBorder?: boolean; + toolbarExtraActions?: React.ReactNode; + typingParentEventId?: string | null; + typingRootEventId?: string | null; +}; 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")); @@ -285,8 +287,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 ( @@ -460,7 +471,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 c01ff9f9c8f..551db6ae9a7 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -30,6 +30,7 @@ import { Skeleton } from "@/shared/ui/skeleton"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageComposer } from "./MessageComposer"; import { MessageRow, type ThreadDepthGuideAction } from "./MessageRow"; +import { parseStickerReference } from "@/shared/api/stickers"; import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow"; import { TypingIndicatorRow } from "./TypingIndicatorRow"; import { UnreadDivider } from "./UnreadDivider"; @@ -635,6 +636,7 @@ export function MessageThreadPanel({ } onEdit={ onEdit && + !parseStickerReference(threadHead.tags) && canManageMessageForCurrentUser( threadHead, currentPubkey, @@ -787,6 +789,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 1f57263fb8a..0e5c912d559 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -34,6 +34,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"; @@ -868,7 +869,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/useMentionSendFlow.ts b/desktop/src/features/messages/ui/useMentionSendFlow.ts index c76c0cb52fb..70015b305dc 100644 --- a/desktop/src/features/messages/ui/useMentionSendFlow.ts +++ b/desktop/src/features/messages/ui/useMentionSendFlow.ts @@ -44,6 +44,7 @@ type PendingNonMemberMentionSend = { savedContent: string; savedImeta: ImetaMedia[]; savedSpoileredAttachmentUrls: Set; + savedStickerTags: string[][]; sentDraftKey: string | null | undefined; }; @@ -58,6 +59,7 @@ type SendMessageWithMentionFlowInput = { sentDraftKey: string | null | undefined; spoileredAttachmentUrls?: ReadonlySet; trimmed: string; + stickerTags?: string[][]; }; type UseMentionSendFlowOptions = { @@ -88,6 +90,7 @@ type UseMentionSendFlowOptions = { setContent: (content: string) => void; setIsEmojiPickerOpen: React.Dispatch>; setPendingImeta: (pendingImeta: ImetaMedia[]) => void; + setPendingStickerTags?: (tags: string[][]) => void; setSpoileredAttachmentUrls?: React.Dispatch< React.SetStateAction> >; @@ -144,6 +147,7 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + setPendingStickerTags, setSpoileredAttachmentUrls, }: UseMentionSendFlowOptions) { const [pendingNonMemberSend, setPendingNonMemberSend] = @@ -375,6 +379,7 @@ export function useMentionSendFlow({ contentRef.current = ""; richText.clearContent(); setPendingImeta([]); + setPendingStickerTags?.([]); setSpoileredAttachmentUrls?.(new Set()); mentions.clearMentions(); channelLinks.clearChannels(); @@ -389,6 +394,7 @@ export function useMentionSendFlow({ setContent, setIsEmojiPickerOpen, setPendingImeta, + setPendingStickerTags, setSpoileredAttachmentUrls, ]); @@ -504,6 +510,7 @@ export function useMentionSendFlow({ contentRef.current = draft.savedContent; richText.setContent(draft.savedContent); setPendingImeta(draft.savedImeta); + setPendingStickerTags?.(draft.savedStickerTags); setSpoileredAttachmentUrls?.( new Set(draft.savedSpoileredAttachmentUrls), ); @@ -528,6 +535,7 @@ export function useMentionSendFlow({ richText.setContent, setContent, setPendingImeta, + setPendingStickerTags, setSpoileredAttachmentUrls, ], ); @@ -596,6 +604,7 @@ export function useMentionSendFlow({ pendingImeta, sentDraftKey, spoileredAttachmentUrls = new Set(), + stickerTags = [], trimmed, }: SendMessageWithMentionFlowInput) => { if (isMentionSendPendingRef.current) { @@ -655,6 +664,7 @@ export function useMentionSendFlow({ const outgoingTags = mergeOutgoingTags( mediaTags, buildCustomEmojiTags(finalContent, customEmoji), + stickerTags, ); const nonMemberPubkeys = getNonMemberMentionPubkeys(pubkeys); let promptNonMemberPubkeys = nonMemberPubkeys.filter( @@ -690,6 +700,7 @@ export function useMentionSendFlow({ savedContent: trimmed, savedImeta: [...pendingImeta], savedSpoileredAttachmentUrls: new Set(spoileredAttachmentUrls), + savedStickerTags: stickerTags, sentDraftKey, }; diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 3ec811c8597..1903182694a 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -15,6 +15,7 @@ import { Moon, ShieldAlert, Smartphone, + Sticker, Smile, Stethoscope, Sun, @@ -30,6 +31,7 @@ import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound"; import { CommunityMembersSettingsCard } from "@/features/community-members/ui/CommunityMembersSettingsCard"; import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard"; import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchiveSettingsCard"; +import { StickerSettingsCard } from "@/features/stickers/ui/StickerSettingsCard"; import { cn } from "@/shared/lib/cn"; import { ACCENT_COLORS, @@ -79,6 +81,7 @@ export type SettingsSection = | "community-members" | "moderation" | "custom-emoji" + | "stickers" | "local-archive" | "mobile" | "updates" @@ -98,6 +101,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "community-members", "moderation", "custom-emoji", + "stickers", "local-archive", "mobile", "updates", @@ -193,6 +197,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [ icon: Smile, featureGate: "custom-emoji", }, + { + value: "stickers", + label: "Stickers", + icon: Sticker, + }, { value: "local-archive", label: "Local archive", @@ -729,6 +738,8 @@ export function renderSettingsSection( return ; case "custom-emoji": return ; + case "stickers": + return ; case "local-archive": return ; case "mobile": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 1a2dbae2ecc..3db6b581fed 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -54,6 +54,7 @@ const settingsNavGroups: Array<{ "notifications", "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..79998db6f8e --- /dev/null +++ b/desktop/src/features/stickers/hooks.ts @@ -0,0 +1,165 @@ +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); + return pack ? [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..db9032e2ae5 --- /dev/null +++ b/desktop/src/features/stickers/ui/ComposerStickerPicker.tsx @@ -0,0 +1,108 @@ +import * as React from "react"; +import { Sticker as StickerIcon } from "lucide-react"; + +import { useInstalledStickerPacks } from "@/features/stickers/hooks"; +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 [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 ? ( +

+ Install a sticker pack in Settings first. +

+ ) : ( +
+ + {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..4eecf9dc79a --- /dev/null +++ b/desktop/src/features/stickers/ui/StickerSettingsCard.tsx @@ -0,0 +1,564 @@ +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 { + 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 [signalLink, setSignalLink] = 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 importSignal = React.useCallback(async () => { + if (!signalLink.trim()) return; + setIsWorking(true); + try { + const imported = await importSignalStickerPack(signalLink); + setIdentifier(imported.identifier); + setTitle(imported.title); + setDescription( + imported.author + ? `Imported from Signal Β· ${imported.author}` + : "Imported from Signal", + ); + 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 (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 Signal pack.", + ); + } finally { + // The secret-bearing link exists only for the duration of this invoke. + setSignalLink(""); + setIsWorking(false); + } + }, [signalLink]); + + 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} + /> +
+