feat: Image & video uploads for MCP and CLI (Blossom protocol) (#433)

Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
This commit is contained in:
tlongwell-block
2026-04-30 14:35:02 -04:00
committed by GitHub
parent f29f790f15
commit f23560d3d4
12 changed files with 848 additions and 46 deletions

7
Cargo.lock generated
View File

@@ -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",

View File

@@ -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" }

View File

@@ -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 `<width>x<height>` (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub dim: Option<String>,
/// Blurhash placeholder string (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub blurhash: Option<String>,
/// Thumbnail URL (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb: Option<String>,
/// Duration in seconds for video/audio (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
/// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata).
pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
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<BlobDescriptor, CliError> {
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::<BlobDescriptor>()
.await
.map_err(|e| CliError::Other(format!("invalid upload response: {e}")))
}
// -----------------------------------------------------------------------
// Convenience wrappers — print response to stdout
// -----------------------------------------------------------------------

View File

@@ -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<u16>,
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<u16>,
pub reply_to: Option<String>,
pub broadcast: bool,
pub mentions: Vec<String>,
pub files: Vec<String>,
}
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<String>> = 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<String> = normalize_mention_pubkeys(mentions, "");
let auto_resolved = resolve_content_mentions(client, channel_id, content).await;
let mut merged: Vec<String> = 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}")))?;

View File

@@ -55,6 +55,9 @@ enum Cmd {
broadcast: bool,
#[arg(long = "mention")]
mentions: Vec<String>,
/// Attach file(s) — uploads and includes as imeta tags
#[arg(long = "file")]
files: Vec<String>,
},
/// 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
);

View File

@@ -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 }

View File

@@ -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;

View File

@@ -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<String> {
// 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 <token>` (production mode).

View File

@@ -178,6 +178,10 @@ pub struct SendMessageParams {
/// Pubkeys to @mention in the message.
#[serde(default)]
pub mention_pubkeys: Option<Vec<String>>,
/// 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<Vec<String>>,
}
fn default_kind() -> Option<u16> {
Some(sprout_core::kind::KIND_STREAM_MESSAGE as u16)
@@ -805,6 +809,13 @@ fn infer_language(file_path: &str) -> Option<String> {
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<String>> = 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<UploadFileParams>) -> 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]

View File

@@ -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 ────────────────────────────────────

View File

@@ -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 `<width>x<height>` (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub dim: Option<String>,
/// Blurhash placeholder string (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub blurhash: Option<String>,
/// Thumbnail URL (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb: Option<String>,
/// Duration in seconds for video/audio (optional).
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}
/// 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<BlobDescriptor, UploadError> {
// 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::<BlobDescriptor>(&body)
.map_err(|e| UploadError::InvalidResponse(format!("{e}: {body}")))
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Build a NIP-92 `imeta` tag from a [`BlobDescriptor`].
///
/// The returned `Vec<String>` is suitable for passing to `Tag::parse`.
pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec<String> {
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);
}
}

View File

@@ -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");