Skip to content
52 changes: 49 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
1 change: 1 addition & 0 deletions crates/marmot-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions crates/marmot-app/src/client/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ impl AppClient {
) -> Option<AuditEventContext> {
let (action, target_count): (&'static str, Option<u64>) = 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),
Expand Down
4 changes: 4 additions & 0 deletions crates/marmot-app/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions crates/marmot-app/src/client/projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions crates/marmot-app/src/client/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ pub(crate) fn notification_trigger_for_intent(
) -> Option<notifications::NotificationTrigger> {
match intent {
AppMessageIntent::Chat { .. }
| AppMessageIntent::Sticker { .. }
| AppMessageIntent::Reply { .. }
| AppMessageIntent::Media { .. }
| AppMessageIntent::StreamFinal { .. } => {
Expand Down
17 changes: 17 additions & 0 deletions crates/marmot-app/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions crates/marmot-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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::{
Expand Down Expand Up @@ -408,6 +414,10 @@ pub struct MarmotApp {
chat_list_projection_stale: Arc<Mutex<HashSet<String>>>,
audit_log_tracker_config: Arc<Mutex<AuditLogTrackerConfig>>,
external_signers: Arc<Mutex<HashMap<String, RegisteredExternalSigner>>>,
/// 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<Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
Expand Down Expand Up @@ -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())),
}
}

Expand Down Expand Up @@ -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())),
}
}

Expand Down
14 changes: 13 additions & 1 deletion crates/marmot-app/src/media/blossom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>, 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<Vec<u8>, AppError> {
let mut current = Url::parse(url)
.map_err(|_| AppError::InvalidEncryptedMedia("media URL is invalid".into()))?;
Expand All @@ -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!(
Expand Down
6 changes: 4 additions & 2 deletions crates/marmot-app/src/media/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions crates/marmot-app/src/messages/intents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ pub(crate) enum AppMessageIntent {
Chat {
content: String,
},
Sticker {
sticker_ref: crate::AppStickerRef,
},
Reaction {
target_message_id: String,
emoji: String,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions crates/marmot-app/src/notifications.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,10 @@ pub struct NotificationUpdate {
pub sender: NotificationUser,
pub receiver: NotificationUser,
pub preview_text: Option<String>,
/// 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<crate::AppStickerRef>,
/// 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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
Loading