diff --git a/Cargo.lock b/Cargo.lock index 8cf5f33a..e6fbf483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2067,6 +2067,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -2837,7 +2838,7 @@ dependencies = [ "nostr-relay-builder", "nostr-sdk", "opentelemetry-proto", - "prost", + "prost 0.14.3", "psl", "rand 0.8.6", "reqwest", @@ -2845,6 +2846,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "sonar-stickers", "storage-sqlite", "tempfile", "thiserror 2.0.18", @@ -3391,7 +3393,7 @@ checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.3", ] [[package]] @@ -3838,6 +3840,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" @@ -3845,7 +3857,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.3", +] + +[[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]] @@ -4292,6 +4317,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -4866,6 +4892,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=162ac26c88b351dfa3f35d8f098c00a18ae46114#162ac26c88b351dfa3f35d8f098c00a18ae46114" +dependencies = [ + "aes", + "base64", + "cbc", + "hex", + "hkdf", + "hmac", + "nostr", + "prost 0.13.5", + "reqwest", + "serde", + "sha2", + "thiserror 2.0.18", + "url", +] + [[package]] name = "spki" version = "0.7.3" diff --git a/Cargo.toml b/Cargo.toml index c472b6fb..26a83086 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,7 @@ cgka-traits = { path = "crates/traits" } cgka-engine = { path = "crates/cgka-engine" } cgka-session = { path = "crates/cgka-session" } storage-sqlite = { path = "crates/storage-sqlite" } +sonar-stickers = { git = "https://github.com/hedwig-corp/bitchat-to-sonar", rev = "162ac26c88b351dfa3f35d8f098c00a18ae46114", features = ["nostr", "signal-import"] } cgka-conformance-simulator = { path = "crates/cgka-conformance-simulator" } transport-nostr-peeler = { path = "crates/transport-nostr-peeler" } transport-nostr-adapter = { path = "crates/transport-nostr-adapter" } diff --git a/crates/marmot-app/Cargo.toml b/crates/marmot-app/Cargo.toml index d78c70d2..6bb09473 100644 --- a/crates/marmot-app/Cargo.toml +++ b/crates/marmot-app/Cargo.toml @@ -35,6 +35,7 @@ rusqlite.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true +sonar-stickers.workspace = true storage-sqlite.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["fs", "net"] } diff --git a/crates/marmot-app/src/client/audit.rs b/crates/marmot-app/src/client/audit.rs index fb74af25..dacaae3a 100644 --- a/crates/marmot-app/src/client/audit.rs +++ b/crates/marmot-app/src/client/audit.rs @@ -62,6 +62,7 @@ impl AppClient { ) -> Option { let (action, target_count): (&'static str, Option) = match intent { AppMessageIntent::Chat { .. } => ("send_message", None), + AppMessageIntent::Sticker { .. } => ("send_sticker", None), AppMessageIntent::Reply { .. } => ("reply_message", None), AppMessageIntent::Edit { .. } => ("edit_message", None), AppMessageIntent::Reaction { .. } => ("react", None), diff --git a/crates/marmot-app/src/client/mod.rs b/crates/marmot-app/src/client/mod.rs index 813c0723..23b57041 100644 --- a/crates/marmot-app/src/client/mod.rs +++ b/crates/marmot-app/src/client/mod.rs @@ -1076,6 +1076,10 @@ impl AppClient { F: FnMut(crate::AppProjectionUpdate), { self.ensure_group(group_id)?; + if let AppMessageIntent::Sticker { sticker_ref } = &intent { + self.app + .authorize_sticker_ref(&self.state.label, sticker_ref)?; + } // Capture the human-action descriptor before `Unreact` is rewritten to // `Delete` below, so the audit log records the user's actual intent. let audit_context = Self::message_human_action_context(&intent); diff --git a/crates/marmot-app/src/client/projection.rs b/crates/marmot-app/src/client/projection.rs index 21bbbfb1..183fdfc3 100644 --- a/crates/marmot-app/src/client/projection.rs +++ b/crates/marmot-app/src/client/projection.rs @@ -395,6 +395,13 @@ fn read_marker_error_code(error: &AppError) -> &'static str { } AppError::InvalidEncryptedMedia(_) => "read_marker_failed:invalid_encrypted_media", AppError::BlobStore(_) => "read_marker_failed:blob_store", + AppError::InvalidSticker(_) => "read_marker_failed:invalid_sticker", + AppError::StickerNotFound => "read_marker_failed:sticker_not_found", + AppError::StickerRelay(_) => "read_marker_failed:sticker_relay", + AppError::StickerImport(_) => "read_marker_failed:sticker_import", + AppError::StickerExternalSignerImportUnsupported => { + "read_marker_failed:sticker_external_signer_import_unsupported" + } AppError::InvalidAppMessagePayload(_) => "read_marker_failed:invalid_app_message_payload", AppError::InvalidPushToken(_) => "read_marker_failed:invalid_push_token", AppError::InvalidPushServer(_) => "read_marker_failed:invalid_push_server", diff --git a/crates/marmot-app/src/client/push.rs b/crates/marmot-app/src/client/push.rs index 9c2a0ae9..e05202d3 100644 --- a/crates/marmot-app/src/client/push.rs +++ b/crates/marmot-app/src/client/push.rs @@ -232,6 +232,7 @@ pub(crate) fn notification_trigger_for_intent( ) -> Option { match intent { AppMessageIntent::Chat { .. } + | AppMessageIntent::Sticker { .. } | AppMessageIntent::Reply { .. } | AppMessageIntent::Media { .. } | AppMessageIntent::StreamFinal { .. } => { diff --git a/crates/marmot-app/src/error.rs b/crates/marmot-app/src/error.rs index f911be9d..7027c6bd 100644 --- a/crates/marmot-app/src/error.rs +++ b/crates/marmot-app/src/error.rs @@ -80,6 +80,16 @@ pub enum AppError { InvalidEncryptedMedia(String), #[error("blob store request failed: {0}")] BlobStore(String), + #[error("invalid sticker data: {0}")] + InvalidSticker(String), + #[error("sticker pack or asset is unavailable")] + StickerNotFound, + #[error("sticker relay operation failed: {0}")] + StickerRelay(String), + #[error("sticker import failed: {0}")] + StickerImport(String), + #[error("Signal sticker import is unavailable for external-signing accounts")] + StickerExternalSignerImportUnsupported, #[error("invalid app message payload: {0}")] InvalidAppMessagePayload(String), #[error("invalid push token")] @@ -156,6 +166,13 @@ impl AppError { Self::InvalidAgentTextStreamPolicy(_) => "invalid_agent_text_stream_policy", Self::InvalidEncryptedMedia(_) => "invalid_encrypted_media", Self::BlobStore(_) => "blob_store", + Self::InvalidSticker(_) => "invalid_sticker", + Self::StickerNotFound => "sticker_not_found", + Self::StickerRelay(_) => "sticker_relay", + Self::StickerImport(_) => "sticker_import", + Self::StickerExternalSignerImportUnsupported => { + "sticker_external_signer_import_unsupported" + } Self::InvalidAppMessagePayload(_) => "invalid_app_message_payload", Self::InvalidPushToken(_) => "invalid_push_token", Self::InvalidPushServer(_) => "invalid_push_server", diff --git a/crates/marmot-app/src/lib.rs b/crates/marmot-app/src/lib.rs index 66889c57..66fd0666 100644 --- a/crates/marmot-app/src/lib.rs +++ b/crates/marmot-app/src/lib.rs @@ -83,6 +83,7 @@ mod relay_plane; mod relay_telemetry_export; mod runtime; mod sqlcipher; +mod stickers; use external_signer::{AccountSigner, RegisteredExternalSigner}; pub use external_signer::{EXTERNAL_SIGNER_REJECTED, ExternalAccountSigner}; @@ -102,6 +103,11 @@ pub use runtime::{ default_directory_discovery_relays, }; pub(crate) use sqlcipher::{SqlcipherDatabaseKind, remove_sqlite_file_set}; +pub use stickers::{ + AppSticker, AppStickerAsset, AppStickerImportResult, AppStickerPack, AppStickerRef, + AppStickerSyncResult, parse_sticker_pack_input, sticker_ref_from_message, + sticker_ref_from_tags, +}; pub use storage_sqlite::{TimelineMessageChange, TimelineRemoveReason, TimelineUpdateTrigger}; pub use agent_streams::{ @@ -408,6 +414,10 @@ pub struct MarmotApp { chat_list_projection_stale: Arc>>, audit_log_tracker_config: Arc>, external_signers: Arc>>, + /// Per-account serialization for public sticker-pack/install mutations. + /// The mutex values are lifecycle-only; protocol truth and pending intent + /// remain in the account SQLCipher database. + sticker_mutation_locks: Arc>>>>, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -959,6 +969,7 @@ impl MarmotApp { chat_list_projection_stale: Arc::new(Mutex::new(HashSet::new())), audit_log_tracker_config: Arc::new(Mutex::new(AuditLogTrackerConfig::default())), external_signers: Arc::new(Mutex::new(HashMap::new())), + sticker_mutation_locks: Arc::new(Mutex::new(HashMap::new())), } } @@ -1003,6 +1014,7 @@ impl MarmotApp { chat_list_projection_stale: Arc::new(Mutex::new(HashSet::new())), audit_log_tracker_config: Arc::new(Mutex::new(AuditLogTrackerConfig::default())), external_signers: Arc::new(Mutex::new(HashMap::new())), + sticker_mutation_locks: Arc::new(Mutex::new(HashMap::new())), } } diff --git a/crates/marmot-app/src/media/blossom.rs b/crates/marmot-app/src/media/blossom.rs index 626a0334..5239b579 100644 --- a/crates/marmot-app/src/media/blossom.rs +++ b/crates/marmot-app/src/media/blossom.rs @@ -79,6 +79,18 @@ pub(crate) async fn upload_blossom_blob( pub(crate) async fn fetch_blossom_blob( url: &str, allow_loopback_http: bool, +) -> Result, AppError> { + fetch_blossom_blob_limited(url, MAX_ENCRYPTED_MEDIA_BLOB_BYTES, allow_loopback_http).await +} + +/// Hardened bounded Blossom fetch shared by encrypted media and smaller public +/// assets such as stickers. Callers choose a domain-specific byte ceiling; +/// URL, redirect, DNS/IP pinning, timeout, proxy, and content-encoding policy +/// remain identical. +pub(crate) async fn fetch_blossom_blob_limited( + url: &str, + max_bytes: u64, + allow_loopback_http: bool, ) -> Result, AppError> { let mut current = Url::parse(url) .map_err(|_| AppError::InvalidEncryptedMedia("media URL is invalid".into()))?; @@ -95,7 +107,7 @@ pub(crate) async fn fetch_blossom_blob( .map_err(reqwest_blob_error)?; let status = response.status(); if status.is_success() { - return read_limited_blossom_body(response, MAX_ENCRYPTED_MEDIA_BLOB_BYTES).await; + return read_limited_blossom_body(response, max_bytes).await; } if !status.is_redirection() { return Err(AppError::BlobStore(format!( diff --git a/crates/marmot-app/src/media/mod.rs b/crates/marmot-app/src/media/mod.rs index 2049a7ed..64467a74 100644 --- a/crates/marmot-app/src/media/mod.rs +++ b/crates/marmot-app/src/media/mod.rs @@ -15,14 +15,16 @@ mod crypto; mod group_image; mod host_safety; -use blossom::{blossom_content_hash_from_url, upload_blossom_blob}; +use blossom::blossom_content_hash_from_url; use crypto::{ derive_media_file_key, media_aad, media_hash_from_reference, media_nonce_from_reference, validate_sha256_hex, }; use host_safety::validate_locator; -pub(crate) use blossom::{blossom_blob_url, fetch_blossom_blob}; +pub(crate) use blossom::{ + blossom_blob_url, fetch_blossom_blob, fetch_blossom_blob_limited, upload_blossom_blob, +}; pub(crate) use crypto::canonical_media_type; pub(crate) use group_image::{fetch_group_image, upload_group_image}; pub(crate) use host_safety::is_loopback_http_endpoint; diff --git a/crates/marmot-app/src/messages/intents.rs b/crates/marmot-app/src/messages/intents.rs index 6bddc2ef..981eec76 100644 --- a/crates/marmot-app/src/messages/intents.rs +++ b/crates/marmot-app/src/messages/intents.rs @@ -160,6 +160,9 @@ pub(crate) enum AppMessageIntent { Chat { content: String, }, + Sticker { + sticker_ref: crate::AppStickerRef, + }, Reaction { target_message_id: String, emoji: String, @@ -246,6 +249,11 @@ pub(crate) fn build_inner_event( mention_p_tags(content), content.clone(), )), + AppMessageIntent::Sticker { sticker_ref } => Ok(event( + MARMOT_APP_EVENT_KIND_CHAT, + vec![crate::stickers::sticker_ref_tag(sticker_ref)?], + String::new(), + )), AppMessageIntent::Reaction { target_message_id, emoji, diff --git a/crates/marmot-app/src/notifications.rs b/crates/marmot-app/src/notifications.rs index 5f74a75f..a0ce8d48 100644 --- a/crates/marmot-app/src/notifications.rs +++ b/crates/marmot-app/src/notifications.rs @@ -204,6 +204,10 @@ pub struct NotificationUpdate { pub sender: NotificationUser, pub receiver: NotificationUser, pub preview_text: Option, + /// Typed Sonar sticker reference for an otherwise-empty kind-9 message. + /// Host apps localize the notification body (for example, "Sticker") and + /// never need to parse raw tags. + pub sticker: Option, /// Reaction emoji (Nostr kind 7 content); `None` for non-reactions. Additive /// at the DTO level (no `trigger`/`preview_text` change), but it changes the /// generated UniFFI record: consumers must regenerate bindings and ship the @@ -1318,6 +1322,7 @@ fn notification_update_from_message( sender, receiver, preview_text: preview_text_for_message(&event.message), + sticker: crate::sticker_ref_from_message(&event.message), reaction_emoji, reacted_to_preview, timestamp_ms: unix_now_ms(), @@ -1362,6 +1367,7 @@ fn notification_update_from_group_join( sender, receiver, preview_text: None, + sticker: None, reaction_emoji: None, reacted_to_preview: None, timestamp_ms: unix_now_ms(), diff --git a/crates/marmot-app/src/relay_plane/mod.rs b/crates/marmot-app/src/relay_plane/mod.rs index 510708ce..f9952617 100644 --- a/crates/marmot-app/src/relay_plane/mod.rs +++ b/crates/marmot-app/src/relay_plane/mod.rs @@ -10,7 +10,7 @@ use cgka_traits::{ TransportPublishRequest, }; use nostr_sdk::prelude::{ - Client as NostrSdkClient, Filter, Kind, PublicKey, RelayMessage, RelayPoolNotification, + Client as NostrSdkClient, Event, Filter, Kind, PublicKey, RelayMessage, RelayPoolNotification, RelayUrl, SubscriptionId, Timestamp as NostrTimestamp, }; use serde::{Deserialize, Serialize}; @@ -56,6 +56,7 @@ pub(crate) use transport_nostr_adapter::{ const ACCOUNT_DELIVERY_BUFFER: usize = 1024; const DIRECTORY_EVENT_BUFFER: usize = 1024; pub(crate) const DIRECTORY_RELAY_CONNECT_WAIT: Duration = Duration::from_secs(5); +const PUBLIC_EVENT_FETCH_WAIT: Duration = Duration::from_secs(8); const RELAY_PLANE_SHUTDOWN_WAIT: Duration = Duration::from_secs(2); const RELAY_PLANE_TASK_ABORT_WAIT: Duration = Duration::from_millis(250); @@ -387,6 +388,85 @@ impl MarmotRelayPlane { .await } + /// One-shot fetch for bounded public protocol projections such as sticker + /// packs. Every endpoint passes through the same relay-safety chokepoint as + /// account and directory traffic. A short-lived SDK client prevents a + /// discovery filter from inheriting the active account/group subscriptions + /// of the long-lived transport pool. + pub(crate) async fn fetch_public_events( + &self, + endpoints: Vec, + filter: Filter, + ) -> Result, String> { + let endpoints = self + .inner + .relay_safety + .sanitize_endpoints(endpoints, "public event fetch") + .map_err(|_| "public event fetch: relay endpoints rejected".to_owned())?; + if endpoints.is_empty() { + return Err("public event fetch: no relay endpoints".to_owned()); + } + let relay_urls = endpoints + .iter() + .map(|endpoint| { + RelayUrl::parse(endpoint.as_str()) + .map_err(|_| "public event fetch: invalid relay endpoint".to_owned()) + }) + .collect::, _>>()?; + let client = NostrSdkClient::builder().build(); + let mut connected_relays = Vec::with_capacity(relay_urls.len()); + for relay_url in relay_urls { + if client.add_relay(relay_url.clone()).await.is_err() { + continue; + } + if client + .try_connect_relay(relay_url.clone(), DIRECTORY_RELAY_CONNECT_WAIT) + .await + .is_ok() + { + connected_relays.push(relay_url); + } + } + if connected_relays.is_empty() { + return Err("public event fetch: no relay connected".to_owned()); + } + client + .fetch_events_from(connected_relays, filter, PUBLIC_EVENT_FETCH_WAIT) + .await + .map(|events| events.into_iter().collect()) + .map_err(|_| "public event fetch failed".to_owned()) + } + + /// Publish an already-signed public event through the shared SDK transport. + /// The adapter owns bounded retries, write-only relay lifecycle, stable + /// privacy-safe errors, and acknowledgement accounting. + pub(crate) async fn publish_public_event( + &self, + endpoints: Vec, + event: &Event, + ) -> Result { + let endpoints = self + .inner + .relay_safety + .sanitize_endpoints(endpoints, "public event publish") + .map_err(|_| "public event publish: relay endpoints rejected".to_owned())?; + if endpoints.is_empty() { + return Err("public event publish: no relay endpoints".to_owned()); + } + let relay_client = self + .inner + .transport + .sdk_relay_client + .as_ref() + .ok_or_else(|| "public event publish requires SDK relay plane".to_owned())?; + let event = NostrTransportEvent::from_nostr_event(event) + .map_err(|_| "public event publish: invalid signed event".to_owned())?; + relay_client + .publish_event(&endpoints, &event, 1) + .await + .map_err(|_| "public event publish failed".to_owned()) + } + pub(crate) fn subscribe_directory_events( &self, ) -> broadcast::Receiver { diff --git a/crates/marmot-app/src/runtime/mod.rs b/crates/marmot-app/src/runtime/mod.rs index 3c5b9928..c5e5bb2e 100644 --- a/crates/marmot-app/src/runtime/mod.rs +++ b/crates/marmot-app/src/runtime/mod.rs @@ -1666,6 +1666,28 @@ impl MarmotAppRuntime { .await } + pub async fn send_sticker( + &self, + account_ref: &str, + group_id: &GroupId, + sticker_ref: crate::AppStickerRef, + ) -> Result { + let summary = self + .accounts + .send_app_event( + account_ref, + group_id, + AppMessageIntent::Sticker { sticker_ref }, + ) + .await?; + let _ = self.publish_chat_list_projection_refresh( + account_ref, + &hex::encode(group_id.as_slice()), + ChatListUpdateTrigger::NewLastMessage, + ); + Ok(summary) + } + pub async fn unreact_from_message( &self, account_ref: &str, diff --git a/crates/marmot-app/src/stickers.rs b/crates/marmot-app/src/stickers.rs new file mode 100644 index 00000000..654e08dc --- /dev/null +++ b/crates/marmot-app/src/stickers.rs @@ -0,0 +1,1814 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use cgka_traits::TransportEndpoint; +use nostr::nips::nip19::{FromBech32, Nip19Coordinate}; +use nostr::{ + Alphabet, Event, EventBuilder, Filter, JsonUtil, Kind, NostrSigner, PublicKey, RelayUrl, + SingleLetterTag, Tag, Timestamp as NostrTimestamp, +}; +use serde::{Deserialize, Serialize}; +use sonar_stickers::{ + InstalledPackList, PACK_FORMAT, PackAddress, STICKER_PACK_KIND, Sticker, StickerPack, + StickerRef, USER_STICKER_PACKS_KIND, build_installed_packs_tags, build_pack_tags, + build_sticker_ref_tag, parse_installed_pack_list, parse_pack_event, parse_sticker_ref_tag, + sha256_hex, +}; +use storage_sqlite::{ + SqliteAccountStorage, StoredSticker, StoredStickerOutboxEvent, StoredStickerPack, + StoredStickerPackVersion, +}; +use url::Url; +use zeroize::Zeroizing; + +use crate::external_signer::AccountSigner; +use crate::media::{fetch_blossom_blob_limited, upload_blossom_blob}; +use crate::{AppError, MarmotApp, ReceivedMessage, unix_now_seconds}; + +pub const DEFAULT_STICKER_BLOSSOM_SERVER_URL: &str = "https://nostr.download"; +const MAX_STICKER_ASSET_BYTES: u64 = 4 * 1024 * 1024; +const MAX_STICKER_DIMENSION: u32 = 4096; +const MAX_STICKER_PIXELS: u64 = 4096 * 4096; +const MAX_STICKER_ANIMATION_FRAMES: u32 = 200; +const MAX_DISCOVERY_PACKS: usize = 100; +const MAX_INSTALLED_PACKS: usize = 100; +const MAX_LINK_CHARS: usize = 2048; +const MAX_STICKER_RELAY_HINTS: usize = 8; +const MAX_FUTURE_EVENT_SKEW_SECONDS: u64 = 5 * 60; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppStickerRef { + pub pack_coordinate: String, + pub shortcode: String, + pub plaintext_sha256: String, +} + +impl AppStickerRef { + fn to_sdk(&self) -> Result { + StickerRef::new( + PackAddress::parse(&self.pack_coordinate) + .map_err(|_| invalid_sticker("invalid pack coordinate"))?, + self.shortcode.clone(), + self.plaintext_sha256.clone(), + ) + .map_err(|_| invalid_sticker("invalid sticker reference")) + } +} + +impl From for AppStickerRef { + fn from(value: StickerRef) -> Self { + Self { + pack_coordinate: value.pack.coordinate(), + shortcode: value.shortcode, + plaintext_sha256: value.plaintext_sha256, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppSticker { + pub pack_coordinate: String, + pub shortcode: String, + pub url: String, + pub sha256: String, + pub mime: String, + pub width: Option, + pub height: Option, + pub alt: Option, + pub emoji: Option, +} + +impl AppSticker { + pub fn reference(&self) -> AppStickerRef { + AppStickerRef { + pack_coordinate: self.pack_coordinate.clone(), + shortcode: self.shortcode.clone(), + plaintext_sha256: self.sha256.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppStickerPack { + pub coordinate: String, + pub author_pubkey_hex: String, + pub identifier: String, + pub event_id_hex: String, + pub created_at: u64, + pub title: String, + pub description: Option, + pub cover: Option, + pub stickers: Vec, + pub license: Option, + pub installed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppStickerAsset { + pub sticker: AppSticker, + pub bytes: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppStickerSyncResult { + pub discovered: u32, + pub updated: u32, + pub installed: u32, + pub pending_operations: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AppStickerImportResult { + pub pack: AppStickerPack, + pub skipped_signal_sticker_ids: Vec, +} + +/// Parse exactly one valid Sonar sticker tag from a kind-9 message. Multiple +/// sticker tags are rejected as ambiguous. Host apps receive this typed value +/// and never need to reinterpret raw Nostr tags. +pub fn sticker_ref_from_tags(kind: u64, tags: &[Vec]) -> Option { + if kind != u64::from(Kind::Custom(9).as_u16()) { + return None; + } + let sticker_tags = tags + .iter() + .filter(|tag| tag.first().is_some_and(|name| name == "sticker")) + .collect::>(); + if sticker_tags.len() != 1 { + return None; + } + let tag = Tag::parse(sticker_tags[0].clone()).ok()?; + parse_sticker_ref_tag(&tag).ok().map(Into::into) +} + +pub fn sticker_ref_from_message(message: &ReceivedMessage) -> Option { + sticker_ref_from_tags(message.kind, &message.tags) +} + +pub(crate) fn sticker_ref_tag(sticker_ref: &AppStickerRef) -> Result, AppError> { + Ok(build_sticker_ref_tag(&sticker_ref.to_sdk()?).to_vec()) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ParsedStickerPackInput { + coordinate: String, + relay_hints: Vec, +} + +/// Accept a canonical coordinate, an NIP-19 `naddr`/`nostr:naddr`, or Sonar's +/// documented HTTPS viewer link. +pub fn parse_sticker_pack_input(input: &str) -> Result { + parse_sticker_pack_input_with_relays(input).map(|parsed| parsed.coordinate) +} + +fn parse_sticker_pack_input_with_relays(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() || input.chars().count() > MAX_LINK_CHARS { + return Err(invalid_sticker("invalid sticker pack link")); + } + if let Ok(address) = PackAddress::parse(input) { + return Ok(ParsedStickerPackInput { + coordinate: address.coordinate(), + relay_hints: Vec::new(), + }); + } + let bech32 = input.strip_prefix("nostr:").unwrap_or(input); + if bech32.starts_with("naddr1") { + let nip19 = Nip19Coordinate::from_bech32(bech32) + .map_err(|_| invalid_sticker("invalid sticker pack address"))?; + if nip19.coordinate.kind != Kind::Custom(STICKER_PACK_KIND) { + return Err(invalid_sticker("sticker pack address has the wrong kind")); + } + let address = PackAddress::new( + nip19.coordinate.public_key.to_hex(), + nip19.coordinate.identifier, + ) + .map_err(|_| invalid_sticker("invalid sticker pack address"))?; + return Ok(ParsedStickerPackInput { + coordinate: address.coordinate(), + relay_hints: normalize_sticker_relay_hints( + nip19.relays.into_iter().map(|relay| relay.to_string()), + ), + }); + } + let url = Url::parse(input).map_err(|_| invalid_sticker("invalid sticker pack link"))?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.port_or_known_default() != Some(443) + || !url + .host_str() + .is_some_and(|host| matches!(host, "sonarprivacy.xyz" | "www.sonarprivacy.xyz")) + || url.path().trim_end_matches('/') != "/stickers" + { + return Err(invalid_sticker("untrusted sticker pack link")); + } + let query_pairs = url.query_pairs().collect::>(); + let value = query_pairs + .iter() + .find_map(|(key, value)| (key == "a").then(|| value.as_ref().to_owned())) + .ok_or_else(|| invalid_sticker("sticker pack link is missing its address"))?; + let mut parsed = parse_sticker_pack_input_with_relays(&value)?; + let link_relays = normalize_sticker_relay_hints( + query_pairs + .into_iter() + .filter_map(|(key, value)| (key == "relay").then(|| value.into_owned())), + ); + for relay in link_relays { + if parsed.relay_hints.len() >= MAX_STICKER_RELAY_HINTS { + break; + } + if !parsed.relay_hints.contains(&relay) { + parsed.relay_hints.push(relay); + } + } + Ok(parsed) +} + +fn normalize_sticker_relay_hints( + relays: impl IntoIterator, +) -> Vec { + let mut normalized = Vec::new(); + for relay in relays { + let Ok(relay) = RelayUrl::parse(relay.trim()) else { + continue; + }; + let endpoint = TransportEndpoint(relay.to_string()); + if !normalized.contains(&endpoint) { + normalized.push(endpoint); + } + if normalized.len() >= MAX_STICKER_RELAY_HINTS { + break; + } + } + normalized +} + +fn validate_signal_sticker_link(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() || input.chars().count() > MAX_LINK_CHARS { + return Err(invalid_sticker("invalid Signal sticker link")); + } + let url = Url::parse(input).map_err(|_| invalid_sticker("invalid Signal sticker link"))?; + if url.scheme() != "https" + || !url.username().is_empty() + || url.password().is_some() + || url.port_or_known_default() != Some(443) + || url.host_str() != Some("signal.art") + || url.path().trim_end_matches('/') != "/addstickers" + { + return Err(invalid_sticker("untrusted Signal sticker link")); + } + let encoded = url + .fragment() + .or_else(|| url.query()) + .ok_or_else(|| invalid_sticker("Signal sticker link is missing credentials"))?; + let parameters = url::form_urlencoded::parse(encoded.as_bytes()).collect::>(); + let pack_id = parameters + .get("pack_id") + .filter(|value| { + value.len() == 32 && value.chars().all(|character| character.is_ascii_hexdigit()) + }) + .ok_or_else(|| invalid_sticker("Signal sticker link has an invalid pack id"))?; + if parameters + .get("pack_key") + .is_none_or(|value| value.is_empty()) + { + return Err(invalid_sticker( + "Signal sticker link is missing credentials", + )); + } + Ok(pack_id.to_ascii_lowercase()) +} + +impl MarmotApp { + pub fn sticker_packs( + &self, + account_ref: &str, + installed_only: bool, + search: Option<&str>, + limit: Option, + ) -> Result, AppError> { + let account = self.account_home().account(account_ref)?; + let storage = self.account_storage(&account.label)?; + let installed = storage + .desired_installed_sticker_packs()? + .into_iter() + .collect::>(); + storage + .sticker_packs(installed_only, search, limit.unwrap_or(50))? + .into_iter() + .map(|pack| app_pack_from_stored(pack, &installed)) + .collect() + } + + pub fn sticker_pack( + &self, + account_ref: &str, + input: &str, + ) -> Result, AppError> { + let coordinate = parse_sticker_pack_input(input)?; + let account = self.account_home().account(account_ref)?; + let storage = self.account_storage(&account.label)?; + let installed = storage + .desired_installed_sticker_packs()? + .into_iter() + .collect::>(); + storage + .sticker_pack(&coordinate)? + .map(|pack| app_pack_from_stored(pack, &installed)) + .transpose() + } + + pub async fn fetch_sticker_asset( + &self, + account_ref: &str, + sticker_ref: AppStickerRef, + ) -> Result { + let sticker_ref = sticker_ref.to_sdk()?; + let account = self.account_home().account(account_ref)?; + let storage = self.account_storage(&account.label)?; + let coordinate = sticker_ref.pack.coordinate(); + let mut stored = storage.sticker_for_ref( + &coordinate, + &sticker_ref.shortcode, + &sticker_ref.plaintext_sha256, + )?; + if stored.is_none() && storage.sticker_pack(&coordinate)?.is_none() { + // A received sticker may reference a valid pack outside the recent + // discovery window and outside this account's installed list. + // Resolve that exact address on demand, serialized with the other + // account sticker mutations, then authorize the shortcode/hash + // against the newly persisted SQLite projection before touching + // its Blossom URL. + let context = self.sticker_context(account_ref)?; + let mutation_lock = self.sticker_mutation_lock(&context.label); + let _guard = mutation_lock.lock().await; + if storage.sticker_pack(&coordinate)?.is_none() { + fetch_pack_into_storage(self, &context, &coordinate, &[]).await?; + } + stored = storage.sticker_for_ref( + &sticker_ref.pack.coordinate(), + &sticker_ref.shortcode, + &sticker_ref.plaintext_sha256, + )?; + } + let stored = stored.ok_or(AppError::StickerNotFound)?; + let bytes = fetch_blossom_blob_limited( + &stored.url, + MAX_STICKER_ASSET_BYTES, + self.allow_loopback_blob_endpoints(), + ) + .await + .map_err(|_| AppError::StickerRelay("sticker asset download failed".into()))?; + validate_downloaded_sticker(&stored, &bytes)?; + Ok(AppStickerAsset { + sticker: app_sticker_from_stored(sticker_ref.pack.coordinate(), stored), + bytes, + }) + } + + pub(crate) fn authorize_sticker_ref( + &self, + account_ref: &str, + sticker_ref: &AppStickerRef, + ) -> Result<(), AppError> { + let sticker_ref = sticker_ref.to_sdk()?; + let account = self.account_home().account(account_ref)?; + self.account_storage(&account.label)? + .sticker_for_ref( + &sticker_ref.pack.coordinate(), + &sticker_ref.shortcode, + &sticker_ref.plaintext_sha256, + )? + .ok_or(AppError::StickerNotFound) + .map(|_| ()) + } + + /// Refresh recent public packs and this account's installed-list projection + /// from its configured outbox relays. Pending local operations are rebased + /// over the winning remote list and published after the fetch. + pub async fn sync_sticker_packs( + &self, + account_ref: &str, + ) -> Result { + let context = self.sticker_context(account_ref)?; + let mutation_lock = self.sticker_mutation_lock(&context.label); + let _guard = mutation_lock.lock().await; + refresh_installed_base(self, &context).await?; + tolerate_offline(flush_sticker_outbox(self, &context).await)?; + + let filter = Filter::new() + .kind(Kind::Custom(STICKER_PACK_KIND)) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::T), + PACK_FORMAT.to_owned(), + ) + .limit(MAX_DISCOVERY_PACKS); + let events = self + .relay_plane + .fetch_public_events(context.endpoints.clone(), filter) + .await + .map_err(AppError::StickerRelay)?; + let discovered = events.len().min(MAX_DISCOVERY_PACKS); + let updated = ingest_pack_events(&context.storage, events)?; + + let desired = context.storage.desired_installed_sticker_packs()?; + for coordinate in desired.iter().take(MAX_INSTALLED_PACKS) { + if context.storage.sticker_pack(coordinate)?.is_none() { + let _ = fetch_pack_into_storage(self, &context, coordinate, &[]).await; + } + } + publish_pending_installed_list(self, &context).await?; + let installed = context.storage.desired_installed_sticker_packs()?.len(); + let pending_operations = context.storage.sticker_install_operations()?.len(); + Ok(AppStickerSyncResult { + discovered: discovered.try_into().unwrap_or(u32::MAX), + updated: updated.try_into().unwrap_or(u32::MAX), + installed: installed.try_into().unwrap_or(u32::MAX), + pending_operations: pending_operations.try_into().unwrap_or(u32::MAX), + }) + } + + pub async fn fetch_sticker_pack( + &self, + account_ref: &str, + input: &str, + ) -> Result { + let parsed = parse_sticker_pack_input_with_relays(input)?; + let context = self.sticker_context(account_ref)?; + let mutation_lock = self.sticker_mutation_lock(&context.label); + let _guard = mutation_lock.lock().await; + fetch_pack_into_storage(self, &context, &parsed.coordinate, &parsed.relay_hints).await?; + app_pack_for_context(&context, &parsed.coordinate) + } + + pub async fn install_sticker_pack( + &self, + account_ref: &str, + input: &str, + ) -> Result { + let parsed = parse_sticker_pack_input_with_relays(input)?; + let coordinate = &parsed.coordinate; + let context = self.sticker_context(account_ref)?; + let mutation_lock = self.sticker_mutation_lock(&context.label); + let _guard = mutation_lock.lock().await; + rebase_and_flush_sticker_outbox_best_effort(self, &context).await?; + if context.storage.sticker_pack(coordinate)?.is_none() { + fetch_pack_into_storage(self, &context, coordinate, &parsed.relay_hints).await?; + } else { + // A validated cached pack is enough to record an install while + // offline. Refresh opportunistically without making connectivity + // a precondition for the user's local intent. + tolerate_offline( + fetch_pack_into_storage(self, &context, coordinate, &parsed.relay_hints) + .await + .map(|_| ()), + )?; + } + let desired = context.storage.desired_installed_sticker_packs()?; + validate_install_capacity(&desired, coordinate)?; + context + .storage + .enqueue_sticker_install_operation(coordinate, true, unix_now_seconds())?; + tolerate_offline(publish_pending_installed_list(self, &context).await)?; + app_pack_for_context(&context, coordinate) + } + + pub async fn uninstall_sticker_pack( + &self, + account_ref: &str, + input: &str, + ) -> Result<(), AppError> { + let coordinate = parse_sticker_pack_input(input)?; + let context = self.sticker_context(account_ref)?; + let mutation_lock = self.sticker_mutation_lock(&context.label); + let _guard = mutation_lock.lock().await; + rebase_and_flush_sticker_outbox_best_effort(self, &context).await?; + context.storage.enqueue_sticker_install_operation( + &coordinate, + false, + unix_now_seconds(), + )?; + tolerate_offline(publish_pending_installed_list(self, &context).await) + } + + /// Import/decrypt a Signal pack with `sonar-stickers`, validate every + /// plaintext image, upload content-addressed public bytes to Blossom, then + /// publish and install the Sonar pack. The link is zeroized by this wrapper + /// and is never persisted. External-signing accounts are explicitly gated: + /// Blossom requires one authorization signature per asset, which would + /// otherwise trigger up to 200 Amber prompts. + pub async fn import_signal_sticker_pack( + &self, + account_ref: &str, + signal_link: String, + blossom_server: Option<&str>, + ) -> Result { + let signal_link = Zeroizing::new(signal_link); + let signal_pack_id = validate_signal_sticker_link(signal_link.as_str())?; + let context = self.sticker_context(account_ref)?; + if !context.local_signing { + return Err(AppError::StickerExternalSignerImportUnsupported); + } + let mutation_lock = self.sticker_mutation_lock(&context.label); + let _guard = mutation_lock.lock().await; + rebase_and_flush_sticker_outbox_best_effort(self, &context).await?; + let anticipated_coordinate = PackAddress::new( + context.account_id_hex.clone(), + format!("signal-{signal_pack_id}"), + ) + .map_err(|_| invalid_sticker("imported pack address is invalid"))? + .coordinate(); + validate_install_capacity( + &context.storage.desired_installed_sticker_packs()?, + &anticipated_coordinate, + )?; + + let imported = sonar_stickers::signal::import_signal_pack(signal_link.as_str()) + .await + .map_err(|_| AppError::StickerImport("Signal pack could not be imported".into()))?; + if imported.pack_id != signal_pack_id { + return Err(invalid_sticker( + "Signal pack identity changed during import", + )); + } + let server = blossom_server + .map(str::trim) + .filter(|server| !server.is_empty()) + .unwrap_or(DEFAULT_STICKER_BLOSSOM_SERVER_URL); + let signer = context.signer.as_nostr_signer(); + let mut stickers_by_id = HashMap::new(); + let mut stickers = Vec::with_capacity(imported.stickers.len()); + for imported_sticker in &imported.stickers { + validate_sticker_asset_size(&imported_sticker.bytes)?; + let inspected = inspect_image(&imported_sticker.bytes)?; + if sha256_hex(&imported_sticker.bytes) != imported_sticker.sha256 { + return Err(invalid_sticker("Signal sticker hash mismatch")); + } + let url = upload_blossom_blob( + server, + &imported_sticker.bytes, + &imported_sticker.sha256, + signer.as_ref(), + self.allow_loopback_blob_endpoints(), + ) + .await + .map_err(|_| AppError::StickerImport("sticker asset upload failed".into()))?; + let sticker = Sticker::new( + imported_sticker.shortcode.clone(), + url, + imported_sticker.sha256.clone(), + inspected.mime, + Some(inspected.width), + Some(inspected.height), + imported_sticker + .emoji + .as_ref() + .map(|emoji| format!("{emoji} sticker")), + imported_sticker.emoji.clone(), + ) + .map_err(|_| invalid_sticker("imported sticker metadata is invalid"))?; + stickers_by_id.insert(imported_sticker.id, sticker.clone()); + stickers.push(sticker); + } + let cover = imported + .cover + .as_ref() + .and_then(|cover| stickers_by_id.get(&cover.id)) + .filter(|sticker| sticker.mime == "image/webp") + .cloned(); + let address = PackAddress::new( + context.account_id_hex.clone(), + format!("signal-{}", imported.pack_id), + ) + .map_err(|_| invalid_sticker("imported pack address is invalid"))?; + let description = imported + .author + .as_deref() + .map(|author| truncate_chars(author.trim(), 500)) + .filter(|author| !author.is_empty()); + let pack = StickerPack::new( + address, + truncate_chars(imported.title.trim(), 80), + description, + cover, + stickers, + None, + ) + .map_err(|_| invalid_sticker("imported sticker pack is invalid"))?; + let coordinate = pack.address.coordinate(); + let created_at = next_pack_publication_timestamp(&context.storage, &coordinate)?; + let event = sign_public_event( + &context, + STICKER_PACK_KIND, + build_pack_tags(&pack), + created_at, + ) + .await?; + publish_outboxed_event(self, &context, &event).await?; + // The newly published pack is already durable locally. A transient + // relay read must not prevent recording the user's install intent; + // the same pending-operation projection used by normal installs will + // reconcile it on the next sync. + tolerate_offline(refresh_installed_base(self, &context).await)?; + let desired = context.storage.desired_installed_sticker_packs()?; + validate_install_capacity(&desired, &coordinate)?; + context + .storage + .enqueue_sticker_install_operation(&coordinate, true, unix_now_seconds())?; + tolerate_offline(publish_pending_installed_list(self, &context).await)?; + Ok(AppStickerImportResult { + pack: app_pack_for_context(&context, &coordinate)?, + skipped_signal_sticker_ids: imported.skipped_sticker_ids, + }) + } + + fn sticker_mutation_lock(&self, account_label: &str) -> Arc> { + self.sticker_mutation_locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .entry(account_label.to_owned()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() + } + + fn sticker_context(&self, account_ref: &str) -> Result { + let account = self.account_home().account(account_ref)?; + let relay_lists = self.account_relay_list_status_for_account_id(&account.account_id_hex)?; + let endpoints = self.key_package_endpoints(&relay_lists); + if endpoints.is_empty() { + return Err(AppError::StickerRelay( + "no account relays configured".into(), + )); + } + let signer = self.account_signer_for_summary(&account)?; + Ok(StickerAccountContext { + label: account.label.clone(), + account_id_hex: account.account_id_hex, + local_signing: account.local_signing, + storage: self.account_storage(&account.label)?, + endpoints, + signer, + }) + } +} + +async fn fetch_pack_into_storage( + app: &MarmotApp, + context: &StickerAccountContext, + coordinate: &str, + relay_hints: &[TransportEndpoint], +) -> Result { + let address = PackAddress::parse(coordinate) + .map_err(|_| invalid_sticker("invalid sticker pack coordinate"))?; + let author = PublicKey::parse(&address.author_pubkey_hex) + .map_err(|_| invalid_sticker("invalid sticker pack author"))?; + let filter = Filter::new() + .author(author) + .kind(Kind::Custom(STICKER_PACK_KIND)) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::D), + address.identifier.clone(), + ) + .limit(16); + if !relay_hints.is_empty() { + match fetch_pack_from_endpoints( + app, + context, + coordinate, + relay_hints.to_vec(), + filter.clone(), + ) + .await + { + Ok((updated, true)) => return Ok(updated), + Ok((_, false)) | Err(AppError::StickerRelay(_)) => {} + Err(error) => return Err(error), + } + } + let (updated, _) = + fetch_pack_from_endpoints(app, context, coordinate, context.endpoints.clone(), filter) + .await?; + if context.storage.sticker_pack(coordinate)?.is_none() { + return Err(AppError::StickerNotFound); + } + Ok(updated) +} + +async fn fetch_pack_from_endpoints( + app: &MarmotApp, + context: &StickerAccountContext, + coordinate: &str, + endpoints: Vec, + filter: Filter, +) -> Result<(bool, bool), AppError> { + let events = app + .relay_plane + .fetch_public_events(endpoints, filter) + .await + .map_err(AppError::StickerRelay)?; + let mut updated = false; + let mut found = false; + for event in events { + let stored = match stored_pack_from_event(&event) { + Ok(pack) if pack.coordinate == coordinate => pack, + _ => continue, + }; + found = true; + updated |= context.storage.replace_sticker_pack_if_newer(&stored)?; + } + Ok((updated, found)) +} + +fn ingest_pack_events( + storage: &SqliteAccountStorage, + events: Vec, +) -> Result { + let mut updated = 0_usize; + let mut seen = HashSet::new(); + for event in events.into_iter().take(MAX_DISCOVERY_PACKS * 2) { + let stored = match stored_pack_from_event(&event) { + Ok(pack) => pack, + Err(_) => continue, + }; + if !seen.insert(( + stored.coordinate.clone(), + stored.version.event_id_hex.clone(), + )) { + continue; + } + if storage.replace_sticker_pack_if_newer(&stored)? { + updated += 1; + } + } + Ok(updated) +} + +/// Refresh the remote installed-list winner before flushing any locally +/// outboxed kind-10031 event. If the account is offline, leave both outbox and +/// operations untouched so the next successful refresh can rebase first. +async fn rebase_and_flush_sticker_outbox_best_effort( + app: &MarmotApp, + context: &StickerAccountContext, +) -> Result<(), AppError> { + match refresh_installed_base(app, context).await { + Ok(()) => tolerate_offline(flush_sticker_outbox(app, context).await), + Err(AppError::StickerRelay(_)) => Ok(()), + Err(error) => Err(error), + } +} + +fn validate_install_capacity(desired: &[String], coordinate: &str) -> Result<(), AppError> { + if desired.len() >= MAX_INSTALLED_PACKS && !desired.iter().any(|pack| pack == coordinate) { + return Err(invalid_sticker("too many installed sticker packs")); + } + Ok(()) +} + +async fn refresh_installed_base( + app: &MarmotApp, + context: &StickerAccountContext, +) -> Result<(), AppError> { + let author = PublicKey::parse(&context.account_id_hex) + .map_err(|_| invalid_sticker("invalid account identity"))?; + let filter = Filter::new() + .author(author) + .kind(Kind::Custom(USER_STICKER_PACKS_KIND)) + .limit(16); + let mut candidates = app + .relay_plane + .fetch_public_events(context.endpoints.clone(), filter) + .await + .map_err(AppError::StickerRelay)? + .into_iter() + .filter(|event| { + event.pubkey.to_hex() == context.account_id_hex + && event.kind == Kind::Custom(USER_STICKER_PACKS_KIND) + && event.verify().is_ok() + && event.created_at.as_secs() + <= unix_now_seconds().saturating_add(MAX_FUTURE_EVENT_SKEW_SECONDS) + }) + .filter_map(|event| { + let list = parse_installed_pack_list(&event).ok()?; + (list.packs.len() <= MAX_INSTALLED_PACKS).then_some((event, list)) + }) + .collect::>(); + candidates.sort_by(|(left, _), (right, _)| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + if let Some((event, list)) = candidates.into_iter().next() { + let version = StoredStickerPackVersion { + event_id_hex: event.id.to_hex(), + created_at: event.created_at.as_secs(), + }; + let packs = list + .packs + .into_iter() + .map(|address| address.coordinate()) + .collect::>(); + context + .storage + .replace_installed_sticker_packs_if_newer(&version, &packs)?; + } + Ok(()) +} + +async fn publish_pending_installed_list( + app: &MarmotApp, + context: &StickerAccountContext, +) -> Result<(), AppError> { + if context.storage.sticker_install_operations()?.is_empty() { + return Ok(()); + } + context + .storage + .trim_sticker_install_operations_to_capacity(MAX_INSTALLED_PACKS)?; + if context.storage.sticker_install_operations()?.is_empty() { + return Ok(()); + } + let desired = context.storage.desired_installed_sticker_packs()?; + if desired.len() > MAX_INSTALLED_PACKS { + return Err(invalid_sticker("too many installed sticker packs")); + } + let addresses = desired + .iter() + .map(|coordinate| { + PackAddress::parse(coordinate) + .map_err(|_| invalid_sticker("installed sticker address is invalid")) + }) + .collect::, _>>()?; + let event = sign_public_event( + context, + USER_STICKER_PACKS_KIND, + build_installed_packs_tags(&InstalledPackList::new(addresses)), + next_installed_list_publication_timestamp(&context.storage)?, + ) + .await?; + publish_outboxed_event(app, context, &event).await +} + +fn next_installed_list_publication_timestamp( + storage: &SqliteAccountStorage, +) -> Result { + let now = unix_now_seconds(); + let installed_created_at = storage + .installed_sticker_state()? + .version + .map(|version| version.created_at); + let outbox_created_at = storage + .sticker_outbox_events()? + .into_iter() + .filter(|event| event.kind == u64::from(USER_STICKER_PACKS_KIND)) + .map(|event| event.created_at) + .max(); + let latest = installed_created_at + .into_iter() + .chain(outbox_created_at) + .max(); + next_monotonic_publication_timestamp( + now, + latest, + "installed-list timestamp is too far in the future", + ) +} + +fn next_pack_publication_timestamp( + storage: &SqliteAccountStorage, + coordinate: &str, +) -> Result { + let now = unix_now_seconds(); + let stored_created_at = storage + .sticker_pack(coordinate)? + .map(|pack| pack.version.created_at); + let mut outbox_created_at: Option = None; + for pending in storage + .sticker_outbox_events()? + .into_iter() + .filter(|event| event.kind == u64::from(STICKER_PACK_KIND)) + { + let Ok(event) = Event::from_json(&pending.event_json) else { + continue; + }; + let Ok(pack) = parse_pack_event(&event) else { + continue; + }; + if pack.address.coordinate() == coordinate { + outbox_created_at = Some( + outbox_created_at + .unwrap_or_default() + .max(event.created_at.as_secs()), + ); + } + } + next_monotonic_publication_timestamp( + now, + stored_created_at.into_iter().chain(outbox_created_at).max(), + "sticker-pack timestamp is too far in the future", + ) +} + +fn next_monotonic_publication_timestamp( + now: u64, + latest: Option, + future_error: &'static str, +) -> Result { + let Some(latest) = latest else { + return Ok(now); + }; + let next = now.max(latest.saturating_add(1)); + if next > now.saturating_add(MAX_FUTURE_EVENT_SKEW_SECONDS) { + return Err(invalid_sticker(future_error)); + } + Ok(next) +} + +async fn sign_public_event( + context: &StickerAccountContext, + kind: u16, + tags: Vec, + created_at: u64, +) -> Result { + let signer = context.signer.as_nostr_signer(); + let public_key = signer + .get_public_key() + .await + .map_err(|error| crate::external_signer_error(error, "sticker public key"))?; + if public_key.to_hex() != context.account_id_hex { + return Err(AppError::ExternalSignerMismatch); + } + let unsigned = EventBuilder::new(Kind::Custom(kind), "") + .tags(tags) + .custom_created_at(NostrTimestamp::from_secs(created_at)) + .build(public_key); + signer + .sign_event(unsigned) + .await + .map_err(|error| crate::external_signer_error(error, "sticker event")) +} + +async fn publish_outboxed_event( + app: &MarmotApp, + context: &StickerAccountContext, + event: &Event, +) -> Result<(), AppError> { + if event.kind == Kind::Custom(USER_STICKER_PACKS_KIND) { + // A newer installed-list event includes every pending operation. Drop + // older unsent variants so publishing one later can never clear local + // intent that it did not contain. A crash between these deletes and + // the insert is safe: operations remain and sync regenerates the event. + for pending in context.storage.sticker_outbox_events()? { + if pending.kind == u64::from(USER_STICKER_PACKS_KIND) + && pending.event_id_hex != event.id.to_hex() + { + context + .storage + .clear_sticker_outbox_event(&pending.event_id_hex)?; + } + } + } + context + .storage + .put_sticker_outbox_event(&StoredStickerOutboxEvent { + event_id_hex: event.id.to_hex(), + kind: u64::from(event.kind.as_u16()), + event_json: event.as_json(), + created_at: event.created_at.as_secs(), + })?; + app.relay_plane + .publish_public_event(context.endpoints.clone(), event) + .await + .map_err(AppError::StickerRelay)?; + apply_published_sticker_event(&context.storage, event)?; + context + .storage + .clear_sticker_outbox_event(&event.id.to_hex())?; + Ok(()) +} + +async fn flush_sticker_outbox( + app: &MarmotApp, + context: &StickerAccountContext, +) -> Result<(), AppError> { + // Kind-10031 outbox events are snapshots of an older remote base plus the + // still-persisted local operations. Never replay that snapshot verbatim + // after a refresh: even a lower-timestamp remote winner may contain packs + // that were unknown when the snapshot was signed. Keep the operations and + // let `publish_pending_installed_list` re-sign the freshly rebased desired + // list. Pack events are self-contained and remain safe to replay. + discard_outboxed_installed_lists(&context.storage)?; + for pending in context.storage.sticker_outbox_events()? { + let event = Event::from_json(&pending.event_json) + .map_err(|_| invalid_sticker("stored sticker publication is invalid"))?; + event + .verify() + .map_err(|_| invalid_sticker("stored sticker publication signature is invalid"))?; + app.relay_plane + .publish_public_event(context.endpoints.clone(), &event) + .await + .map_err(AppError::StickerRelay)?; + apply_published_sticker_event(&context.storage, &event)?; + context + .storage + .clear_sticker_outbox_event(&pending.event_id_hex)?; + } + Ok(()) +} + +fn discard_outboxed_installed_lists(storage: &SqliteAccountStorage) -> Result<(), AppError> { + for pending in storage.sticker_outbox_events()? { + if pending.kind == u64::from(USER_STICKER_PACKS_KIND) { + storage.clear_sticker_outbox_event(&pending.event_id_hex)?; + } + } + Ok(()) +} + +fn apply_published_sticker_event( + storage: &SqliteAccountStorage, + event: &Event, +) -> Result<(), AppError> { + match event.kind { + Kind::Custom(STICKER_PACK_KIND) => { + let pack = stored_pack_from_event(event)?; + storage.replace_sticker_pack_if_newer(&pack)?; + } + Kind::Custom(USER_STICKER_PACKS_KIND) => { + let list = parse_installed_pack_list(event) + .map_err(|_| invalid_sticker("installed sticker list is invalid"))?; + if list.packs.len() > MAX_INSTALLED_PACKS { + return Err(invalid_sticker("too many installed sticker packs")); + } + let version = StoredStickerPackVersion { + event_id_hex: event.id.to_hex(), + created_at: event.created_at.as_secs(), + }; + let packs = list + .packs + .into_iter() + .map(|address| address.coordinate()) + .collect::>(); + storage.commit_installed_sticker_publication(&version, &packs)?; + } + _ => return Err(invalid_sticker("unsupported sticker outbox event")), + } + Ok(()) +} + +fn app_pack_for_context( + context: &StickerAccountContext, + coordinate: &str, +) -> Result { + let installed = context + .storage + .desired_installed_sticker_packs()? + .into_iter() + .collect::>(); + context + .storage + .sticker_pack(coordinate)? + .ok_or(AppError::StickerNotFound) + .and_then(|pack| app_pack_from_stored(pack, &installed)) +} + +fn truncate_chars(value: &str, max: usize) -> String { + value.chars().take(max).collect() +} + +fn tolerate_offline(result: Result<(), AppError>) -> Result<(), AppError> { + match result { + Ok(()) | Err(AppError::StickerRelay(_)) => Ok(()), + Err(error) => Err(error), + } +} + +struct StickerAccountContext { + label: String, + account_id_hex: String, + local_signing: bool, + storage: SqliteAccountStorage, + endpoints: Vec, + signer: AccountSigner, +} + +fn app_pack_from_stored( + pack: StoredStickerPack, + installed: &HashSet, +) -> Result { + let coordinate = pack.coordinate.clone(); + Ok(AppStickerPack { + installed: installed.contains(&coordinate), + coordinate: coordinate.clone(), + author_pubkey_hex: pack.author_pubkey_hex, + identifier: pack.identifier, + event_id_hex: pack.version.event_id_hex, + created_at: pack.version.created_at, + title: pack.title, + description: pack.description, + cover: pack + .cover + .map(|sticker| app_sticker_from_stored(coordinate.clone(), sticker)), + stickers: pack + .stickers + .into_iter() + .map(|sticker| app_sticker_from_stored(coordinate.clone(), sticker)) + .collect(), + license: pack.license, + }) +} + +fn app_sticker_from_stored(pack_coordinate: String, sticker: StoredSticker) -> AppSticker { + AppSticker { + pack_coordinate, + shortcode: sticker.shortcode, + url: sticker.url, + sha256: sticker.sha256, + mime: sticker.mime, + width: sticker.width, + height: sticker.height, + alt: sticker.alt, + emoji: sticker.emoji, + } +} + +fn stored_sticker(sticker: Sticker) -> StoredSticker { + StoredSticker { + shortcode: sticker.shortcode, + url: sticker.url, + sha256: sticker.sha256, + mime: sticker.mime, + width: sticker.width, + height: sticker.height, + alt: sticker.alt, + emoji: sticker.emoji, + } +} + +fn stored_pack_from_event(event: &Event) -> Result { + event + .verify() + .map_err(|_| invalid_sticker("sticker pack signature verification failed"))?; + if event.created_at.as_secs() > unix_now_seconds().saturating_add(MAX_FUTURE_EVENT_SKEW_SECONDS) + { + return Err(invalid_sticker( + "sticker pack timestamp is too far in the future", + )); + } + let pack = + parse_pack_event(event).map_err(|_| invalid_sticker("sticker pack metadata is invalid"))?; + let coordinate = pack.address.coordinate(); + Ok(StoredStickerPack { + coordinate, + author_pubkey_hex: pack.address.author_pubkey_hex, + identifier: pack.address.identifier, + version: StoredStickerPackVersion { + event_id_hex: event.id.to_hex(), + created_at: event.created_at.as_secs(), + }, + title: pack.title, + description: pack.description, + cover: pack.cover.map(stored_sticker), + stickers: pack.stickers.into_iter().map(stored_sticker).collect(), + license: pack.license, + }) +} + +fn invalid_sticker(reason: &'static str) -> AppError { + AppError::InvalidSticker(reason.to_owned()) +} + +fn validate_downloaded_sticker(sticker: &StoredSticker, bytes: &[u8]) -> Result<(), AppError> { + validate_sticker_asset_size(bytes)?; + if sha256_hex(bytes) != sticker.sha256 { + return Err(invalid_sticker("sticker asset hash mismatch")); + } + let inspected = inspect_image(bytes)?; + if inspected.mime != sticker.mime { + return Err(invalid_sticker("sticker asset MIME mismatch")); + } + if sticker.width.is_some_and(|width| width != inspected.width) + || sticker + .height + .is_some_and(|height| height != inspected.height) + { + return Err(invalid_sticker("sticker asset dimensions mismatch")); + } + Ok(()) +} + +fn validate_sticker_asset_size(bytes: &[u8]) -> Result<(), AppError> { + if bytes.is_empty() || bytes.len() as u64 > MAX_STICKER_ASSET_BYTES { + return Err(invalid_sticker("sticker asset size is invalid")); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct InspectedImage { + mime: &'static str, + width: u32, + height: u32, + frames: u32, +} + +fn inspect_image(bytes: &[u8]) -> Result { + let inspected = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + inspect_png(bytes)? + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + inspect_gif(bytes)? + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + inspect_webp(bytes)? + } else { + return Err(invalid_sticker("unsupported sticker image format")); + }; + if inspected.width == 0 + || inspected.height == 0 + || inspected.width > MAX_STICKER_DIMENSION + || inspected.height > MAX_STICKER_DIMENSION + || u64::from(inspected.width) * u64::from(inspected.height) > MAX_STICKER_PIXELS + { + return Err(invalid_sticker("sticker image dimensions exceed limits")); + } + if inspected.frames == 0 || inspected.frames > MAX_STICKER_ANIMATION_FRAMES { + return Err(invalid_sticker( + "sticker animation frame count exceeds limits", + )); + } + Ok(inspected) +} + +fn inspect_png(bytes: &[u8]) -> Result { + if bytes.len() < 33 || bytes.get(12..16) != Some(b"IHDR") { + return Err(invalid_sticker("invalid PNG sticker")); + } + let width = be_u32(bytes, 16)?; + let height = be_u32(bytes, 20)?; + let mut offset = 8_usize; + let mut declared_frames = None; + let mut frame_controls = 0_u32; + let mut saw_iend = false; + while offset.checked_add(12).is_some_and(|end| end <= bytes.len()) { + let length = be_u32(bytes, offset)? as usize; + let chunk_end = offset + .checked_add(12) + .and_then(|base| base.checked_add(length)) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| invalid_sticker("invalid PNG chunk length"))?; + let kind = &bytes[offset + 4..offset + 8]; + if kind == b"acTL" { + if length != 8 || declared_frames.is_some() { + return Err(invalid_sticker("invalid APNG animation header")); + } + let frames = be_u32(bytes, offset + 8)?; + if frames == 0 || frames > MAX_STICKER_ANIMATION_FRAMES { + return Err(invalid_sticker( + "sticker animation frame count exceeds limits", + )); + } + declared_frames = Some(frames); + } else if kind == b"fcTL" { + if length != 26 || declared_frames.is_none() { + return Err(invalid_sticker("invalid APNG frame control")); + } + let frame_width = be_u32(bytes, offset + 12)?; + let frame_height = be_u32(bytes, offset + 16)?; + let frame_x = be_u32(bytes, offset + 20)?; + let frame_y = be_u32(bytes, offset + 24)?; + if frame_width == 0 + || frame_height == 0 + || frame_width > MAX_STICKER_DIMENSION + || frame_height > MAX_STICKER_DIMENSION + || frame_x + .checked_add(frame_width) + .is_none_or(|right| right > width) + || frame_y + .checked_add(frame_height) + .is_none_or(|bottom| bottom > height) + { + return Err(invalid_sticker("APNG frame dimensions exceed canvas")); + } + frame_controls = frame_controls + .checked_add(1) + .ok_or_else(|| invalid_sticker("sticker animation frame count exceeds limits"))?; + if frame_controls > MAX_STICKER_ANIMATION_FRAMES { + return Err(invalid_sticker( + "sticker animation frame count exceeds limits", + )); + } + } + offset = chunk_end; + if kind == b"IEND" { + saw_iend = true; + break; + } + } + if !saw_iend { + return Err(invalid_sticker("PNG sticker is truncated")); + } + let frames = match declared_frames { + Some(declared) if declared == frame_controls => declared, + Some(_) => return Err(invalid_sticker("APNG frame count mismatch")), + None if frame_controls == 0 => 1, + None => return Err(invalid_sticker("invalid APNG frame control")), + }; + Ok(InspectedImage { + mime: if declared_frames.is_some() { + "image/apng" + } else { + "image/png" + }, + width, + height, + frames, + }) +} + +fn inspect_gif(bytes: &[u8]) -> Result { + if bytes.len() < 14 { + return Err(invalid_sticker("invalid GIF sticker")); + } + let width = le_u16(bytes, 6)? as u32; + let height = le_u16(bytes, 8)? as u32; + let packed = bytes[10]; + let mut offset = 13_usize; + if packed & 0x80 != 0 { + let table = 3_usize + .checked_mul(1_usize << (usize::from(packed & 0x07) + 1)) + .ok_or_else(|| invalid_sticker("invalid GIF color table"))?; + offset = offset + .checked_add(table) + .filter(|offset| *offset <= bytes.len()) + .ok_or_else(|| invalid_sticker("truncated GIF color table"))?; + } + let mut frames = 0_u32; + let mut saw_trailer = false; + while offset < bytes.len() { + match bytes[offset] { + 0x2c => { + frames = frames.saturating_add(1); + if frames > MAX_STICKER_ANIMATION_FRAMES { + break; + } + if offset + 10 > bytes.len() { + return Err(invalid_sticker("truncated GIF image descriptor")); + } + let image_packed = bytes[offset + 9]; + offset += 10; + if image_packed & 0x80 != 0 { + let table = 3_usize + .checked_mul(1_usize << (usize::from(image_packed & 0x07) + 1)) + .ok_or_else(|| invalid_sticker("invalid GIF local color table"))?; + offset = offset + .checked_add(table) + .filter(|offset| *offset <= bytes.len()) + .ok_or_else(|| invalid_sticker("truncated GIF local color table"))?; + } + if offset >= bytes.len() { + return Err(invalid_sticker("truncated GIF image data")); + } + offset += 1; + offset = skip_gif_sub_blocks(bytes, offset)?; + } + 0x21 => { + if offset + 2 > bytes.len() { + return Err(invalid_sticker("truncated GIF extension")); + } + offset = skip_gif_sub_blocks(bytes, offset + 2)?; + } + 0x3b => { + saw_trailer = true; + break; + } + _ => return Err(invalid_sticker("invalid GIF block")), + } + } + if !saw_trailer || frames == 0 { + return Err(invalid_sticker("GIF sticker is incomplete")); + } + Ok(InspectedImage { + mime: "image/gif", + width, + height, + frames, + }) +} + +fn skip_gif_sub_blocks(bytes: &[u8], mut offset: usize) -> Result { + loop { + let length = *bytes + .get(offset) + .ok_or_else(|| invalid_sticker("truncated GIF sub-block"))? + as usize; + offset += 1; + if length == 0 { + return Ok(offset); + } + offset = offset + .checked_add(length) + .filter(|offset| *offset <= bytes.len()) + .ok_or_else(|| invalid_sticker("truncated GIF sub-block"))?; + } +} + +fn inspect_webp(bytes: &[u8]) -> Result { + if bytes.len() < 20 { + return Err(invalid_sticker("invalid WebP sticker")); + } + let riff_len = le_u32(bytes, 4)? as usize; + if riff_len + .checked_add(8) + .is_none_or(|length| length > bytes.len()) + { + return Err(invalid_sticker("truncated WebP sticker")); + } + let mut offset = 12_usize; + let mut dimensions = None; + let mut frames = 0_u32; + while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) { + let kind = &bytes[offset..offset + 4]; + let length = le_u32(bytes, offset + 4)? as usize; + let data = offset + 8; + let end = data + .checked_add(length) + .filter(|end| *end <= bytes.len()) + .ok_or_else(|| invalid_sticker("invalid WebP chunk length"))?; + match kind { + b"VP8X" if length >= 10 => { + dimensions = Some(( + le_u24(bytes, data + 4)?.saturating_add(1), + le_u24(bytes, data + 7)?.saturating_add(1), + )); + } + b"VP8L" if length >= 5 && bytes[data] == 0x2f => { + let bits = le_u32(bytes, data + 1)?; + dimensions = Some(((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1)); + } + b"VP8 " if length >= 10 && bytes.get(data + 3..data + 6) == Some(b"\x9d\x01\x2a") => { + dimensions = Some(( + u32::from(le_u16(bytes, data + 6)? & 0x3fff), + u32::from(le_u16(bytes, data + 8)? & 0x3fff), + )); + } + b"ANMF" => frames = frames.saturating_add(1), + _ => {} + } + offset = end + (length & 1); + } + let (width, height) = dimensions.ok_or_else(|| invalid_sticker("WebP dimensions missing"))?; + Ok(InspectedImage { + mime: "image/webp", + width, + height, + frames: frames.max(1), + }) +} + +fn be_u32(bytes: &[u8], offset: usize) -> Result { + bytes + .get(offset..offset + 4) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_be_bytes) + .ok_or_else(|| invalid_sticker("truncated image header")) +} + +fn le_u32(bytes: &[u8], offset: usize) -> Result { + bytes + .get(offset..offset + 4) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .ok_or_else(|| invalid_sticker("truncated image header")) +} + +fn le_u24(bytes: &[u8], offset: usize) -> Result { + let bytes = bytes + .get(offset..offset + 3) + .ok_or_else(|| invalid_sticker("truncated image header"))?; + Ok(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16)) +} + +fn le_u16(bytes: &[u8], offset: usize) -> Result { + bytes + .get(offset..offset + 2) + .and_then(|bytes| bytes.try_into().ok()) + .map(u16::from_le_bytes) + .ok_or_else(|| invalid_sticker("truncated image header")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn coordinate() -> String { + format!("30031:{}:cats", "ab".repeat(32)) + } + + fn chunk(kind: &[u8; 4], payload: &[u8]) -> Vec { + let mut chunk = Vec::new(); + chunk.extend_from_slice(&(payload.len() as u32).to_be_bytes()); + chunk.extend_from_slice(kind); + chunk.extend_from_slice(payload); + chunk.extend_from_slice(&[0; 4]); + chunk + } + + fn png_with_frames( + width: u32, + height: u32, + declared_frames: Option, + frames: &[(u32, u32, u32, u32)], + ) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); + bytes.extend(chunk(b"IHDR", &ihdr)); + if let Some(frames) = declared_frames { + let mut actl = Vec::new(); + actl.extend_from_slice(&frames.to_be_bytes()); + actl.extend_from_slice(&0_u32.to_be_bytes()); + bytes.extend(chunk(b"acTL", &actl)); + } + for (sequence, (frame_width, frame_height, frame_x, frame_y)) in frames.iter().enumerate() { + let mut frame_control = Vec::with_capacity(26); + frame_control.extend_from_slice(&(sequence as u32).to_be_bytes()); + frame_control.extend_from_slice(&frame_width.to_be_bytes()); + frame_control.extend_from_slice(&frame_height.to_be_bytes()); + frame_control.extend_from_slice(&frame_x.to_be_bytes()); + frame_control.extend_from_slice(&frame_y.to_be_bytes()); + frame_control.extend_from_slice(&[0; 6]); + bytes.extend(chunk(b"fcTL", &frame_control)); + } + bytes.extend(chunk(b"IEND", &[])); + bytes + } + + fn png(width: u32, height: u32, animation_frames: Option) -> Vec { + let frames = vec![(width, height, 0, 0); animation_frames.unwrap_or_default() as usize]; + png_with_frames(width, height, animation_frames, &frames) + } + + #[test] + fn sticker_tag_roundtrips_and_ambiguous_tags_are_rejected() { + let sticker_ref = AppStickerRef { + pack_coordinate: coordinate(), + shortcode: "wave".to_owned(), + plaintext_sha256: "11".repeat(32), + }; + let tag = sticker_ref_tag(&sticker_ref).unwrap(); + assert_eq!( + sticker_ref_from_tags(9, std::slice::from_ref(&tag)), + Some(sticker_ref) + ); + assert!(sticker_ref_from_tags(1, std::slice::from_ref(&tag)).is_none()); + assert!(sticker_ref_from_tags(9, &[tag, vec!["sticker".to_owned()]]).is_none()); + } + + #[test] + fn pack_input_accepts_only_canonical_and_trusted_sonar_links() { + let coordinate = coordinate(); + assert_eq!(parse_sticker_pack_input(&coordinate).unwrap(), coordinate); + let link = format!( + "https://sonarprivacy.xyz/stickers?a={coordinate}\ + &relay=wss%3A%2F%2Frelay.damus.io\ + &relay=wss%3A%2F%2Fnos.lol\ + &relay=wss%3A%2F%2Fnos.lol\ + &relay=not-a-relay" + ); + assert_eq!(parse_sticker_pack_input(&link).unwrap(), coordinate); + assert_eq!( + parse_sticker_pack_input_with_relays(&link) + .unwrap() + .relay_hints, + vec![ + TransportEndpoint("wss://relay.damus.io".to_owned()), + TransportEndpoint("wss://nos.lol".to_owned()), + ] + ); + assert!( + parse_sticker_pack_input(&format!("https://evil.test/stickers?a={coordinate}")) + .is_err() + ); + assert!( + parse_sticker_pack_input(&format!( + "https://attacker@sonarprivacy.xyz/stickers?a={coordinate}" + )) + .is_err() + ); + assert!(parse_sticker_pack_input("http://sonarprivacy.xyz/stickers").is_err()); + } + + #[test] + fn future_pack_event_is_rejected_before_it_can_freeze_replacement_state() { + let keys = nostr::Keys::generate(); + let pack = StickerPack::new( + PackAddress::new(keys.public_key().to_hex(), "cats".to_owned()).unwrap(), + "Cats".to_owned(), + None, + None, + vec![ + Sticker::new( + "wave".to_owned(), + format!("https://cdn.example/{}.png", "11".repeat(32)), + "11".repeat(32), + "image/png".to_owned(), + Some(32), + Some(32), + None, + None, + ) + .unwrap(), + ], + None, + ) + .unwrap(); + let event = EventBuilder::new(Kind::Custom(STICKER_PACK_KIND), "") + .tags(build_pack_tags(&pack)) + .custom_created_at(NostrTimestamp::from_secs( + unix_now_seconds() + MAX_FUTURE_EVENT_SKEW_SECONDS + 1, + )) + .sign_with_keys(&keys) + .unwrap(); + + assert!(stored_pack_from_event(&event).is_err()); + } + + #[test] + fn signal_import_only_accepts_the_canonical_https_origin() { + assert!( + validate_signal_sticker_link( + "https://signal.art/addstickers/#pack_id=0123456789abcdef0123456789abcdef&pack_key=def" + ) + .is_ok() + ); + assert!( + validate_signal_sticker_link( + "https://signal.art/addstickers?pack_id=0123456789abcdef0123456789abcdef&pack_key=def" + ) + .is_ok() + ); + assert!( + validate_signal_sticker_link( + "https://evil.test/addstickers/#pack_id=0123456789abcdef0123456789abcdef&pack_key=def" + ) + .is_err() + ); + assert!( + validate_signal_sticker_link( + "http://signal.art/addstickers/#pack_id=0123456789abcdef0123456789abcdef&pack_key=def" + ) + .is_err() + ); + assert!( + validate_signal_sticker_link( + "https://signal.art:8443/addstickers/#pack_id=0123456789abcdef0123456789abcdef&pack_key=def" + ) + .is_err() + ); + assert!( + validate_signal_sticker_link( + "https://signal.art/addstickers/#pack_id=0123456789abcdef0123456789abcdef" + ) + .is_err() + ); + assert!( + validate_signal_sticker_link( + "https://signal.art/addstickers/#pack_id=abc&pack_key=def" + ) + .is_err() + ); + } + + #[test] + fn install_capacity_rejects_only_a_new_pack_at_the_limit() { + let installed = (0..MAX_INSTALLED_PACKS) + .map(|index| format!("pack-{index}")) + .collect::>(); + assert!(validate_install_capacity(&installed, "pack-new").is_err()); + assert!(validate_install_capacity(&installed, "pack-0").is_ok()); + } + + #[test] + fn outboxed_installed_snapshot_is_discarded_without_clearing_rebase_operations() { + let storage = SqliteAccountStorage::in_memory().unwrap(); + storage + .enqueue_sticker_install_operation("pack-b", true, 6) + .unwrap(); + storage + .put_sticker_outbox_event(&StoredStickerOutboxEvent { + event_id_hex: "ef".repeat(32), + kind: u64::from(USER_STICKER_PACKS_KIND), + event_json: "{}".to_owned(), + created_at: 7, + }) + .unwrap(); + + discard_outboxed_installed_lists(&storage).unwrap(); + + assert!(storage.sticker_outbox_events().unwrap().is_empty()); + assert_eq!(storage.sticker_install_operations().unwrap().len(), 1); + assert_eq!( + storage.desired_installed_sticker_packs().unwrap(), + vec!["pack-b".to_owned()] + ); + } + + #[test] + fn pack_republication_timestamp_advances_past_same_second_version() { + let storage = SqliteAccountStorage::in_memory().unwrap(); + let created_at = unix_now_seconds(); + let coordinate = coordinate(); + storage + .replace_sticker_pack_if_newer(&StoredStickerPack { + coordinate: coordinate.clone(), + author_pubkey_hex: "ab".repeat(32), + identifier: "cats".to_owned(), + version: StoredStickerPackVersion { + event_id_hex: "cd".repeat(32), + created_at, + }, + title: "Cats".to_owned(), + description: None, + cover: None, + stickers: Vec::new(), + license: None, + }) + .unwrap(); + + assert!(next_pack_publication_timestamp(&storage, &coordinate).unwrap() > created_at); + } + + #[test] + fn image_inspection_enforces_dimensions_and_animation_limits() { + assert_eq!( + inspect_image(&png(512, 256, None)).unwrap(), + InspectedImage { + mime: "image/png", + width: 512, + height: 256, + frames: 1, + } + ); + assert_eq!( + inspect_image(&png(32, 48, Some(2))).unwrap().mime, + "image/apng" + ); + assert!(inspect_image(&png(4097, 1, None)).is_err()); + assert!(inspect_image(&png(32, 32, Some(MAX_STICKER_ANIMATION_FRAMES + 1))).is_err()); + assert!( + inspect_image(&png_with_frames( + 32, + 32, + Some(1), + &[(32, 32, 0, 0), (32, 32, 0, 0)], + )) + .is_err() + ); + assert!( + inspect_image(&png_with_frames( + 32, + 32, + Some(1), + &vec![(32, 32, 0, 0); MAX_STICKER_ANIMATION_FRAMES as usize + 1], + )) + .is_err() + ); + assert!(inspect_image(&png_with_frames(32, 32, Some(1), &[(0, 32, 0, 0)])).is_err()); + assert!(inspect_image(&png_with_frames(32, 32, Some(1), &[(33, 32, 0, 0)])).is_err()); + assert!(inspect_image(&png_with_frames(32, 32, Some(1), &[(16, 16, 17, 0)])).is_err()); + assert!(inspect_image(b"not an image").is_err()); + } + + #[test] + fn sticker_asset_size_rejects_empty_and_oversized_buffers() { + assert!(validate_sticker_asset_size(&[]).is_err()); + assert!( + validate_sticker_asset_size(&vec![0; MAX_STICKER_ASSET_BYTES as usize + 1]).is_err() + ); + } + + #[test] + fn downloaded_asset_requires_exact_hash_mime_and_dimensions() { + let bytes = png(64, 32, None); + let sticker = StoredSticker { + shortcode: "wave".to_owned(), + url: "https://example.test/hash.png".to_owned(), + sha256: sha256_hex(&bytes), + mime: "image/png".to_owned(), + width: Some(64), + height: Some(32), + alt: None, + emoji: None, + }; + assert!(validate_downloaded_sticker(&sticker, &bytes).is_ok()); + let mut wrong_hash = sticker.clone(); + wrong_hash.sha256 = "00".repeat(32); + assert!(validate_downloaded_sticker(&wrong_hash, &bytes).is_err()); + let mut wrong_mime = sticker.clone(); + wrong_mime.mime = "image/webp".to_owned(); + assert!(validate_downloaded_sticker(&wrong_mime, &bytes).is_err()); + } +} diff --git a/crates/marmot-app/src/tests.rs b/crates/marmot-app/src/tests.rs index 9c42336c..0a8adf21 100644 --- a/crates/marmot-app/src/tests.rs +++ b/crates/marmot-app/src/tests.rs @@ -1690,6 +1690,30 @@ fn chat_intent_builds_kind_nine_with_no_tags() { assert_eq!(event.pubkey, SENDER_HEX); } +#[test] +fn sticker_intent_builds_sonar_kind_nine_tag() { + let coordinate = format!("30031:{}:cats", "ab".repeat(32)); + let hash = "11".repeat(32); + let event = build(AppMessageIntent::Sticker { + sticker_ref: AppStickerRef { + pack_coordinate: coordinate.clone(), + shortcode: "wave".to_owned(), + plaintext_sha256: hash.clone(), + }, + }); + assert_eq!(event.kind, MARMOT_APP_EVENT_KIND_CHAT); + assert!(event.content.is_empty()); + assert_eq!( + event.tags, + vec![vec![ + "sticker".to_owned(), + coordinate, + "wave".to_owned(), + hash, + ]] + ); +} + #[test] fn reaction_intent_builds_kind_seven_with_e_tag() { let event = build(AppMessageIntent::Reaction { diff --git a/crates/marmot-uniffi/src/commands/message.rs b/crates/marmot-uniffi/src/commands/message.rs index e224196d..f493250e 100644 --- a/crates/marmot-uniffi/src/commands/message.rs +++ b/crates/marmot-uniffi/src/commands/message.rs @@ -4,7 +4,8 @@ use marmot_app::AppMessageQuery; use crate::Marmot; use crate::conversions::{ - AppMessageRecordFfi, SecureDeleteExpiredResultFfi, SendSummaryFfi, group_id_from_hex, + AppMessageRecordFfi, SecureDeleteExpiredResultFfi, SendSummaryFfi, StickerRefFfi, + group_id_from_hex, }; use crate::errors::MarmotKitError; use crate::optional_group_id_hex; @@ -31,6 +32,23 @@ impl Marmot { Ok(summary.into()) } + /// Send a Sonar sticker as a kind-9 message carrying the immutable + /// coordinate/shortcode/plaintext-hash reference. Native storage must + /// currently authorize the exact reference before the send is accepted. + pub async fn send_sticker( + &self, + account_ref: String, + group_id_hex: String, + sticker_ref: StickerRefFfi, + ) -> Result { + let group_id = group_id_from_hex(&group_id_hex)?; + let summary = self + .runtime + .send_sticker(&account_ref, &group_id, sticker_ref.into()) + .await?; + Ok(summary.into()) + } + /// Re-attempt publishing a group's pending (committed-but-undelivered) /// commit(s) without minting a new event. /// diff --git a/crates/marmot-uniffi/src/commands/mod.rs b/crates/marmot-uniffi/src/commands/mod.rs index e07c87db..7f452fa0 100644 --- a/crates/marmot-uniffi/src/commands/mod.rs +++ b/crates/marmot-uniffi/src/commands/mod.rs @@ -19,5 +19,6 @@ mod message; mod notification; mod push; mod relay; +mod sticker; mod subscription; mod timeline; diff --git a/crates/marmot-uniffi/src/commands/sticker.rs b/crates/marmot-uniffi/src/commands/sticker.rs new file mode 100644 index 00000000..0750a903 --- /dev/null +++ b/crates/marmot-uniffi/src/commands/sticker.rs @@ -0,0 +1,111 @@ +//! Sonar-compatible sticker pack lifecycle, asset fetch, and import commands. + +use crate::Marmot; +use crate::conversions::{ + StickerAssetFfi, StickerImportResultFfi, StickerPackFfi, StickerRefFfi, StickerSyncResultFfi, +}; +use crate::errors::MarmotKitError; + +#[uniffi::export(async_runtime = "tokio")] +impl Marmot { + /// Read the encrypted native sticker projection. Call off the UI thread. + pub fn sticker_packs( + &self, + account_ref: String, + installed_only: bool, + search: Option, + limit: Option, + ) -> Result, MarmotKitError> { + self.app + .sticker_packs( + &account_ref, + installed_only, + search.as_deref(), + limit.map(|value| value as usize), + ) + .map(|packs| packs.into_iter().map(Into::into).collect()) + .map_err(Into::into) + } + + pub fn sticker_pack( + &self, + account_ref: String, + input: String, + ) -> Result, MarmotKitError> { + self.app + .sticker_pack(&account_ref, &input) + .map(|pack| pack.map(Into::into)) + .map_err(Into::into) + } + + pub async fn sync_sticker_packs( + &self, + account_ref: String, + ) -> Result { + self.app + .sync_sticker_packs(&account_ref) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn fetch_sticker_pack( + &self, + account_ref: String, + input: String, + ) -> Result { + self.app + .fetch_sticker_pack(&account_ref, &input) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn install_sticker_pack( + &self, + account_ref: String, + input: String, + ) -> Result { + self.app + .install_sticker_pack(&account_ref, &input) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn uninstall_sticker_pack( + &self, + account_ref: String, + input: String, + ) -> Result<(), MarmotKitError> { + self.app + .uninstall_sticker_pack(&account_ref, &input) + .await + .map_err(Into::into) + } + + pub async fn import_signal_sticker_pack( + &self, + account_ref: String, + signal_link: String, + blossom_server: Option, + ) -> Result { + self.app + .import_signal_sticker_pack(&account_ref, signal_link, blossom_server.as_deref()) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn fetch_sticker_asset( + &self, + account_ref: String, + sticker_ref: StickerRefFfi, + ) -> Result { + self.app + .fetch_sticker_asset(&account_ref, sticker_ref.into()) + .await + .map(Into::into) + .map_err(Into::into) + } +} diff --git a/crates/marmot-uniffi/src/conversions/chat_list.rs b/crates/marmot-uniffi/src/conversions/chat_list.rs index c4110f91..f3cd4bec 100644 --- a/crates/marmot-uniffi/src/conversions/chat_list.rs +++ b/crates/marmot-uniffi/src/conversions/chat_list.rs @@ -1,8 +1,12 @@ //! Chat-list avatar, row, message-preview, and subscription-update FFI conversions. -use marmot_app::{ChatListAvatar, ChatListMessagePreview, ChatListRow, RuntimeChatListUpdate}; +use marmot_app::{ + ChatListAvatar, ChatListMessagePreview, ChatListRow, RuntimeChatListUpdate, + sticker_ref_from_tags, +}; use super::common::{SelfMembershipFfi, markdown_content_tokens}; +use crate::conversions::StickerRefFfi; use crate::markdown::MarkdownDocumentFfi; /// Group avatar reference. `image_key_hex` is the symmetric key that decrypts @@ -55,6 +59,7 @@ pub struct ChatListMessagePreviewFfi { pub plaintext: String, pub content_tokens: MarkdownDocumentFfi, pub kind: u64, + pub sticker: Option, pub timeline_at: u64, pub deleted: bool, } @@ -62,6 +67,7 @@ pub struct ChatListMessagePreviewFfi { impl From for ChatListMessagePreviewFfi { fn from(value: ChatListMessagePreview) -> Self { let content_tokens = markdown_content_tokens(value.kind, &value.plaintext); + let sticker = sticker_ref_from_tags(value.kind, &value.tags).map(Into::into); Self { message_id_hex: value.message_id_hex, sender: value.sender, @@ -69,6 +75,7 @@ impl From for ChatListMessagePreviewFfi { plaintext: value.plaintext, content_tokens, kind: value.kind, + sticker, timeline_at: value.timeline_at, deleted: value.deleted, } diff --git a/crates/marmot-uniffi/src/conversions/message.rs b/crates/marmot-uniffi/src/conversions/message.rs index 2e143ca2..9fd903ac 100644 --- a/crates/marmot-uniffi/src/conversions/message.rs +++ b/crates/marmot-uniffi/src/conversions/message.rs @@ -2,10 +2,11 @@ use marmot_app::{ AppMessageRecord, ReceivedMessage, RuntimeMessageReceived, RuntimeMessageUpdate, - SecureDeleteExpiredResult, + SecureDeleteExpiredResult, sticker_ref_from_tags, }; use super::common::{MessageTagFfi, markdown_content_tokens, message_tags_ffi}; +use crate::conversions::StickerRefFfi; use crate::markdown::MarkdownDocumentFfi; #[derive(Clone, Debug, uniffi::Record)] @@ -20,6 +21,7 @@ pub struct AppMessageRecordFfi { pub kind: u64, /// Nostr `tags` of the inner Marmot app event. pub tags: Vec, + pub sticker: Option, pub recorded_at: u64, pub received_at: u64, } @@ -27,6 +29,7 @@ pub struct AppMessageRecordFfi { impl From for AppMessageRecordFfi { fn from(value: AppMessageRecord) -> Self { let content_tokens = markdown_content_tokens(value.kind, &value.plaintext); + let sticker = sticker_ref_from_tags(value.kind, &value.tags).map(Into::into); Self { message_id_hex: value.message_id_hex, direction: value.direction, @@ -36,6 +39,7 @@ impl From for AppMessageRecordFfi { content_tokens, kind: value.kind, tags: message_tags_ffi(value.tags), + sticker, recorded_at: value.recorded_at, received_at: value.received_at, } @@ -69,6 +73,7 @@ pub struct ReceivedMessageFfi { pub kind: u64, /// Nostr `tags` of the inner Marmot app event. pub tags: Vec, + pub sticker: Option, /// Source-event timestamp (seconds since epoch) for the MLS-delivered /// message. Clients should sort the timeline by this value so chronology /// reflects send time, not delivery time. Zero means the timestamp was @@ -78,6 +83,7 @@ pub struct ReceivedMessageFfi { impl From<&ReceivedMessage> for ReceivedMessageFfi { fn from(value: &ReceivedMessage) -> Self { + let sticker = sticker_ref_from_tags(value.kind, &value.tags).map(Into::into); Self { message_id_hex: value.message_id_hex.clone(), group_id_hex: hex::encode(value.group_id.as_slice()), @@ -87,6 +93,7 @@ impl From<&ReceivedMessage> for ReceivedMessageFfi { content_tokens: markdown_content_tokens(value.kind, &value.plaintext), kind: value.kind, tags: message_tags_ffi(value.tags.clone()), + sticker, recorded_at: value.recorded_at, } } diff --git a/crates/marmot-uniffi/src/conversions/mod.rs b/crates/marmot-uniffi/src/conversions/mod.rs index d41d0b83..2a911a71 100644 --- a/crates/marmot-uniffi/src/conversions/mod.rs +++ b/crates/marmot-uniffi/src/conversions/mod.rs @@ -23,6 +23,7 @@ mod message; mod notification; mod push; mod relay; +mod sticker; mod timeline; pub use account::*; @@ -38,6 +39,7 @@ pub use message::*; pub use notification::*; pub use push::*; pub use relay::*; +pub use sticker::*; pub use timeline::*; #[cfg(test)] @@ -197,6 +199,7 @@ mod tests { sender: "bb".repeat(32), plaintext: "parent text".to_owned(), kind: 9, + tags: Vec::new(), source_epoch: None, media: None, agent_text_stream: None, diff --git a/crates/marmot-uniffi/src/conversions/notification.rs b/crates/marmot-uniffi/src/conversions/notification.rs index b9b6ef40..29333f7d 100644 --- a/crates/marmot-uniffi/src/conversions/notification.rs +++ b/crates/marmot-uniffi/src/conversions/notification.rs @@ -1,6 +1,7 @@ //! Notification settings, triggers, users, and update FFI conversions — //! plus the wake-path cursor-persistence policy. +use crate::conversions::StickerRefFfi; use marmot_app::{ CursorPersistence, NotificationCollectionStatus, NotificationSettings, NotificationTrigger, NotificationUpdate, NotificationUser, NotificationWakeSource, @@ -135,6 +136,7 @@ pub struct NotificationUpdateFfi { pub sender: NotificationUserFfi, pub receiver: NotificationUserFfi, pub preview_text: Option, + pub sticker: Option, pub reaction_emoji: Option, pub reacted_to_preview: Option, pub timestamp_ms: i64, @@ -157,6 +159,7 @@ impl From for NotificationUpdateFfi { sender: value.sender.into(), receiver: value.receiver.into(), preview_text: value.preview_text, + sticker: value.sticker.map(Into::into), reaction_emoji: value.reaction_emoji, reacted_to_preview: value.reacted_to_preview, timestamp_ms: value.timestamp_ms, diff --git a/crates/marmot-uniffi/src/conversions/sticker.rs b/crates/marmot-uniffi/src/conversions/sticker.rs new file mode 100644 index 00000000..2a7aeb67 --- /dev/null +++ b/crates/marmot-uniffi/src/conversions/sticker.rs @@ -0,0 +1,142 @@ +use marmot_app::{ + AppSticker, AppStickerAsset, AppStickerImportResult, AppStickerPack, AppStickerRef, + AppStickerSyncResult, +}; + +#[derive(Clone, Debug, uniffi::Record)] +pub struct StickerRefFfi { + pub pack_coordinate: String, + pub shortcode: String, + pub plaintext_sha256: String, +} + +impl From for StickerRefFfi { + fn from(value: AppStickerRef) -> Self { + Self { + pack_coordinate: value.pack_coordinate, + shortcode: value.shortcode, + plaintext_sha256: value.plaintext_sha256, + } + } +} + +impl From for AppStickerRef { + fn from(value: StickerRefFfi) -> Self { + Self { + pack_coordinate: value.pack_coordinate, + shortcode: value.shortcode, + plaintext_sha256: value.plaintext_sha256, + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct StickerFfi { + pub pack_coordinate: String, + pub shortcode: String, + pub url: String, + pub sha256: String, + pub mime: String, + pub width: Option, + pub height: Option, + pub alt: Option, + pub emoji: Option, +} + +impl From for StickerFfi { + fn from(value: AppSticker) -> Self { + Self { + pack_coordinate: value.pack_coordinate, + shortcode: value.shortcode, + url: value.url, + sha256: value.sha256, + mime: value.mime, + width: value.width, + height: value.height, + alt: value.alt, + emoji: value.emoji, + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct StickerPackFfi { + pub coordinate: String, + pub author_pubkey_hex: String, + pub identifier: String, + pub event_id_hex: String, + pub created_at: u64, + pub title: String, + pub description: Option, + pub cover: Option, + pub stickers: Vec, + pub license: Option, + pub installed: bool, +} + +impl From for StickerPackFfi { + fn from(value: AppStickerPack) -> Self { + Self { + coordinate: value.coordinate, + author_pubkey_hex: value.author_pubkey_hex, + identifier: value.identifier, + event_id_hex: value.event_id_hex, + created_at: value.created_at, + title: value.title, + description: value.description, + cover: value.cover.map(Into::into), + stickers: value.stickers.into_iter().map(Into::into).collect(), + license: value.license, + installed: value.installed, + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct StickerAssetFfi { + pub sticker: StickerFfi, + pub bytes: Vec, +} + +impl From for StickerAssetFfi { + fn from(value: AppStickerAsset) -> Self { + Self { + sticker: value.sticker.into(), + bytes: value.bytes, + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct StickerSyncResultFfi { + pub discovered: u32, + pub updated: u32, + pub installed: u32, + pub pending_operations: u32, +} + +impl From for StickerSyncResultFfi { + fn from(value: AppStickerSyncResult) -> Self { + Self { + discovered: value.discovered, + updated: value.updated, + installed: value.installed, + pending_operations: value.pending_operations, + } + } +} + +#[derive(Clone, Debug, uniffi::Record)] +pub struct StickerImportResultFfi { + pub pack: StickerPackFfi, + pub skipped_signal_sticker_ids: Vec, +} + +impl From for StickerImportResultFfi { + fn from(value: AppStickerImportResult) -> Self { + Self { + pack: value.pack.into(), + skipped_signal_sticker_ids: value.skipped_signal_sticker_ids, + } + } +} diff --git a/crates/marmot-uniffi/src/conversions/timeline.rs b/crates/marmot-uniffi/src/conversions/timeline.rs index c1c89313..e9b0a09f 100644 --- a/crates/marmot-uniffi/src/conversions/timeline.rs +++ b/crates/marmot-uniffi/src/conversions/timeline.rs @@ -5,12 +5,13 @@ use marmot_app::{ AppGroupSystemEvent, AppProjectionUpdate, RuntimeProjectionUpdate, RuntimeTimelineMessageUpdate, TimelineMessageChange, TimelineMessageRecord, TimelinePage, TimelineReactionSummary, TimelineRemoveReason, TimelineReplyPreview, TimelineUpdateTrigger, - TimelineUserReaction, group_system_event_from_message, + TimelineUserReaction, group_system_event_from_message, sticker_ref_from_tags, }; use super::chat_list::{ChatListRowFfi, ChatListUpdateTriggerFfi}; use super::common::{MessageTagFfi, markdown_content_tokens, message_tags_ffi}; use super::media::{MediaAttachmentReferenceFfi, timeline_media_references_ffi}; +use crate::conversions::StickerRefFfi; use crate::markdown::MarkdownDocumentFfi; #[derive(Clone, Debug, uniffi::Record)] @@ -90,6 +91,7 @@ pub struct TimelineReplyPreviewFfi { pub plaintext: String, pub content_tokens: MarkdownDocumentFfi, pub kind: u64, + pub sticker: Option, pub media_json: Option, /// Fully-resolved, downloadable media references for the previewed message, /// built from its `imeta` tags + its own `source_epoch` using the same @@ -104,12 +106,14 @@ impl From for TimelineReplyPreviewFfi { fn from(value: TimelineReplyPreview) -> Self { let content_tokens = markdown_content_tokens(value.kind, &value.plaintext); let media = timeline_media_references_ffi(&value.media, value.source_epoch); + let sticker = sticker_ref_from_tags(value.kind, &value.tags).map(Into::into); Self { message_id_hex: value.message_id_hex, sender: value.sender, plaintext: value.plaintext, content_tokens, kind: value.kind, + sticker, media_json: value.media.map(|media| media.to_string()), media, agent_text_stream_json: value.agent_text_stream.map(|stream| stream.to_string()), @@ -171,6 +175,7 @@ pub struct TimelineMessageRecordFfi { pub content_tokens: MarkdownDocumentFfi, pub kind: u64, pub tags: Vec, + pub sticker: Option, pub timeline_at: u64, pub received_at: u64, pub reply_to_message_id_hex: Option, @@ -202,6 +207,7 @@ impl From for TimelineMessageRecordFfi { let content_tokens = markdown_content_tokens(value.kind, &value.plaintext); let group_system = group_system_event_from_message(value.kind, &value.plaintext); let media = timeline_media_references_ffi(&value.media, value.source_epoch); + let sticker = sticker_ref_from_tags(value.kind, &value.tags).map(Into::into); Self { message_id_hex: value.message_id_hex, source_message_id_hex: value.source_message_id_hex, @@ -212,6 +218,7 @@ impl From for TimelineMessageRecordFfi { content_tokens, kind: value.kind, tags: message_tags_ffi(value.tags), + sticker, timeline_at: value.timeline_at, received_at: value.received_at, reply_to_message_id_hex: value.reply_to_message_id_hex, @@ -521,6 +528,7 @@ mod tests { sender: "bob".to_owned(), plaintext: "original".to_owned(), kind: 9, + tags: Vec::new(), // The previewed (target) message lives in its own epoch, distinct // from the replying message's epoch. source_epoch: Some(3), diff --git a/crates/marmot-uniffi/src/errors.rs b/crates/marmot-uniffi/src/errors.rs index 9039d353..f5841f16 100644 --- a/crates/marmot-uniffi/src/errors.rs +++ b/crates/marmot-uniffi/src/errors.rs @@ -109,6 +109,16 @@ pub enum MarmotKitError { /// retry without treating the account as broken. #[error("external signer request was rejected or cancelled")] ExternalSignerRejected, + #[error("invalid sticker data: {details}")] + InvalidSticker { details: String }, + #[error("sticker pack or asset is unavailable")] + StickerNotFound, + #[error("sticker network operation failed: {details}")] + StickerNetwork { details: String }, + #[error("sticker import failed: {details}")] + StickerImport { details: String }, + #[error("Signal sticker import is unavailable for external-signing accounts")] + StickerImportUnsupported, #[error("marmot runtime error: {details}")] Runtime { details: String }, } @@ -184,6 +194,11 @@ impl From for MarmotKitError { } AppError::ExternalSignerMismatch => Self::ExternalSignerMismatch, AppError::ExternalSignerRejected => Self::ExternalSignerRejected, + AppError::InvalidSticker(details) => Self::InvalidSticker { details }, + AppError::StickerNotFound => Self::StickerNotFound, + AppError::StickerRelay(details) => Self::StickerNetwork { details }, + AppError::StickerImport(details) => Self::StickerImport { details }, + AppError::StickerExternalSignerImportUnsupported => Self::StickerImportUnsupported, other => Self::Runtime { details: other.to_string(), }, diff --git a/crates/marmot-uniffi/src/lib.rs b/crates/marmot-uniffi/src/lib.rs index 9497c387..fff116f4 100644 --- a/crates/marmot-uniffi/src/lib.rs +++ b/crates/marmot-uniffi/src/lib.rs @@ -57,10 +57,12 @@ pub use conversions::{ NotificationTriggerFfi, NotificationUpdateFfi, NotificationUserFfi, NotificationWakeSourceFfi, PushPlatformFfi, PushRegistrationFfi, RelayTelemetryResourceFfi, RelayTelemetryRuntimeConfigFfi, RelayTelemetrySettingsFfi, RuntimeProjectionUpdateFfi, - SecureDeleteExpiredResultFfi, TimelineMessageChangeFfi, TimelineMessageQueryFfi, - TimelineMessageRecordFfi, TimelinePageFfi, TimelineProjectionUpdateFfi, - TimelineReactionEmojiFfi, TimelineReactionSummaryFfi, TimelineRemoveReasonFfi, - TimelineSubscriptionUpdateFfi, TimelineUpdateTriggerFfi, TimelineUserReactionFfi, + SecureDeleteExpiredResultFfi, StickerAssetFfi, StickerFfi, StickerImportResultFfi, + StickerPackFfi, StickerRefFfi, StickerSyncResultFfi, TimelineMessageChangeFfi, + TimelineMessageQueryFfi, TimelineMessageRecordFfi, TimelinePageFfi, + TimelineProjectionUpdateFfi, TimelineReactionEmojiFfi, TimelineReactionSummaryFfi, + TimelineRemoveReasonFfi, TimelineSubscriptionUpdateFfi, TimelineUpdateTriggerFfi, + TimelineUserReactionFfi, }; /// Convenience: turn an FFI string list of relay URLs into the engine's diff --git a/crates/storage-sqlite/src/chat_list.rs b/crates/storage-sqlite/src/chat_list.rs index fa90e5db..b30f7b8e 100644 --- a/crates/storage-sqlite/src/chat_list.rs +++ b/crates/storage-sqlite/src/chat_list.rs @@ -69,6 +69,7 @@ pub struct ChatListMessagePreview { pub sender_display_name: Option, pub plaintext: String, pub kind: u64, + pub tags: Vec>, pub timeline_at: u64, pub deleted: bool, } @@ -487,6 +488,15 @@ fn chat_list_projection_complete_tx( ORDER BY mt.timeline_at DESC, mt.message_id_hex DESC LIMIT 1 ) + OR row.last_message_tags_json IS NOT ( + SELECT mt.tags_json + FROM message_timeline AS mt + WHERE mt.group_id_hex = ag.group_id_hex + AND mt.kind = ?1 + AND mt.invalidation_status IS NULL + ORDER BY mt.timeline_at DESC, mt.message_id_hex DESC + LIMIT 1 + ) OR row.last_message_timeline_at IS NOT ( SELECT mt.timeline_at FROM message_timeline AS mt @@ -578,6 +588,11 @@ fn rebuild_chat_list_row_for_group_tx( mention_classifier, )?; let now = unix_now_seconds(); + let latest_tags_json = latest + .as_ref() + .map(|message| serde_json::to_string(&message.tags)) + .transpose() + .map_err(|err| StorageError::Serialization(err.to_string()))?; tx.execute( "INSERT INTO chat_list_rows ( group_id_hex, archived, pending_confirmation, title, group_name, @@ -585,14 +600,15 @@ fn rebuild_chat_list_row_for_group_tx( avatar_image_hash_hex, avatar_image_key_hex, avatar_image_nonce_hex, avatar_image_upload_key_hex, avatar_media_type, last_message_id_hex, last_message_sender, last_message_preview, - last_message_kind, last_message_timeline_at, last_message_deleted, + last_message_kind, last_message_tags_json, last_message_timeline_at, + last_message_deleted, unread_count, unread_mention_count, first_unread_message_id_hex, last_read_message_id_hex, last_read_timeline_at, updated_at, self_membership ) VALUES ( ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, - ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24 + ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25 ) ON CONFLICT(group_id_hex) DO UPDATE SET archived = excluded.archived, @@ -609,6 +625,7 @@ fn rebuild_chat_list_row_for_group_tx( last_message_sender = excluded.last_message_sender, last_message_preview = excluded.last_message_preview, last_message_kind = excluded.last_message_kind, + last_message_tags_json = excluded.last_message_tags_json, last_message_timeline_at = excluded.last_message_timeline_at, last_message_deleted = excluded.last_message_deleted, unread_count = excluded.unread_count, @@ -655,6 +672,7 @@ fn rebuild_chat_list_row_for_group_tx( latest.as_ref().map(|message| message.sender.as_str()), latest.as_ref().map(|message| message.plaintext.as_str()), optional_u64_to_i64(latest.as_ref().map(|message| message.kind))?, + latest_tags_json.as_deref(), optional_u64_to_i64(latest.as_ref().map(|message| message.timeline_at))?, latest .as_ref() @@ -841,7 +859,7 @@ fn latest_kind9_message_tx( group_id_hex: &str, ) -> StorageResult> { tx.query_row( - "SELECT message_id_hex, sender, plaintext, kind, timeline_at, deleted + "SELECT message_id_hex, sender, plaintext, kind, tags_json, timeline_at, deleted FROM message_timeline WHERE group_id_hex = ?1 AND kind = ?2 AND invalidation_status IS NULL @@ -860,7 +878,7 @@ fn timeline_message_for_read_marker_tx( message_id_hex: &str, ) -> StorageResult> { tx.query_row( - "SELECT message_id_hex, sender, plaintext, kind, timeline_at, deleted + "SELECT message_id_hex, sender, plaintext, kind, tags_json, timeline_at, deleted FROM message_timeline WHERE group_id_hex = ?1 AND message_id_hex = ?2 AND kind = ?3", params![ @@ -881,8 +899,11 @@ fn chat_list_message_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(3)?.try_into().unwrap_or_default(), - timeline_at: row.get::<_, i64>(4)?.try_into().unwrap_or_default(), - deleted: row.get::<_, i64>(5)? != 0, + tags: serde_json::from_str(&row.get::<_, String>(4)?).map_err(|err| { + rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(err)) + })?, + timeline_at: row.get::<_, i64>(5)?.try_into().unwrap_or_default(), + deleted: row.get::<_, i64>(6)? != 0, }) } @@ -916,7 +937,8 @@ fn chat_list_rows_tx(tx: &Connection, query: ChatListQuery) -> StorageResult StorageResult StorageResult