diff --git a/Cargo.lock b/Cargo.lock index 7f20c73306..2a97b9d8e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3716,6 +3716,8 @@ version = "0.1.0" dependencies = [ "base64", "clap", + "hex", + "infer", "nostr", "reqwest 0.13.2", "serde", @@ -3725,6 +3727,7 @@ dependencies = [ "sprout-sdk", "thiserror 2.0.18", "tokio", + "url", "uuid", ] @@ -3770,7 +3773,10 @@ name = "sprout-mcp" version = "0.1.0" dependencies = [ "anyhow", + "base64", "futures-util", + "hex", + "infer", "nostr", "reqwest 0.13.2", "rmcp", @@ -3778,6 +3784,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2 0.11.0", "sprout-core", "sprout-sdk", "thiserror 2.0.18", diff --git a/crates/sprout-cli/Cargo.toml b/crates/sprout-cli/Cargo.toml index 7668f1e030..2495590d92 100644 --- a/crates/sprout-cli/Cargo.toml +++ b/crates/sprout-cli/Cargo.toml @@ -40,8 +40,17 @@ sprout-sdk = { workspace = true } # Base64 encoding — NIP-98 event serialization for Authorization header base64 = "0.22" -# SHA-256 — NIP-98 payload hash tag +# SHA-256 — NIP-98 payload hash tag and Blossom file hash sha2 = "0.11" +# Hex encoding — SHA-256 hash output for Blossom uploads +hex = { workspace = true } + +# MIME type detection via magic bytes — file upload validation +infer = "0.19" + +# URL parsing — extract server domain for Blossom auth tag +url = { workspace = true } + # Persona pack parsing, validation, and resolution sprout-persona = { path = "../sprout-persona" } diff --git a/crates/sprout-cli/src/client.rs b/crates/sprout-cli/src/client.rs index 4c6aa0e444..b319475b8b 100644 --- a/crates/sprout-cli/src/client.rs +++ b/crates/sprout-cli/src/client.rs @@ -4,6 +4,77 @@ use nostr::Keys; use crate::error::CliError; +// --------------------------------------------------------------------------- +// Blob / Media types +// --------------------------------------------------------------------------- + +/// Descriptor returned by the relay after a successful upload. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BlobDescriptor { + /// Public URL of the uploaded blob. + pub url: String, + /// Hex-encoded SHA-256 of the file content. + pub sha256: String, + /// File size in bytes. + pub size: u64, + /// MIME type (e.g. `image/jpeg`). + #[serde(rename = "type")] + pub mime_type: String, + /// Unix timestamp when the file was uploaded. + pub uploaded: i64, + /// Image dimensions as `x` (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub dim: Option, + /// Blurhash placeholder string (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub blurhash: Option, + /// Thumbnail URL (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb: Option, + /// Duration in seconds for video/audio (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +/// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata). +pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { + let mut tag = vec![ + "imeta".to_string(), + format!("url {}", d.url), + format!("m {}", d.mime_type), + format!("x {}", d.sha256), + format!("size {}", d.size), + ]; + if let Some(ref dim) = d.dim { + tag.push(format!("dim {dim}")); + } + if let Some(ref bh) = d.blurhash { + tag.push(format!("blurhash {bh}")); + } + if let Some(ref th) = d.thumb { + tag.push(format!("thumb {th}")); + } + if let Some(dur) = d.duration { + tag.push(format!("duration {dur}")); + } + tag +} + +/// MIME types accepted for upload. +const ALLOWED_MIMES: &[&str] = &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", +]; + +/// Maximum file size for image uploads (50 MB). +const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; + +/// Maximum file size for video uploads (500 MB). +const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; + // --------------------------------------------------------------------------- // Auth // --------------------------------------------------------------------------- @@ -135,6 +206,127 @@ impl SproutClient { Ok(resp.text().await?) } + // ----------------------------------------------------------------------- + // File upload (Blossom protocol) + // ----------------------------------------------------------------------- + + /// Upload a file to the relay's Blossom endpoint. + /// Returns a BlobDescriptor on success. + pub async fn upload_file(&self, file_path: &str) -> Result { + let keys = self.keys().ok_or_else(|| { + CliError::Key("private key required for uploads (set SPROUT_PRIVATE_KEY)".into()) + })?; + + // 1. Read file — validate it exists and is a regular file + let metadata = std::fs::metadata(file_path) + .map_err(|e| CliError::Other(format!("cannot access {file_path}: {e}")))?; + if !metadata.is_file() { + return Err(CliError::Usage(format!("{file_path} is not a file"))); + } + + let bytes = std::fs::read(file_path) + .map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?; + + // 2. Detect MIME from magic bytes + let mime = infer::get(&bytes) + .map(|t| t.mime_type().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + if !ALLOWED_MIMES.contains(&mime.as_str()) { + return Err(CliError::Usage(format!("unsupported file type: {mime}"))); + } + + // 3. Size check + let max = if mime.starts_with("video/") { + MAX_VIDEO_BYTES + } else { + MAX_IMAGE_BYTES + }; + if bytes.len() as u64 > max { + return Err(CliError::Usage(format!( + "file too large: {} bytes (max {})", + bytes.len(), + max + ))); + } + + // 4. SHA-256 + use sha2::{Digest, Sha256}; + let sha256 = hex::encode(Sha256::digest(&bytes)); + + // 5. Sign Blossom auth event (kind:24242) + use nostr::{EventBuilder, Kind, Tag, Timestamp}; + let now = Timestamp::now().as_u64(); + let expiry = if mime.starts_with("video/") { + 3600 + } else { + 600 + }; + let exp_str = (now + expiry).to_string(); + + let mut tags = vec![ + Tag::parse(&["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(&["x", &sha256]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(&["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, + ]; + // Extract server domain from relay URL for BUD-11 server tag + if let Ok(parsed) = url::Url::parse(&self.relay_url) { + if let Some(host) = parsed.host_str() { + let domain = match parsed.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + tags.push( + Tag::parse(&["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, + ); + } + } + + let auth_event = EventBuilder::new(Kind::from(24242), "Upload file", tags) + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + + // 6. Base64url encode the auth event for the header + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + use nostr::JsonUtil; + let auth_header = format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) + ); + + // 7. PUT request to /media/upload — with generous per-request timeout. + // The shared client has a 10s timeout for REST calls; uploads need more. + let upload_timeout = if mime.starts_with("video/") { + std::time::Duration::from_secs(600) + } else { + std::time::Duration::from_secs(120) + }; + let url = format!("{}/media/upload", self.relay_url); + let mut req = self + .http + .put(&url) + .timeout(upload_timeout) + .header("Authorization", &auth_header) + .header("Content-Type", &mime) + .header("X-SHA-256", &sha256); + // Add bearer token as X-Auth-Token for relay auth + if let Auth::Bearer(ref token) = self.auth { + req = req.header("X-Auth-Token", token.as_str()); + } + + let resp = req.body(bytes).send().await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::Relay { status, body }); + } + + resp.json::() + .await + .map_err(|e| CliError::Other(format!("invalid upload response: {e}"))) + } + // ----------------------------------------------------------------------- // Convenience wrappers — print response to stdout // ----------------------------------------------------------------------- diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs index 4d8f90ab29..5e6703e9bc 100644 --- a/crates/sprout-cli/src/commands/messages.rs +++ b/crates/sprout-cli/src/commands/messages.rs @@ -159,29 +159,55 @@ pub async fn cmd_search( // Write commands — signed events // --------------------------------------------------------------------------- -pub async fn cmd_send_message( - client: &SproutClient, - channel_id: &str, - content: &str, - _kind: Option, - reply_to: Option<&str>, - broadcast: bool, - mentions: &[String], -) -> Result<(), CliError> { - validate_uuid(channel_id)?; - validate_content_size(content)?; - if let Some(r) = reply_to { +pub struct SendMessageParams { + pub channel_id: String, + pub content: String, + #[allow(dead_code)] // reserved for future kind routing + pub kind: Option, + pub reply_to: Option, + pub broadcast: bool, + pub mentions: Vec, + pub files: Vec, +} + +pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Result<(), CliError> { + validate_uuid(&p.channel_id)?; + validate_content_size(&p.content)?; + if let Some(ref r) = p.reply_to { validate_hex64(r)?; } - for m in mentions { + for m in &p.mentions { validate_hex64(m)?; } let keys = require_keys!(client); - let channel_uuid = parse_uuid(channel_id)?; + let channel_uuid = parse_uuid(&p.channel_id)?; + + // Upload files and build imeta tags + let mut media_tags: Vec> = Vec::new(); + let mut media_content = String::new(); + for file_path in &p.files { + let desc = client + .upload_file(file_path) + .await + .map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?; + media_tags.push(crate::client::build_imeta_tag(&desc)); + if desc.mime_type.starts_with("video/") { + media_content.push_str("\n![video]("); + } else { + media_content.push_str("\n![image]("); + } + media_content.push_str(&desc.url); + media_content.push(')'); + } + let final_content = if media_content.is_empty() { + p.content.clone() + } else { + format!("{}{media_content}", p.content) + }; // Build thread ref if replying - let thread_ref = if let Some(r) = reply_to { + let thread_ref = if let Some(ref r) = p.reply_to { let eid = parse_event_id(r)?; Some(ThreadRef { root_event_id: eid, @@ -192,18 +218,18 @@ pub async fn cmd_send_message( }; // Normalize explicit mentions, then merge auto-resolved up to SDK cap of 50. - let mut merged: Vec = normalize_mention_pubkeys(mentions, ""); - let auto_resolved = resolve_content_mentions(client, channel_id, content).await; + let mut merged: Vec = normalize_mention_pubkeys(&p.mentions, ""); + let auto_resolved = resolve_content_mentions(client, &p.channel_id, &final_content).await; merge_mentions(&mut merged, &auto_resolved, 50); let mention_refs: Vec<&str> = merged.iter().map(|s| s.as_str()).collect(); let builder = sprout_sdk::build_message( channel_uuid, - content, + &final_content, thread_ref.as_ref(), &mention_refs, - broadcast, - &[], + p.broadcast, + &media_tags, ) .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?; diff --git a/crates/sprout-cli/src/main.rs b/crates/sprout-cli/src/main.rs index 03279d6bd4..a0dbadbdbb 100644 --- a/crates/sprout-cli/src/main.rs +++ b/crates/sprout-cli/src/main.rs @@ -55,6 +55,9 @@ enum Cmd { broadcast: bool, #[arg(long = "mention")] mentions: Vec, + /// Attach file(s) — uploads and includes as imeta tags + #[arg(long = "file")] + files: Vec, }, /// Send a diff/code-review message SendDiffMessage { @@ -140,6 +143,13 @@ enum Cmd { direction: String, }, + /// Upload a file to the relay (returns BlobDescriptor JSON) + UploadFile { + /// Path to the file to upload + #[arg(long)] + file: String, + }, + // ---- Channels ---------------------------------------------------------- /// List channels ListChannels { @@ -565,15 +575,19 @@ async fn run(cli: Cli) -> Result<(), CliError> { reply_to, broadcast, mentions, + files, } => { commands::messages::cmd_send_message( &client, - &channel, - &content, - kind, - reply_to.as_deref(), - broadcast, - &mentions, + commands::messages::SendMessageParams { + channel_id: channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + }, ) .await } @@ -656,6 +670,14 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::VoteOnPost { event, direction } => { commands::messages::cmd_vote_on_post(&client, &event, &direction).await } + Cmd::UploadFile { file } => { + let desc = client.upload_file(&file).await?; + println!( + "{}", + serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))? + ); + Ok(()) + } // ---- Channels ------------------------------------------------------ Cmd::ListChannels { visibility, member } => { @@ -893,10 +915,10 @@ mod tests { assert!(super::parse_bool_flag("--approved", "").is_err()); } - /// Parity: the CLI exposes exactly the expected 54 commands. + /// Parity: the CLI exposes exactly the expected 55 commands. /// If a command is added or removed, this test forces a conscious update. #[test] - fn command_inventory_is_54() { + fn command_inventory_is_55() { let expected: Vec<&str> = vec![ "add-channel-member", "add-dm-member", @@ -951,6 +973,7 @@ mod tests { "unarchive-channel", "update-channel", "update-workflow", + "upload-file", "vote-on-post", ]; @@ -964,8 +987,8 @@ mod tests { assert_eq!( actual.len(), - 54, - "Expected 54 commands, got {}. Actual: {:?}", + 55, + "Expected 55 commands, got {}. Actual: {:?}", actual.len(), actual ); diff --git a/crates/sprout-mcp/Cargo.toml b/crates/sprout-mcp/Cargo.toml index 35938d9e12..9db50f8ac5 100644 --- a/crates/sprout-mcp/Cargo.toml +++ b/crates/sprout-mcp/Cargo.toml @@ -38,6 +38,12 @@ reqwest = { workspace = true } # TLS crypto provider (required for wss:// connections) rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +# Crypto +sha2 = { workspace = true } +hex = { workspace = true } +base64 = "0.22" +infer = "0.19" + # Utilities uuid = { workspace = true } tracing = { workspace = true } diff --git a/crates/sprout-mcp/src/lib.rs b/crates/sprout-mcp/src/lib.rs index d0824a0756..e104177499 100644 --- a/crates/sprout-mcp/src/lib.rs +++ b/crates/sprout-mcp/src/lib.rs @@ -125,3 +125,5 @@ pub mod relay_client; pub mod server; /// Toolset definitions and configuration for organizing MCP tools. pub mod toolsets; +/// File upload to the Sprout relay (Blossom protocol). +pub mod upload; diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-mcp/src/relay_client.rs index 0a03d7e3ac..d895800b74 100644 --- a/crates/sprout-mcp/src/relay_client.rs +++ b/crates/sprout-mcp/src/relay_client.rs @@ -796,6 +796,43 @@ impl RelayClient { self.keys.public_key().to_hex() } + /// Returns a reference to the shared reqwest HTTP client. + pub fn http_client(&self) -> &reqwest::Client { + &self.http + } + + /// Returns a reference to the Nostr signing keys. + pub fn keys(&self) -> &nostr::Keys { + &self.keys + } + + /// Returns the API token, if configured. + pub fn api_token(&self) -> Option<&str> { + self.api_token.as_deref() + } + + /// Returns the relay's server authority (host or host:port) for BUD-11 server tags. + /// + /// Uses the same logic as the desktop client's `extract_server_authority`: + /// default ports (80/443) are omitted, non-default ports are included. + /// Returns `None` for localhost (no server tag in dev mode). + pub fn server_domain(&self) -> Option { + // Convert ws:// → http://, wss:// → https:// for url::Url parsing. + let http_url = self + .relay_url + .replace("wss://", "https://") + .replace("ws://", "http://"); + let parsed = url::Url::parse(&http_url).ok()?; + let host = parsed.host_str()?; + if host.is_empty() || host == "localhost" { + return None; + } + match parsed.port() { + Some(port) => Some(format!("{host}:{port}")), + None => Some(host.to_string()), + } + } + /// Returns the appropriate auth header for REST requests. /// /// - If an API token is present: `Authorization: Bearer ` (production mode). diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 457147b884..72d770e47d 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -178,6 +178,10 @@ pub struct SendMessageParams { /// Pubkeys to @mention in the message. #[serde(default)] pub mention_pubkeys: Option>, + /// Optional file paths to upload and attach as media. Each file is uploaded + /// to the relay and included as an imeta tag + markdown image in the message. + #[serde(default)] + pub file_paths: Option>, } fn default_kind() -> Option { Some(sprout_core::kind::KIND_STREAM_MESSAGE as u16) @@ -805,6 +809,13 @@ fn infer_language(file_path: &str) -> Option { Some(lang.to_string()) } +/// Parameters for the `upload_file` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct UploadFileParams { + /// Local filesystem path to the file to upload. + pub file_path: String, +} + /// The MCP server that exposes Sprout relay functionality as tools. #[derive(Clone)] pub struct SproutMcpServer { @@ -938,11 +949,53 @@ Default kind is 9 (stream message)." let mention_refs: Vec<&str> = mentions.iter().map(String::as_str).collect(); let broadcast = p.broadcast_to_channel.unwrap_or(false); + // Upload files and build media tags + let mut media_tags: Vec> = Vec::new(); + let mut media_content = String::new(); + if let Some(ref paths) = p.file_paths { + for path in paths { + match crate::upload::upload_file( + self.client.http_client(), + self.client.keys(), + &self.client.relay_http_url(), + self.client.api_token(), + self.client.server_domain().as_deref(), + path, + ) + .await + { + Ok(desc) => { + media_tags.push(crate::upload::build_imeta_tag(&desc)); + if desc.mime_type.starts_with("video/") { + media_content.push_str("\n![video]("); + } else { + media_content.push_str("\n![image]("); + } + media_content.push_str(&desc.url); + media_content.push(')'); + } + Err(e) => { + return format!("Error uploading {}: {e}", path); + } + } + } + } + let final_content = if media_content.is_empty() { + p.content.clone() + } else { + format!("{}{}", p.content, media_content) + }; + // Build the event builder via SDK, routing by kind. let builder = match kind_num as u32 { sprout_core::kind::KIND_FORUM_POST => { // kind 45001: forum post (no thread ref, no broadcast) - match sprout_sdk::build_forum_post(channel_uuid, &p.content, &mention_refs, &[]) { + match sprout_sdk::build_forum_post( + channel_uuid, + &final_content, + &mention_refs, + &media_tags, + ) { Ok(b) => b, Err(e) => return format!("Error: {e}"), } @@ -964,10 +1017,10 @@ Default kind is 9 (stream message)." }; match sprout_sdk::build_forum_comment( channel_uuid, - &p.content, + &final_content, &thread_ref, &mention_refs, - &[], + &media_tags, ) { Ok(b) => b, Err(e) => return format!("Error: {e}"), @@ -989,11 +1042,11 @@ Default kind is 9 (stream message)." }; match sprout_sdk::build_message( channel_uuid, - &p.content, + &final_content, thread_ref.as_ref(), &mention_refs, broadcast && p.parent_event_id.is_some(), - &[], + &media_tags, ) { Ok(b) => b, Err(e) => return format!("Error: {e}"), @@ -2620,6 +2673,33 @@ with kind:45003 comments)." Err(e) => format!("Error: {e}"), } } + + /// Upload a local file to the Sprout relay. + #[tool( + name = "upload_file", + description = "Upload a local file (image or video) to the Sprout relay. \ +Returns a BlobDescriptor with the URL, hash, dimensions, and other metadata. \ +Supported types: JPEG, PNG, GIF, WebP, MP4. \ +The returned URL can be included in messages, or use the file_paths parameter \ +on send_message to upload and attach in one step." + )] + pub async fn upload_file(&self, Parameters(p): Parameters) -> String { + match crate::upload::upload_file( + self.client.http_client(), + self.client.keys(), + &self.client.relay_http_url(), + self.client.api_token(), + self.client.server_domain().as_deref(), + &p.file_path, + ) + .await + { + Ok(desc) => { + serde_json::to_string_pretty(&desc).unwrap_or_else(|e| format!("Error: {e}")) + } + Err(e) => format!("Error: {e}"), + } + } } #[tool_handler] diff --git a/crates/sprout-mcp/src/toolsets.rs b/crates/sprout-mcp/src/toolsets.rs index ed3b067d8c..c6223beeb8 100644 --- a/crates/sprout-mcp/src/toolsets.rs +++ b/crates/sprout-mcp/src/toolsets.rs @@ -103,13 +103,13 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ ("get_event", "social", true), ("get_user_notes", "social", true), ("get_contact_list", "social", true), - // Deferred tools (not yet implemented): upload_file, subscribe, unsubscribe + // ── media ──────────────────────────────────────────────────────────────── + ("upload_file", "media", false), ]; /// Tools planned but not yet implemented. These will be added to ALL_TOOLS /// when their #[tool] handlers are created in server.rs. pub const DEFERRED_TOOLS: &[(&str, &str, bool)] = &[ - ("upload_file", "media", false), ("subscribe", "realtime", true), ("unsubscribe", "realtime", false), ]; @@ -383,13 +383,13 @@ mod tests { } #[test] - fn all_tools_count_is_48() { - assert_eq!(ALL_TOOLS.len(), 48); + fn all_tools_count_is_49() { + assert_eq!(ALL_TOOLS.len(), 49); } #[test] - fn deferred_tools_count_is_3() { - assert_eq!(DEFERRED_TOOLS.len(), 3); + fn deferred_tools_count_is_2() { + assert_eq!(DEFERRED_TOOLS.len(), 2); } #[test] @@ -408,15 +408,16 @@ mod tests { #[test] fn all_toolsets_returns_correct_count() { - // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums, social - // (media and realtime have no implemented tools yet) + // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums, social, media + // (realtime has no implemented tools yet) let defs = all_toolsets(); - assert_eq!(defs.len(), 8); + assert_eq!(defs.len(), 9); let names: Vec<_> = defs.iter().map(|d| d.name).collect(); assert!(names.contains(&"default")); assert!(names.contains(&"canvas")); assert!(names.contains(&"forums")); assert!(names.contains(&"social")); + assert!(names.contains(&"media")); } // ── Cross-check: ALL_TOOLS integrity ──────────────────────────────────── diff --git a/crates/sprout-mcp/src/upload.rs b/crates/sprout-mcp/src/upload.rs new file mode 100644 index 0000000000..a86e148b68 --- /dev/null +++ b/crates/sprout-mcp/src/upload.rs @@ -0,0 +1,419 @@ +//! File upload pipeline for Sprout media (Blossom protocol). +//! +//! Reads a local file, validates it against size/type constraints, computes a SHA-256 +//! hash, signs a kind:24242 Blossom auth event, PUTs the file to the relay's +//! `/media/upload` endpoint, and returns a [`BlobDescriptor`]. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use nostr::util::hex as nostr_hex; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use sha2::{Digest, Sha256}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// Maximum file size for image uploads (50 MB). +pub const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; + +/// Maximum file size for video uploads (500 MB). +pub const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; + +/// MIME types we accept for upload. +const ALLOWED_MIMES: &[&str] = &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", +]; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/// Descriptor returned by the relay after a successful upload. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BlobDescriptor { + /// Public URL of the uploaded blob. + pub url: String, + /// Hex-encoded SHA-256 of the file content. + pub sha256: String, + /// File size in bytes. + pub size: u64, + /// MIME type (e.g. `image/jpeg`). + #[serde(rename = "type")] + pub mime_type: String, + /// Unix timestamp when the file was uploaded. + pub uploaded: i64, + /// Image dimensions as `x` (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub dim: Option, + /// Blurhash placeholder string (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub blurhash: Option, + /// Thumbnail URL (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb: Option, + /// Duration in seconds for video/audio (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +/// Errors that can occur during the upload pipeline. +#[derive(Debug, thiserror::Error)] +pub enum UploadError { + /// The path does not exist on disk. + #[error("file not found: {0}")] + FileNotFound(String), + /// The path exists but is not a regular file. + #[error("not a file: {0}")] + NotAFile(String), + /// File exceeds the size limit for its type. + #[error("file too large: {size} bytes (max {max})")] + FileTooLarge { + /// Actual file size. + size: u64, + /// Maximum allowed size. + max: u64, + }, + /// MIME type is not in the allowlist. + #[error("unsupported file type: {0}")] + UnsupportedFileType(String), + /// Read bytes don't match metadata length. + #[error("size mismatch: metadata says {expected}, read {actual}")] + SizeMismatch { + /// Size reported by filesystem metadata. + expected: u64, + /// Actual number of bytes read. + actual: u64, + }, + /// Filesystem I/O error. + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + /// Nostr event signing failed. + #[error("signing failed: {0}")] + SigningFailed(String), + /// Server returned a non-success status. + #[error("upload rejected ({status}): {body}")] + ServerRejected { + /// HTTP status code. + status: u16, + /// Response body text. + body: String, + }, + /// HTTP transport error. + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + /// Could not parse the server's response as a BlobDescriptor. + #[error("invalid response: {0}")] + InvalidResponse(String), +} + +// ── Upload pipeline ─────────────────────────────────────────────────────────── + +/// Upload a local file to the Sprout relay's media endpoint. +/// +/// Performs validation, SHA-256 hashing, Blossom auth signing, and the HTTP PUT. +/// Returns the relay's [`BlobDescriptor`] on success. +pub async fn upload_file( + http: &reqwest::Client, + keys: &Keys, + relay_http_url: &str, + api_token: Option<&str>, + server_domain: Option<&str>, + file_path: &str, +) -> Result { + // 1. Validate path exists + let metadata = std::fs::metadata(file_path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + UploadError::FileNotFound(file_path.to_string()) + } else { + UploadError::Io(e) + } + })?; + + if !metadata.is_file() { + return Err(UploadError::NotAFile(file_path.to_string())); + } + + let expected_size = metadata.len(); + + // Early rejection: no supported file type exceeds MAX_VIDEO_BYTES. + // This prevents buffering hundreds of MB into RAM before MIME detection. + if expected_size > MAX_VIDEO_BYTES { + return Err(UploadError::FileTooLarge { + size: expected_size, + max: MAX_VIDEO_BYTES, + }); + } + + // 2. Read file into memory + let bytes = std::fs::read(file_path)?; + let actual_size = bytes.len() as u64; + + // Post-read size check + if actual_size != expected_size { + return Err(UploadError::SizeMismatch { + expected: expected_size, + actual: actual_size, + }); + } + + // 3. Detect MIME via magic bytes + let mime = infer::get(&bytes) + .map(|t| t.mime_type()) + .unwrap_or("application/octet-stream"); + + if !ALLOWED_MIMES.contains(&mime) { + return Err(UploadError::UnsupportedFileType(mime.to_string())); + } + + // 4. Pre-check file size against type-specific limits + let max_size = if mime.starts_with("video/") { + MAX_VIDEO_BYTES + } else { + MAX_IMAGE_BYTES + }; + + if actual_size > max_size { + return Err(UploadError::FileTooLarge { + size: actual_size, + max: max_size, + }); + } + + // 5. Compute SHA-256 + let sha256 = nostr_hex::encode(Sha256::digest(&bytes)); + + // 6. Sign Blossom auth event (kind:24242) + let now = Timestamp::now().as_u64(); + let expiry = if mime.starts_with("video/") { + 3600 + } else { + 600 + }; + let exp_str = (now + expiry).to_string(); + + let mut tags = vec![ + Tag::parse(&["t", "upload"]).map_err(|e| UploadError::SigningFailed(e.to_string()))?, + Tag::parse(&["x", &sha256]).map_err(|e| UploadError::SigningFailed(e.to_string()))?, + Tag::parse(&["expiration", &exp_str]) + .map_err(|e| UploadError::SigningFailed(e.to_string()))?, + ]; + if let Some(domain) = server_domain { + tags.push( + Tag::parse(&["server", domain]) + .map_err(|e| UploadError::SigningFailed(e.to_string()))?, + ); + } + + let auth_event = EventBuilder::new(Kind::from(24242), "Upload file", tags) + .sign_with_keys(keys) + .map_err(|e| UploadError::SigningFailed(e.to_string()))?; + + // 7. Base64url encode the auth event + let auth_header = format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) + ); + + // 8. HTTP PUT — with a generous per-request timeout. + // The shared reqwest client has a 10s timeout suitable for REST API calls, + // but uploads can take minutes for large files. Override per-request. + let upload_timeout = if mime.starts_with("video/") { + std::time::Duration::from_secs(600) // 10 min for video (up to 500 MB) + } else { + std::time::Duration::from_secs(120) // 2 min for images (up to 50 MB) + }; + + let url = format!("{}/media/upload", relay_http_url.trim_end_matches('/')); + let mut req = http + .put(&url) + .timeout(upload_timeout) + .header("Authorization", &auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", &sha256); + + if let Some(token) = api_token { + req = req.header("X-Auth-Token", token); + } + + let resp = req.body(bytes).send().await?; + + // 9. Handle response + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(UploadError::ServerRejected { + status: status.as_u16(), + body, + }); + } + + let body = resp.text().await?; + serde_json::from_str::(&body) + .map_err(|e| UploadError::InvalidResponse(format!("{e}: {body}"))) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Build a NIP-92 `imeta` tag from a [`BlobDescriptor`]. +/// +/// The returned `Vec` is suitable for passing to `Tag::parse`. +pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { + let mut tag = vec![ + "imeta".to_string(), + format!("url {}", d.url), + format!("m {}", d.mime_type), + format!("x {}", d.sha256), + format!("size {}", d.size), + ]; + if let Some(ref dim) = d.dim { + tag.push(format!("dim {dim}")); + } + if let Some(ref bh) = d.blurhash { + tag.push(format!("blurhash {bh}")); + } + if let Some(ref th) = d.thumb { + tag.push(format!("thumb {th}")); + } + if let Some(dur) = d.duration { + tag.push(format!("duration {dur}")); + } + tag +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn image_descriptor() -> BlobDescriptor { + BlobDescriptor { + url: "https://relay.example.com/media/abc123.jpg".to_string(), + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(), + size: 12345, + mime_type: "image/jpeg".to_string(), + uploaded: 1700000000, + dim: Some("1920x1080".to_string()), + blurhash: Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string()), + thumb: Some("https://relay.example.com/media/abc123_thumb.jpg".to_string()), + duration: None, + } + } + + fn video_descriptor() -> BlobDescriptor { + BlobDescriptor { + url: "https://relay.example.com/media/vid456.mp4".to_string(), + sha256: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(), + size: 5_000_000, + mime_type: "video/mp4".to_string(), + uploaded: 1700000000, + dim: Some("1280x720".to_string()), + blurhash: None, + thumb: None, + duration: Some(42.5), + } + } + + fn minimal_descriptor() -> BlobDescriptor { + BlobDescriptor { + url: "https://relay.example.com/media/min.png".to_string(), + sha256: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + size: 100, + mime_type: "image/png".to_string(), + uploaded: 1700000000, + dim: None, + blurhash: None, + thumb: None, + duration: None, + } + } + + #[test] + fn test_build_imeta_tag_image() { + let d = image_descriptor(); + let tag = build_imeta_tag(&d); + + assert_eq!(tag[0], "imeta"); + assert_eq!(tag[1], "url https://relay.example.com/media/abc123.jpg"); + assert_eq!(tag[2], "m image/jpeg"); + assert_eq!( + tag[3], + "x e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!(tag[4], "size 12345"); + assert_eq!(tag[5], "dim 1920x1080"); + assert_eq!(tag[6], "blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj"); + assert_eq!( + tag[7], + "thumb https://relay.example.com/media/abc123_thumb.jpg" + ); + // No duration for images + assert_eq!(tag.len(), 8); + } + + #[test] + fn test_build_imeta_tag_video() { + let d = video_descriptor(); + let tag = build_imeta_tag(&d); + + assert_eq!(tag[0], "imeta"); + assert_eq!(tag[1], "url https://relay.example.com/media/vid456.mp4"); + assert_eq!(tag[2], "m video/mp4"); + assert_eq!( + tag[3], + "x abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + ); + assert_eq!(tag[4], "size 5000000"); + assert_eq!(tag[5], "dim 1280x720"); + // No blurhash or thumb + assert_eq!(tag[6], "duration 42.5"); + assert_eq!(tag.len(), 7); + } + + #[test] + fn test_build_imeta_tag_minimal() { + let d = minimal_descriptor(); + let tag = build_imeta_tag(&d); + + assert_eq!(tag.len(), 5); + assert_eq!(tag[0], "imeta"); + assert_eq!(tag[1], "url https://relay.example.com/media/min.png"); + assert_eq!(tag[2], "m image/png"); + assert_eq!( + tag[3], + "x 0000000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!(tag[4], "size 100"); + } + + #[test] + fn test_mime_allowlist() { + // Allowed types + assert!(ALLOWED_MIMES.contains(&"image/jpeg")); + assert!(ALLOWED_MIMES.contains(&"image/png")); + assert!(ALLOWED_MIMES.contains(&"image/gif")); + assert!(ALLOWED_MIMES.contains(&"image/webp")); + assert!(ALLOWED_MIMES.contains(&"video/mp4")); + + // Rejected types + assert!(!ALLOWED_MIMES.contains(&"application/pdf")); + assert!(!ALLOWED_MIMES.contains(&"text/plain")); + assert!(!ALLOWED_MIMES.contains(&"image/svg+xml")); + assert!(!ALLOWED_MIMES.contains(&"video/webm")); + assert!(!ALLOWED_MIMES.contains(&"application/octet-stream")); + } + + #[test] + fn test_file_size_limits() { + // Image limit: 50 MB + assert_eq!(MAX_IMAGE_BYTES, 50 * 1024 * 1024); + assert_eq!(MAX_IMAGE_BYTES, 52_428_800); + + // Video limit: 500 MB + assert_eq!(MAX_VIDEO_BYTES, 500 * 1024 * 1024); + assert_eq!(MAX_VIDEO_BYTES, 524_288_000); + + // Video limit is 10x image limit + assert_eq!(MAX_VIDEO_BYTES, MAX_IMAGE_BYTES * 10); + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 1954f09a5b..991d8f3fc8 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -551,7 +551,7 @@ pub fn spawn_agent_child( if let Some(toolsets) = &record.mcp_toolsets { command.env("SPROUT_TOOLSETS", toolsets); } else { - command.env_remove("SPROUT_TOOLSETS"); + command.env("SPROUT_TOOLSETS", "default,media"); } command.env_remove("SPROUT_ACP_PRIVATE_KEY"); command.env_remove("SPROUT_ACP_API_TOKEN");