feat: Blossom-compatible media upload (sprout-media crate + relay + desktop) (#66)

This commit is contained in:
tlongwell-block
2026-03-15 15:17:10 -04:00
committed by GitHub
parent ed34600e5b
commit 2b08ad80d0
43 changed files with 4730 additions and 77 deletions

728
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ members = [
"crates/sprout-test-client",
"crates/sprout-admin",
"crates/sprout-workflow",
"crates/sprout-media",
]
exclude = ["desktop/src-tauri"]
resolver = "2"
@@ -105,3 +106,4 @@ sprout-mcp = { path = "crates/sprout-mcp" }
sprout-proxy = { path = "crates/sprout-proxy" }
sprout-huddle = { path = "crates/sprout-huddle" }
sprout-workflow = { path = "crates/sprout-workflow" }
sprout-media = { path = "crates/sprout-media" }

View File

@@ -26,6 +26,8 @@ pub enum AuditAction {
AuthFailure,
/// A client exceeded the rate limit.
RateLimitExceeded,
/// A media file was uploaded via the Blossom endpoint.
MediaUploaded,
}
impl AuditAction {
@@ -42,6 +44,7 @@ impl AuditAction {
Self::AuthSuccess => "auth_success",
Self::AuthFailure => "auth_failure",
Self::RateLimitExceeded => "rate_limit_exceeded",
Self::MediaUploaded => "media_uploaded",
}
}
@@ -56,6 +59,7 @@ impl AuditAction {
Self::AuthSuccess,
Self::AuthFailure,
Self::RateLimitExceeded,
Self::MediaUploaded,
];
}

View File

@@ -203,6 +203,10 @@ pub const KIND_HUDDLE_TRACK_PUBLISHED: u32 = 48104;
/// A huddle recording became available.
pub const KIND_HUDDLE_RECORDING_AVAILABLE: u32 = 48105;
// Media (49000–49999)
/// Internal kind for media upload audit entries. Not a relay event kind.
pub const KIND_MEDIA_UPLOAD: u32 = 49001;
/// All registered kind constants — used for duplicate detection and iteration.
pub const ALL_KINDS: &[u32] = &[
KIND_PROFILE,
@@ -284,6 +288,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_HUDDLE_ENDED,
KIND_HUDDLE_TRACK_PUBLISHED,
KIND_HUDDLE_RECORDING_AVAILABLE,
KIND_MEDIA_UPLOAD,
];
/// Returns `true` if `kind` is in the ephemeral range (20000–29999).

View File

@@ -0,0 +1,30 @@
[package]
name = "sprout-media"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Media storage, validation, and thumbnail generation for Sprout"
[dependencies]
sprout-core = { workspace = true }
nostr = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
thiserror = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
chrono = { workspace = true }
axum = { workspace = true }
s3 = { version = "0.35", package = "rust-s3" }
infer = "0.16"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
blurhash = "0.2"
imagesize = "0.13"
bytes = "1"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }

View File

@@ -0,0 +1,280 @@
//! Blossom kind:24242 auth verification (BUD-11 compliant).
use crate::error::MediaError;
/// Verify kind:24242 event validity per BUD-11:
/// 1. Schnorr signature
/// 2. kind == 24242
/// 3. `t` tag == "upload"
/// 4. `expiration` tag in the future
/// 5. `created_at` in the past (with 5s clock-skew tolerance)
/// 6. If `server` tags present, our domain must appear in at least one
///
/// Does NOT check the `x` tag — that requires the body hash, computed later.
/// Call this BEFORE trusting the event's pubkey for scope resolution.
pub fn verify_blossom_auth_event(
auth_event: &nostr::Event,
server_domain: Option<&str>,
) -> Result<(), MediaError> {
// 1. Verify Schnorr signature
auth_event
.verify()
.map_err(|_| MediaError::InvalidSignature)?;
// 2. Kind must be 24242
if auth_event.kind.as_u16() != 24242 {
return Err(MediaError::InvalidAuthKind);
}
// 2b. Content must be non-empty (BUD-11: "human readable string")
if auth_event.content.trim().is_empty() {
return Err(MediaError::InvalidAuthEvent);
}
let mut found_t = false;
let mut found_exp = false;
let mut server_tags: Vec<&str> = Vec::new();
let mut exp_value: u64 = 0;
for tag in auth_event.tags.iter() {
let kind = tag.kind().to_string();
match kind.as_str() {
"t" => {
if let Some(v) = tag.content() {
if v != "upload" {
return Err(MediaError::InvalidAuthVerb);
}
found_t = true;
}
}
"expiration" => {
if let Some(v) = tag.content() {
exp_value = v.parse().unwrap_or(0);
found_exp = true;
}
}
"server" => {
if let Some(v) = tag.content() {
server_tags.push(v);
}
}
_ => {}
}
}
// 3. t tag required
if !found_t {
return Err(MediaError::MissingTag("t"));
}
// 4. Expiration must exist and be in the future
if !found_exp {
return Err(MediaError::MissingTag("expiration"));
}
let now = nostr::Timestamp::now().as_u64();
if exp_value <= now {
return Err(MediaError::TokenExpired);
}
// 5. created_at must be recent: not in the future (5s tolerance) and not
// older than 10 minutes. This bounds the replay window — even if the
// expiration tag allows a longer lifetime, the token must have been
// freshly minted.
let created = auth_event.created_at.as_u64();
if created > now + 5 {
return Err(MediaError::TimestampOutOfWindow);
}
const MAX_AGE_SECS: u64 = 600; // 10 minutes
if now > created + MAX_AGE_SECS {
return Err(MediaError::TimestampOutOfWindow);
}
// 6. Server tag enforcement (BUD-11 §5): if server tags present, our domain must appear.
// Fail closed: if server_domain is unconfigured, reject tokens that carry server tags
// rather than silently accepting them.
if !server_tags.is_empty() {
match server_domain {
Some(domain) => {
if !server_tags.contains(&domain) {
return Err(MediaError::ServerMismatch);
}
}
None => {
// Server tags present but we don't know our own domain — reject.
return Err(MediaError::ServerMismatch);
}
}
}
Ok(())
}
/// Verify a kind:24242 Blossom upload auth event, including the x tag hash check.
///
/// Calls [`verify_blossom_auth_event`] first, then verifies that at least one
/// `x` tag matches `sha256` (BUD-11 §6: "at least one x tag matches").
pub fn verify_blossom_upload_auth(
auth_event: &nostr::Event,
sha256: &str,
server_domain: Option<&str>,
) -> Result<(), MediaError> {
verify_blossom_auth_event(auth_event, server_domain)?;
// At least one x tag must match the body sha256 (BUD-11 §6)
let has_matching_x = auth_event
.tags
.iter()
.any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(sha256)));
if !has_matching_x {
return Err(MediaError::HashMismatch);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
fn build_valid_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_u64();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", sha256]).unwrap(),
Tag::parse(&["expiration", &exp_str]).unwrap(),
];
EventBuilder::new(Kind::from(24242), "Upload sprout-media", tags)
.sign_with_keys(keys)
.unwrap()
}
#[test]
fn test_verify_valid() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
assert!(verify_blossom_upload_auth(&event, &sha256, None).is_ok());
}
#[test]
fn test_verify_auth_event_valid() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
assert!(verify_blossom_auth_event(&event, None).is_ok());
}
#[test]
fn test_verify_hash_mismatch() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
let wrong_hash = "b".repeat(64);
assert!(matches!(
verify_blossom_upload_auth(&event, &wrong_hash, None),
Err(MediaError::HashMismatch)
));
}
#[test]
fn test_verify_wrong_kind() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_u64();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &exp_str]).unwrap(),
];
let event = EventBuilder::new(Kind::from(27235), "wrong kind", tags)
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, None),
Err(MediaError::InvalidAuthKind)
));
}
#[test]
fn test_verify_multi_x_tags() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let other_hash = "b".repeat(64);
let now = Timestamp::now().as_u64();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &other_hash]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &exp_str]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload multi-x", tags)
.sign_with_keys(&keys)
.unwrap();
// Should pass because at least one x tag matches
assert!(verify_blossom_upload_auth(&event, &sha256, None).is_ok());
}
#[test]
fn test_server_tag_enforcement() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_u64();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &exp_str]).unwrap(),
Tag::parse(&["server", "other.example.com"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload scoped", tags)
.sign_with_keys(&keys)
.unwrap();
// Should fail — server tag present but doesn't match our domain
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, Some("sprout.example.com")),
Err(MediaError::ServerMismatch)
));
// Should pass when our domain matches
assert!(verify_blossom_upload_auth(&event, &sha256, Some("other.example.com")).is_ok());
// Should fail when server_domain is None — fail closed
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, None),
Err(MediaError::ServerMismatch)
));
}
#[test]
fn test_no_server_tags_always_passes() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
// No server tags → passes regardless of our domain
assert!(verify_blossom_upload_auth(&event, &sha256, Some("any.domain.com")).is_ok());
}
#[test]
fn test_empty_content_rejected() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_u64();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &exp_str]).unwrap(),
];
// Empty content — BUD-11 requires a human-readable string
let event = EventBuilder::new(Kind::from(24242), "", tags)
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_auth_event(&event, None),
Err(MediaError::InvalidAuthEvent)
));
}
}

View File

@@ -0,0 +1,51 @@
//! Media storage configuration.
/// Configuration for media storage (S3/MinIO).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct MediaConfig {
/// S3-compatible endpoint URL (e.g. "http://localhost:9000").
pub s3_endpoint: String,
/// S3 access key.
pub s3_access_key: String,
/// S3 secret key.
pub s3_secret_key: String,
/// S3 bucket name.
pub s3_bucket: String,
/// Maximum upload size for images (bytes). Default: 50 MB.
pub max_image_bytes: u64,
/// Maximum upload size for animated GIFs (bytes). Default: 10 MB.
pub max_gif_bytes: u64,
/// Public base URL for media URLs in BlobDescriptor (must include `/media` path).
pub public_base_url: String,
/// Server authority for BUD-11 server tag validation.
/// Format: `host` for default ports, `host:port` for non-default ports.
/// Examples: "sprout.example.com", "localhost:3000", "relay.example.com:8080".
/// If None, auth events carrying `server` tags are rejected (fail-closed).
/// Must match the authority the desktop signer derives from the relay URL.
pub server_domain: Option<String>,
}
impl MediaConfig {
/// Validate configuration at startup. Returns an error on invalid config.
pub fn validate(&self) -> Result<(), String> {
if !self.public_base_url.ends_with("/media") {
return Err(format!(
"public_base_url must end with /media: got '{}'",
self.public_base_url
));
}
if self.public_base_url.ends_with('/') {
return Err(format!(
"public_base_url must not end with /: got '{}'",
self.public_base_url
));
}
if self.max_image_bytes == 0 {
return Err("max_image_bytes must be > 0".to_string());
}
if self.max_gif_bytes == 0 || self.max_gif_bytes > self.max_image_bytes {
return Err("max_gif_bytes must be > 0 and <= max_image_bytes".to_string());
}
Ok(())
}
}

View File

@@ -0,0 +1,122 @@
//! Media error types.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
/// Errors from media operations.
#[derive(Debug, thiserror::Error)]
pub enum MediaError {
#[error("unknown content type")]
UnknownContentType,
#[error("disallowed content type: {0}")]
DisallowedContentType(String),
#[error("file too large: {size} bytes (max {max})")]
FileTooLarge { size: u64, max: u64 },
#[error("image dimensions too large")]
ImageTooLarge,
#[error("invalid image data")]
InvalidImage,
#[error("invalid signature")]
InvalidSignature,
#[error("invalid auth event kind")]
InvalidAuthKind,
#[error("invalid auth verb")]
InvalidAuthVerb,
#[error("missing required tag: {0}")]
MissingTag(&'static str),
#[error("hash mismatch")]
HashMismatch,
#[error("server mismatch")]
ServerMismatch,
#[error("token expired")]
TokenExpired,
#[error("timestamp out of window")]
TimestampOutOfWindow,
#[error("storage error: {0}")]
StorageError(String),
#[error("internal error")]
Internal,
#[error("not found")]
NotFound,
#[error("missing authorization header")]
MissingAuth,
#[error("invalid authorization scheme")]
InvalidAuthScheme,
#[error("invalid base64 encoding")]
InvalidBase64,
#[error("invalid auth event")]
InvalidAuthEvent,
#[error("unauthorized")]
Unauthorized,
#[error("insufficient scope")]
InsufficientScope,
#[error("token revoked")]
TokenRevoked,
#[error("pubkey mismatch")]
PubkeyMismatch,
}
impl From<image::ImageError> for MediaError {
fn from(_: image::ImageError) -> Self {
Self::InvalidImage
}
}
impl From<s3::error::S3Error> for MediaError {
fn from(e: s3::error::S3Error) -> Self {
Self::StorageError(e.to_string())
}
}
impl From<serde_json::Error> for MediaError {
fn from(e: serde_json::Error) -> Self {
Self::StorageError(e.to_string())
}
}
impl IntoResponse for MediaError {
fn into_response(self) -> Response {
let (status, msg) = match &self {
Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
Self::DisallowedContentType(_) => {
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
}
Self::FileTooLarge { .. } | Self::ImageTooLarge => {
(StatusCode::PAYLOAD_TOO_LARGE, self.to_string())
}
// All authentication failures return the same generic 401 to prevent oracle enumeration.
// InsufficientScope is intentionally 403 — it's an authorization (not authentication)
// failure and is safe to distinguish since it requires a valid identity first.
Self::MissingAuth
| Self::InvalidAuthScheme
| Self::InvalidBase64
| Self::InvalidAuthEvent
| Self::InvalidSignature
| Self::InvalidAuthKind
| Self::InvalidAuthVerb
| Self::TokenExpired
| Self::TimestampOutOfWindow
| Self::Unauthorized
| Self::TokenRevoked
| Self::PubkeyMismatch
| Self::HashMismatch
| Self::ServerMismatch
| Self::MissingTag(_) => {
tracing::warn!(error = %self, "authentication failed");
(
StatusCode::UNAUTHORIZED,
"authentication failed".to_string(),
)
}
Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()),
Self::UnknownContentType | Self::InvalidImage => {
(StatusCode::BAD_REQUEST, self.to_string())
}
Self::StorageError(_) | Self::Internal => {
tracing::error!(error = %self, "media storage error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}
};
(status, axum::Json(serde_json::json!({"error": msg}))).into_response()
}
}

View File

@@ -0,0 +1,18 @@
//! Media storage, validation, and thumbnail generation for Sprout.
//!
//! Library crate — no Axum dependency for handlers. Axum handlers live in `sprout-relay`.
pub mod auth;
pub mod config;
pub mod error;
pub mod storage;
pub mod thumbnail;
pub mod types;
pub mod upload;
pub mod validation;
pub use config::MediaConfig;
pub use error::MediaError;
pub use storage::{BlobHeadMeta, BlobMeta, MediaStorage};
pub use types::BlobDescriptor;
pub use upload::process_upload;

View File

@@ -0,0 +1,120 @@
//! S3/MinIO storage client.
use s3::creds::Credentials;
use s3::{Bucket, Region};
use serde::{Deserialize, Serialize};
use crate::config::MediaConfig;
use crate::error::MediaError;
/// S3-compatible object storage client.
pub struct MediaStorage {
bucket: Box<Bucket>,
}
impl MediaStorage {
/// Create a new storage client from config.
pub fn new(config: &MediaConfig) -> Result<Self, MediaError> {
let region = Region::Custom {
region: "us-east-1".into(),
endpoint: config.s3_endpoint.clone(),
};
let creds = Credentials::new(
Some(&config.s3_access_key),
Some(&config.s3_secret_key),
None,
None,
None,
)
.map_err(|e| MediaError::StorageError(e.to_string()))?;
let bucket = Bucket::new(&config.s3_bucket, region, creds)
.map_err(|e| MediaError::StorageError(e.to_string()))?
.with_path_style();
Ok(Self { bucket })
}
/// Store an object.
pub async fn put(&self, key: &str, bytes: &[u8], content_type: &str) -> Result<(), MediaError> {
self.bucket
.put_object_with_content_type(key, bytes, content_type)
.await?;
Ok(())
}
/// Retrieve an object's bytes.
pub async fn get(&self, key: &str) -> Result<Vec<u8>, MediaError> {
match self.bucket.get_object(key).await {
Ok(response) => Ok(response.to_vec()),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Check if an object exists. Returns false on 404.
pub async fn head(&self, key: &str) -> Result<bool, MediaError> {
match self.bucket.head_object(key).await {
Ok(_) => Ok(true),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(false),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Delete an object. Returns an error on failure — callers decide whether to propagate.
pub async fn delete(&self, key: &str) -> Result<(), MediaError> {
self.bucket
.delete_object(key)
.await
.map_err(|e| MediaError::StorageError(e.to_string()))?;
Ok(())
}
/// HEAD with metadata — returns Content-Length (size).
pub async fn head_with_metadata(&self, key: &str) -> Result<Option<BlobHeadMeta>, MediaError> {
match self.bucket.head_object(key).await {
Ok((result, _)) => Ok(Some(BlobHeadMeta {
size: result.content_length.unwrap_or(0) as u64,
})),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Ok(None),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Read sidecar JSON for a given sha256 (bare hash, no extension).
pub async fn get_sidecar(&self, sha256: &str) -> Result<BlobMeta, MediaError> {
let key = format!("_meta/{sha256}.json");
let resp = self.bucket.get_object(&key).await?;
let meta: BlobMeta = serde_json::from_slice(&resp.to_vec())?;
Ok(meta)
}
/// Convenience: read just the MIME type from the sidecar.
pub async fn read_sidecar_mime(&self, sha256_ext: &str) -> Option<String> {
let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext);
self.get_sidecar(sha256).await.ok().map(|m| m.mime_type)
}
}
/// Metadata returned by HEAD — just enough for BUD-01 response headers.
pub struct BlobHeadMeta {
pub size: u64,
}
/// Full blob metadata — stored as sidecar JSON in `_meta/{sha256}.json`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BlobMeta {
/// Pixel dimensions ("WxH").
pub dim: String,
/// Blurhash string.
pub blurhash: String,
/// Full URL to thumbnail.
pub thumb_url: String,
/// File extension (e.g. "jpg").
pub ext: String,
/// MIME type (e.g. "image/jpeg").
pub mime_type: String,
/// File size in bytes.
pub size: u64,
/// Unix timestamp when the blob was first uploaded.
#[serde(default)]
pub uploaded_at: i64,
}

View File

@@ -0,0 +1,51 @@
//! Synchronous thumbnail generation and blurhash encoding.
use std::io::Cursor;
use image::ImageFormat;
use crate::config::MediaConfig;
use crate::error::MediaError;
use crate::storage::BlobMeta;
/// Generate thumbnail and blurhash from image bytes (CPU-bound, sync).
///
/// Returns `(metadata, optional thumbnail JPEG bytes)`.
/// Caller handles S3 writes after `spawn_blocking` returns.
pub fn generate_image_metadata_sync(
config: &MediaConfig,
sha256: &str,
bytes: &[u8],
mime: &str,
ext: &str,
) -> Result<(BlobMeta, Option<Vec<u8>>), MediaError> {
if !mime.starts_with("image/") {
return Ok((BlobMeta::default(), None));
}
let img = image::load_from_memory(bytes)?;
let (w, h) = (img.width(), img.height());
// Thumbnail: 320px max dimension, preserve aspect ratio
let thumb = img.thumbnail(320, 320);
let mut thumb_bytes = Vec::new();
thumb.write_to(&mut Cursor::new(&mut thumb_bytes), ImageFormat::Jpeg)?;
// Blurhash from thumbnail (faster than full image)
let rgba = thumb.to_rgba8();
let bh =
blurhash::encode(4, 3, thumb.width(), thumb.height(), rgba.as_raw()).unwrap_or_default();
Ok((
BlobMeta {
dim: format!("{w}x{h}"),
blurhash: bh,
thumb_url: format!("{}/{sha256}.thumb.jpg", config.public_base_url),
ext: ext.to_string(),
mime_type: mime.to_string(),
size: bytes.len() as u64,
..BlobMeta::default() // uploaded_at set by caller (process_upload)
},
Some(thumb_bytes),
))
}

View File

@@ -0,0 +1,28 @@
//! Blossom BUD-02 response types.
use serde::{Deserialize, Serialize};
/// Blossom BlobDescriptor — returned by PUT /media/upload.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobDescriptor {
/// Full URL to the blob.
pub url: String,
/// SHA-256 hex hash (64 chars).
pub sha256: String,
/// File size in bytes.
pub size: u64,
/// MIME type.
#[serde(rename = "type")]
pub mime_type: String,
/// Unix timestamp of upload.
pub uploaded: i64,
/// Pixel dimensions ("WxH").
#[serde(skip_serializing_if = "Option::is_none")]
pub dim: Option<String>,
/// Blurhash string.
#[serde(skip_serializing_if = "Option::is_none")]
pub blurhash: Option<String>,
/// Thumbnail URL.
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb: Option<String>,
}

View File

@@ -0,0 +1,142 @@
//! Upload pipeline — validate, store, thumbnail, sidecar.
use bytes::Bytes;
use sha2::{Digest, Sha256};
use crate::auth::verify_blossom_upload_auth;
use crate::config::MediaConfig;
use crate::error::MediaError;
use crate::storage::{BlobMeta, MediaStorage};
use crate::thumbnail::generate_image_metadata_sync;
use crate::types::BlobDescriptor;
use crate::validation::{mime_to_ext, validate_content};
/// Process an upload end-to-end: validate, store, thumbnail, return descriptor.
pub async fn process_upload(
storage: &MediaStorage,
config: &MediaConfig,
auth_event: &nostr::Event,
body: Bytes,
) -> Result<BlobDescriptor, MediaError> {
// CPU-bound: validate content, compute hash, verify auth
let auth = auth_event.clone();
let bytes = body.clone();
let cfg = config.clone();
let (mime, sha256, ext) = tokio::task::spawn_blocking(move || -> Result<_, MediaError> {
let mime = validate_content(&bytes, &cfg)?;
let sha256 = hex::encode(Sha256::digest(&bytes));
let ext = mime_to_ext(&mime).to_string();
verify_blossom_upload_auth(&auth, &sha256, cfg.server_domain.as_deref())?;
Ok((mime, sha256, ext))
})
.await
.map_err(|_| MediaError::Internal)??;
let key = format!("{sha256}.{ext}");
let meta_key = format!("_meta/{sha256}.json"); // used in idempotency check below
// Idempotent: check BOTH sidecar AND blob exist before short-circuiting.
// If sidecar exists but blob is missing, fall through to re-upload.
let sidecar_exists = storage.head(&meta_key).await?;
let blob_exists = storage.head(&key).await?;
if sidecar_exists && blob_exists {
let meta = storage.get_sidecar(&sha256).await?;
return Ok(build_descriptor(
config,
&sha256,
&ext,
&mime,
body.len() as u64,
Some(&meta),
meta.uploaded_at,
));
}
// Compute uploaded_at once — single source of truth for sidecar and response.
let uploaded_at = chrono::Utc::now().timestamp();
// Store blob first, then generate metadata.
// On failure we intentionally do NOT delete the orphan blob — concurrent
// uploads of the same hash could race and delete a blob that another
// request is about to reference via its sidecar. Orphan blobs are
// content-addressed and bounded by the upload size limit, so the storage
// cost is negligible. A V2 background GC job can sweep blobs with no
// matching sidecar after a grace period.
storage.put(&key, &body, &mime).await?;
match generate_and_store_metadata(storage, config, &sha256, &ext, &mime, &body, uploaded_at)
.await
{
Ok(meta) => Ok(build_descriptor(
config,
&sha256,
&ext,
&mime,
body.len() as u64,
Some(&meta),
uploaded_at,
)),
Err(e) => {
tracing::warn!(sha256 = %sha256, "metadata generation failed; orphan blob left for GC");
Err(e)
}
}
}
/// Generate thumbnail, blurhash, and sidecar metadata, then store them.
/// Returns the completed [`BlobMeta`] on success.
async fn generate_and_store_metadata(
storage: &MediaStorage,
config: &MediaConfig,
sha256: &str,
ext: &str,
mime: &str,
body: &Bytes,
uploaded_at: i64,
) -> Result<BlobMeta, MediaError> {
let body_ref = body.clone();
let mime_ref = mime.to_string();
let ext_ref = ext.to_string();
let sha256_ref = sha256.to_string();
let cfg_ref = config.clone();
let (mut meta, thumb_bytes) = tokio::task::spawn_blocking(move || {
generate_image_metadata_sync(&cfg_ref, &sha256_ref, &body_ref, &mime_ref, &ext_ref)
})
.await
.map_err(|_| MediaError::Internal)??;
meta.uploaded_at = uploaded_at;
if let Some(ref tb) = thumb_bytes {
let thumb_key = format!("{sha256}.thumb.jpg");
storage.put(&thumb_key, tb, "image/jpeg").await?;
}
let meta_key = format!("_meta/{sha256}.json");
let meta_json = serde_json::to_vec(&meta)?;
storage
.put(&meta_key, &meta_json, "application/json")
.await?;
Ok(meta)
}
fn build_descriptor(
config: &MediaConfig,
sha256: &str,
ext: &str,
mime: &str,
size: u64,
meta: Option<&BlobMeta>,
uploaded_at: i64,
) -> BlobDescriptor {
BlobDescriptor {
url: format!("{}/{sha256}.{ext}", config.public_base_url),
sha256: sha256.to_string(),
size,
mime_type: mime.to_string(),
uploaded: uploaded_at,
dim: meta.map(|m| m.dim.clone()),
blurhash: meta.map(|m| m.blurhash.clone()),
thumb: meta.map(|m| m.thumb_url.clone()),
}
}

View File

@@ -0,0 +1,166 @@
//! Content validation — magic bytes, allowlist, size, image bomb protection.
use crate::config::MediaConfig;
use crate::error::MediaError;
/// V1: images only. Video deferred to v2.
const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
/// Validate uploaded bytes: magic bytes, allowlist, size, pixel dimensions.
pub fn validate_content(bytes: &[u8], config: &MediaConfig) -> Result<String, MediaError> {
// 1. Magic bytes — never trust Content-Type header
let mime = infer::get(bytes)
.map(|t| t.mime_type().to_string())
.ok_or(MediaError::UnknownContentType)?;
// 2. Allowlist (SVG, PDF, executables all rejected)
if !ALLOWED_MIME_TYPES.contains(&mime.as_str()) {
return Err(MediaError::DisallowedContentType(mime));
}
// 3. Size — GIF-specific cap (animated GIFs are CPU-intensive)
let max = if mime == "image/gif" {
config.max_gif_bytes
} else {
config.max_image_bytes
};
if bytes.len() as u64 > max {
return Err(MediaError::FileTooLarge {
size: bytes.len() as u64,
max,
});
}
// 4. Image bomb — check pixel dimensions before full decode.
// Fail closed for all accepted types: imagesize supports JPEG, PNG, GIF, WebP.
// If dimensions can't be parsed, reject — don't let unknown-geometry images
// reach the full decoder in thumbnail generation.
const MAX_PIXELS: u64 = 25_000_000; // 25 megapixels — 100MB max RGBA decode
let size = imagesize::blob_size(bytes).map_err(|_| MediaError::InvalidImage)?;
if (size.width as u64) * (size.height as u64) > MAX_PIXELS {
return Err(MediaError::ImageTooLarge);
}
Ok(mime)
}
/// Map MIME type to file extension.
pub fn mime_to_ext(mime: &str) -> &'static str {
match mime {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "bin",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> MediaConfig {
MediaConfig {
s3_endpoint: String::new(),
s3_access_key: String::new(),
s3_secret_key: String::new(),
s3_bucket: String::new(),
max_image_bytes: 50 * 1024 * 1024,
max_gif_bytes: 10 * 1024 * 1024,
public_base_url: String::new(),
server_domain: None,
}
}
// Minimal valid JPEG: SOI + APP0 + SOF0 (1x1px).
// SOF0 is required for imagesize to parse dimensions (fail-closed check).
const TINY_JPEG: &[u8] = &[
// SOI
0xFF, 0xD8, // APP0 (JFIF marker)
0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00,
0x01, 0x00, 0x00, // SOF0: precision=8, height=1, width=1, components=1
0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
// EOI
0xFF, 0xD9,
];
// Minimal PNG header
const TINY_PNG: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
];
#[test]
fn test_validate_jpeg() {
let config = test_config();
let result = validate_content(TINY_JPEG, &config);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/jpeg");
}
#[test]
fn test_validate_png() {
let config = test_config();
let result = validate_content(TINY_PNG, &config);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/png");
}
#[test]
fn test_validate_svg_rejected() {
let config = test_config();
// SVG starts with XML declaration — infer won't detect it as image
let svg = b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
let result = validate_content(svg, &config);
assert!(result.is_err());
}
#[test]
fn test_validate_oversized() {
let mut config = test_config();
config.max_image_bytes = 10; // 10 bytes max
let result = validate_content(TINY_JPEG, &config);
assert!(matches!(result, Err(MediaError::FileTooLarge { .. })));
}
// Minimal valid GIF89a (1x1 pixel) — full logical screen descriptor so imagesize can parse.
const TINY_GIF: &[u8] = &[
// Header
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
// Logical Screen Descriptor: width=1, height=1, flags, bgcolor, aspect
0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00,
// Global Color Table (2 colors: white, black)
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // Image Descriptor
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Data
0x02, 0x02, 0x4C, 0x01, 0x00, // Trailer
0x3B,
];
#[test]
fn test_validate_gif_cap() {
let mut config = test_config();
config.max_gif_bytes = 5; // tiny cap
config.max_image_bytes = 50 * 1024 * 1024;
let result = validate_content(TINY_GIF, &config);
assert!(matches!(result, Err(MediaError::FileTooLarge { .. })));
}
#[test]
fn test_validate_gif_ok() {
let config = test_config();
let result = validate_content(TINY_GIF, &config);
assert!(result.is_ok());
assert_eq!(result.unwrap(), "image/gif");
}
#[test]
fn test_mime_to_ext() {
assert_eq!(mime_to_ext("image/jpeg"), "jpg");
assert_eq!(mime_to_ext("image/png"), "png");
assert_eq!(mime_to_ext("image/gif"), "gif");
assert_eq!(mime_to_ext("image/webp"), "webp");
assert_eq!(mime_to_ext("application/pdf"), "bin");
}
}

View File

@@ -39,6 +39,9 @@ redis = { workspace = true }
sqlx = { workspace = true }
base64 = "0.22"
sprout-workflow = { workspace = true, features = ["reqwest"] }
sprout-media = { workspace = true }
bytes = "1"
infer = "0.16"
serde_yaml = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }

View File

@@ -0,0 +1,498 @@
//! Blossom-compatible media upload, retrieval, and existence check handlers.
//!
//! Routes:
//! PUT /media/upload — BUD-02 upload (auth required)
//! GET /media/{sha256_ext} — BUD-01 serve blob
//! HEAD /media/{sha256_ext} — BUD-01 existence check
use std::sync::Arc;
use axum::{
body::Bytes,
extract::{FromRequestParts, Path, State},
http::{request::Parts, HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use base64::Engine;
use sha2::{Digest, Sha256};
use sprout_audit::{AuditAction, NewAuditEntry};
use sprout_auth::Scope;
use sprout_media::{BlobDescriptor, MediaError};
use crate::state::AppState;
// ── Upload ────────────────────────────────────────────────────────────────────
/// Axum extractor that validates Blossom auth + API token scopes from headers
/// BEFORE the request body is read. This prevents unauthenticated clients from
/// forcing the server to buffer up to 50MB of body data.
///
/// Axum processes `FromRequestParts` extractors before `FromRequest` (body)
/// extractors, so auth rejection happens before any body buffering.
pub(crate) struct AuthenticatedUpload {
auth_event: nostr::Event,
#[allow(dead_code)] // scopes validated in extractor; stored for future per-scope handler logic
scopes: Vec<Scope>,
}
impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
type Rejection = MediaError;
async fn from_request_parts(
parts: &mut Parts,
state: &Arc<AppState>,
) -> Result<Self, Self::Rejection> {
let headers = &parts.headers;
// 1. Extract and validate Blossom auth event
let auth_event = extract_blossom_auth(headers)?;
sprout_media::auth::verify_blossom_auth_event(
&auth_event,
state.config.media.server_domain.as_deref(),
)?;
// 2. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload)
let claimed_hash = headers
.get("x-sha-256")
.and_then(|v| v.to_str().ok())
.ok_or(MediaError::MissingTag("x-sha-256"))?;
// Validate format: exactly 64 lowercase hex characters
if claimed_hash.len() != 64
|| !claimed_hash
.chars()
.all(|c| matches!(c, '0'..='9' | 'a'..='f'))
{
return Err(MediaError::HashMismatch);
}
// 3. Validate X-SHA-256 matches at least one x tag in the auth event
let has_matching_x = auth_event
.tags
.iter()
.any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(claimed_hash)));
if !has_matching_x {
return Err(MediaError::HashMismatch);
}
// 4. Resolve scopes (API token or dev mode)
let scopes = resolve_upload_scopes(headers, state, &auth_event.pubkey).await?;
sprout_auth::require_scope(&scopes, Scope::FilesWrite)
.map_err(|_| MediaError::InsufficientScope)?;
Ok(AuthenticatedUpload { auth_event, scopes })
}
}
/// PUT /media/upload — Blossom BUD-02 upload.
///
/// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body
/// is read, preventing unauthenticated clients from forcing body buffering.
// AuthenticatedUpload is pub(crate) — it's an internal extractor type, never
// exposed outside this crate. The warning is benign: axum resolves it at
// compile time via trait bounds, not by name.
#[allow(private_interfaces)]
///
/// Expects:
/// - `Authorization: Nostr <base64(kind:24242 event)>` — Blossom auth
/// - `X-SHA-256: <hex>` — Required per BUD-11
/// - `X-Auth-Token: sprout_*` — API token for scope resolution (optional in dev mode)
/// - Raw binary body (the file bytes)
///
/// Returns a [`BlobDescriptor`] JSON on success.
// TODO(v2): Add per-pubkey upload rate limiting and storage quotas to prevent
// bandwidth/storage exhaustion from authenticated callers. Currently mitigated by
// auth requirement (API token + Blossom signature) and body size limit.
pub async fn upload_blob(
State(state): State<Arc<AppState>>,
auth: AuthenticatedUpload,
body: Bytes,
) -> Result<Json<BlobDescriptor>, MediaError> {
let descriptor = sprout_media::process_upload(
&state.media_storage,
&state.config.media,
&auth.auth_event,
body,
)
.await?;
// Fire-and-forget audit — never block the response on audit I/O.
let audit = state.audit.clone();
let desc = descriptor.clone();
let uploader = auth.auth_event.pubkey.to_hex();
tokio::spawn(async move {
let _ = audit
.log(NewAuditEntry {
event_id: desc.sha256.clone(),
event_kind: sprout_core::kind::KIND_MEDIA_UPLOAD,
actor_pubkey: uploader,
action: AuditAction::MediaUploaded,
channel_id: None,
metadata: serde_json::json!({
"sha256": desc.sha256,
"size": desc.size,
"mime": desc.mime_type,
}),
})
.await;
});
Ok(Json(descriptor))
}
// ── Serve ─────────────────────────────────────────────────────────────────────
/// Validate that sha256_ext is a safe path segment.
///
/// Accepted forms (max 3 segments):
/// - `{sha256}` — bare 64-char lowercase hex
/// - `{sha256}.{ext}` — hash + extension
/// - `{sha256}.thumb.jpg` — hash + thumb variant (always JPEG)
///
/// Where `{ext}` ∈ {"jpg", "png", "gif", "webp"} for primary blobs (uploads canonicalize to .jpg, not .jpeg).
/// Rejects path traversal, leading underscores, and any non-hex first segment.
fn validate_media_path(sha256_ext: &str) -> Result<(), MediaError> {
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
let segments: Vec<&str> = sha256_ext.split('.').collect();
// 1–3 segments only (hash, optional thumb, optional ext)
if segments.is_empty() || segments.len() > 3 {
return Err(MediaError::NotFound);
}
// First segment must be exactly 64 lowercase hex chars (SHA-256)
let hash = segments[0];
if hash.len() != 64 || !hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) {
return Err(MediaError::NotFound);
}
// Validate remaining segments
match segments.len() {
1 => {} // bare hash — ok
2 => {
// {hash}.{ext}
if !ALLOWED_EXTS.contains(&segments[1]) {
return Err(MediaError::NotFound);
}
}
3 => {
// {hash}.thumb.jpg — thumbnails are always JPEG
if segments[1] != "thumb" || segments[2] != "jpg" {
return Err(MediaError::NotFound);
}
}
_ => return Err(MediaError::NotFound),
}
Ok(())
}
/// GET /media/{sha256_ext} — Blossom BUD-01 serve blob.
///
/// `sha256_ext` is either:
/// - `<sha256>.<ext>` — direct key (e.g. `abc123.jpg`)
/// - `<sha256>` — bare hash; extension resolved from sidecar
/// - `<sha256>.thumb.jpg` — thumbnail variant
pub async fn get_blob(
State(state): State<Arc<AppState>>,
Path(sha256_ext): Path<String>,
) -> Result<Response, MediaError> {
validate_media_path(&sha256_ext)?;
// Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative.
let content_type = if sha256_ext.ends_with(".thumb.jpg") {
let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext);
let _ = state
.media_storage
.read_sidecar_mime(parent_hash)
.await
.ok_or(MediaError::NotFound)?;
"image/jpeg".to_string()
} else {
// For explicit paths (hash.ext), verify the requested extension matches
// the sidecar's canonical extension — sidecar is authoritative.
let sidecar_mime = state
.media_storage
.read_sidecar_mime(&sha256_ext)
.await
.ok_or(MediaError::NotFound)?;
if sha256_ext.contains('.') {
let requested_ext = sha256_ext.rsplit('.').next().unwrap_or("");
let sidecar = state
.media_storage
.get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext))
.await
.map_err(|_| MediaError::NotFound)?;
if requested_ext != sidecar.ext {
return Err(MediaError::NotFound);
}
}
sidecar_mime
};
let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?;
let bytes = state.media_storage.get(&key).await?;
Ok((
[
("content-type", content_type.as_str()),
("cache-control", "public, max-age=31536000, immutable"),
("content-disposition", "inline"),
("content-security-policy", "default-src 'none'"),
("x-content-type-options", "nosniff"),
],
bytes,
)
.into_response())
}
/// HEAD /media/{sha256_ext} — Blossom BUD-01 existence check.
///
/// Content-type is derived from the validated sidecar only — never from raw S3
/// object metadata — to prevent MIME spoofing via tampered storage. If the sidecar
/// is missing, we return 404 rather than fall back to untrusted metadata.
pub async fn head_blob(
State(state): State<Arc<AppState>>,
Path(sha256_ext): Path<String>,
) -> Result<Response, MediaError> {
validate_media_path(&sha256_ext)?;
// Sidecar gate FIRST — reject before any blob I/O.
let content_type = if sha256_ext.ends_with(".thumb.jpg") {
let parent_hash = sha256_ext.strip_suffix(".thumb.jpg").unwrap_or(&sha256_ext);
let _ = state
.media_storage
.read_sidecar_mime(parent_hash)
.await
.ok_or(MediaError::NotFound)?;
"image/jpeg".to_string()
} else {
let sidecar_mime = state
.media_storage
.read_sidecar_mime(&sha256_ext)
.await
.ok_or(MediaError::NotFound)?;
if sha256_ext.contains('.') {
let requested_ext = sha256_ext.rsplit('.').next().unwrap_or("");
let sidecar = state
.media_storage
.get_sidecar(sha256_ext.split('.').next().unwrap_or(&sha256_ext))
.await
.map_err(|_| MediaError::NotFound)?;
if requested_ext != sidecar.ext {
return Err(MediaError::NotFound);
}
}
sidecar_mime
};
let key = resolve_s3_key(&state.media_storage, &sha256_ext).await?;
match state.media_storage.head_with_metadata(&key).await? {
Some(meta) => {
let size_str = meta.size.to_string();
Ok((
StatusCode::OK,
[
("content-type", content_type.as_str()),
("content-length", size_str.as_str()),
("cache-control", "public, max-age=31536000, immutable"),
],
)
.into_response())
}
None => Ok(StatusCode::NOT_FOUND.into_response()),
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/// Resolve the S3 key from a URL path segment.
///
/// - `sha256.ext` → used as-is (already validated by `validate_media_path`)
/// - `sha256` (no dot) → read sidecar to get extension, return `sha256.ext`
///
/// Sidecar-derived extensions are validated against the allowlist to prevent
/// object-key confusion if sidecar data is ever tampered with.
async fn resolve_s3_key(
storage: &sprout_media::MediaStorage,
sha256_ext: &str,
) -> Result<String, MediaError> {
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
if sha256_ext.contains('.') {
Ok(sha256_ext.to_string())
} else {
let sidecar = storage
.get_sidecar(sha256_ext)
.await
.map_err(|_| MediaError::NotFound)?;
// Validate sidecar ext — never trust storage as authoritative for path construction
if !ALLOWED_EXTS.contains(&sidecar.ext.as_str()) {
return Err(MediaError::NotFound);
}
Ok(format!("{}.{}", sha256_ext, sidecar.ext))
}
}
/// Extract and verify a kind:24242 Blossom auth event from the `Authorization` header.
///
/// Accepts both base64url (BUD-11 spec) and standard base64 (nostr-tools compat).
fn extract_blossom_auth(headers: &HeaderMap) -> Result<nostr::Event, MediaError> {
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
let header = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.ok_or(MediaError::MissingAuth)?;
let token = header
.strip_prefix("Nostr ")
.ok_or(MediaError::InvalidAuthScheme)?;
let json_bytes = URL_SAFE_NO_PAD
.decode(token)
.or_else(|_| STANDARD.decode(token))
.map_err(|_| MediaError::InvalidBase64)?;
let event: nostr::Event =
serde_json::from_slice(&json_bytes).map_err(|_| MediaError::InvalidAuthEvent)?;
Ok(event)
}
/// Resolve permission scopes for an upload caller.
///
/// Resolution order:
/// 1. `X-Auth-Token: sprout_*` header — API token path (validates owner matches Blossom signer)
/// 2. If `require_auth_token` is false (dev mode) — check pubkey allowlist, then grant file scopes
async fn resolve_upload_scopes(
headers: &HeaderMap,
state: &AppState,
blossom_pubkey: &nostr::PublicKey,
) -> Result<Vec<Scope>, MediaError> {
// 1. API token path — desktop sends Blossom auth in Authorization + token in X-Auth-Token.
if let Some(token) = headers
.get("x-auth-token")
.and_then(|v| v.to_str().ok())
.filter(|t| t.starts_with("sprout_"))
{
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
let record = state
.db
.get_api_token_by_hash_including_revoked(&hash)
.await
.map_err(|_| MediaError::Unauthorized)?
.ok_or(MediaError::Unauthorized)?;
if record.revoked_at.is_some() {
return Err(MediaError::TokenRevoked);
}
if let Some(expires_at) = record.expires_at {
if expires_at < chrono::Utc::now() {
return Err(MediaError::TokenExpired);
}
}
// Token owner must match the Blossom signer — prevents token theft attacks.
let blossom_bytes = blossom_pubkey.serialize().to_vec();
if record.owner_pubkey != blossom_bytes {
return Err(MediaError::PubkeyMismatch);
}
return Ok(record
.scopes
.iter()
.filter_map(|s| s.parse::<Scope>().ok())
.collect());
}
// 2. Dev mode: no API token required.
if state.config.require_auth_token {
return Err(MediaError::Unauthorized);
}
// Dev mode is active — any valid Blossom signer can upload.
// This must never be enabled in production.
tracing::warn!(
"dev mode upload: no API token required — ensure require_auth_token=true in production"
);
// 3. Pubkey allowlist check (dev mode only).
if state.config.pubkey_allowlist_enabled {
let pubkey_bytes = blossom_pubkey.serialize().to_vec();
if !state
.db
.is_pubkey_allowed(&pubkey_bytes)
.await
.unwrap_or(false)
{
return Err(MediaError::Unauthorized);
}
}
// Dev mode: grant file scopes.
Ok(vec![Scope::FilesRead, Scope::FilesWrite])
}
#[cfg(test)]
mod tests {
use super::*;
const VALID_HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
#[test]
fn test_validate_media_path_bare_hash() {
assert!(validate_media_path(VALID_HASH).is_ok());
}
#[test]
fn test_validate_media_path_hash_ext() {
for ext in &["jpg", "png", "gif", "webp"] {
assert!(validate_media_path(&format!("{VALID_HASH}.{ext}")).is_ok());
}
}
#[test]
fn test_validate_media_path_thumb_jpg_only() {
assert!(validate_media_path(&format!("{VALID_HASH}.thumb.jpg")).is_ok());
// Other thumb extensions rejected — thumbnails are always JPEG
assert!(validate_media_path(&format!("{VALID_HASH}.thumb.png")).is_err());
assert!(validate_media_path(&format!("{VALID_HASH}.thumb.webp")).is_err());
}
#[test]
fn test_validate_media_path_rejects_bad_ext() {
assert!(validate_media_path(&format!("{VALID_HASH}.svg")).is_err());
assert!(validate_media_path(&format!("{VALID_HASH}.exe")).is_err());
assert!(validate_media_path(&format!("{VALID_HASH}.pdf")).is_err());
}
#[test]
fn test_validate_media_path_rejects_short_hash() {
assert!(validate_media_path("abc123.jpg").is_err());
}
#[test]
fn test_validate_media_path_rejects_uppercase_hash() {
let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789";
assert!(validate_media_path(&format!("{upper}.jpg")).is_err());
}
#[test]
fn test_validate_media_path_rejects_traversal() {
assert!(validate_media_path("../etc/passwd").is_err());
assert!(validate_media_path(&format!("../{VALID_HASH}.jpg")).is_err());
}
#[test]
fn test_validate_media_path_rejects_too_many_segments() {
assert!(validate_media_path(&format!("{VALID_HASH}.thumb.jpg.extra")).is_err());
}
#[test]
fn test_validate_media_path_rejects_empty() {
assert!(validate_media_path("").is_err());
}
}

View File

@@ -35,6 +35,295 @@ use super::{
internal_error, not_found,
};
/// Validate imeta tags for correctness and safety.
///
/// Shared between REST (send_message) and WebSocket (handle_event) paths.
/// Returns Ok(()) if all tags are valid, or a human-readable error string.
pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result<(), String> {
const ALLOWED_IMETA_KEYS: &[&str] = &[
"url", "m", "x", "size", "dim", "blurhash", "alt", "thumb", "fallback",
];
const SINGLETON_KEYS: &[&str] = &["url", "m", "x", "size", "dim", "blurhash", "thumb", "alt"];
const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
for tag in tags {
if tag.first().map(|s| s.as_str()) != Some("imeta") {
return Err("only imeta tags allowed in media_tags".into());
}
let mut has_url = false;
let mut has_m = false;
let mut has_x = false;
let mut has_size = false;
let mut seen_keys = std::collections::HashSet::new();
let mut url_value = String::new();
let mut x_value = String::new();
let mut m_value = String::new();
let mut thumb_value = String::new();
for part in tag.iter().skip(1) {
let mut parts = part.splitn(2, ' ');
let key = parts.next().unwrap_or("");
let value = parts.next().unwrap_or("");
if !ALLOWED_IMETA_KEYS.contains(&key) {
return Err(format!("disallowed imeta key: {key}"));
}
if SINGLETON_KEYS.contains(&key) && !seen_keys.insert(key.to_string()) {
return Err(format!("duplicate imeta key: {key}"));
}
match key {
"url" => {
if !is_local_media_url(value, media_base_url) {
return Err("imeta url must be a local /media/ path".into());
}
if value.contains(".thumb.") {
return Err(
"imeta url must not be a thumbnail path; use thumb field".into()
);
}
url_value = value.to_string();
has_url = true;
}
"m" => {
if !ALLOWED_MIME.contains(&value) {
return Err(
"imeta m must be image/jpeg, image/png, image/gif, or image/webp"
.into(),
);
}
m_value = value.to_string();
has_m = true;
}
"x" => {
if value.len() != 64
|| !value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
{
return Err("imeta x must be a 64-char lowercase hex SHA-256".into());
}
x_value = value.to_string();
has_x = true;
}
"size" => {
match value.parse::<u64>() {
Ok(0) | Err(_) => {
return Err("imeta size must be a positive integer".into())
}
Ok(_) => {}
}
has_size = true;
}
"thumb" => {
if !is_local_media_url(value, media_base_url) || !value.ends_with(".thumb.jpg")
{
return Err("imeta thumb must be a local .thumb.jpg path".into());
}
thumb_value = value.to_string();
}
_ => {}
}
}
if !has_url || !has_m || !has_x || !has_size {
return Err("imeta tag must include url, m, x, and size".into());
}
// Cross-check internal consistency: url hash must match x, url ext must match m.
if let Some(hash_in_url) = extract_hash_from_media_url(&url_value) {
if hash_in_url != x_value {
return Err("imeta url hash does not match x".into());
}
}
if let Some(ext_in_url) = extract_ext_from_media_url(&url_value) {
let expected_ext = mime_to_canonical_ext(&m_value);
if ext_in_url != expected_ext {
return Err("imeta url extension does not match m".into());
}
}
// Thumb hash must match x (same blob, different variant).
if !thumb_value.is_empty() {
if let Some(thumb_hash) = extract_hash_from_media_url(&thumb_value) {
if thumb_hash != x_value {
return Err("imeta thumb hash does not match x".into());
}
}
}
}
Ok(())
}
/// Verify that every imeta tag references a blob that actually exists in storage
/// and that the claimed metadata (size, MIME) matches the sidecar.
///
/// Called after syntactic validation. Returns Ok(()) if all blobs exist and match,
/// or a human-readable error string. This prevents clients from referencing
/// nonexistent blobs or lying about size/MIME in imeta tags.
pub async fn verify_imeta_blobs(
tags: &[Vec<String>],
storage: &sprout_media::MediaStorage,
) -> Result<(), String> {
for tag in tags {
let mut x_value = String::new();
let mut m_value = String::new();
let mut size_value: u64 = 0;
let mut thumb_value = String::new();
for part in tag.iter().skip(1) {
let mut parts = part.splitn(2, ' ');
let key = parts.next().unwrap_or("");
let value = parts.next().unwrap_or("");
match key {
"x" => x_value = value.to_string(),
"m" => m_value = value.to_string(),
"size" => size_value = value.parse().unwrap_or(0),
"thumb" => thumb_value = value.to_string(),
_ => {}
}
}
if x_value.is_empty() {
continue; // syntactic validation already caught this
}
// 1. Sidecar must exist — proves the upload pipeline completed.
let sidecar = storage
.get_sidecar(&x_value)
.await
.map_err(|_| format!("imeta references nonexistent blob: {x_value}"))?;
// 2. HEAD the actual blob object — sidecar alone is not proof of blob existence.
let blob_key = format!("{x_value}.{}", sidecar.ext);
let blob_exists = storage
.head(&blob_key)
.await
.map_err(|e| format!("storage error checking blob {x_value}: {e}"))?;
if !blob_exists {
return Err(format!("imeta blob object missing in storage: {x_value}"));
}
// 3. Cross-check claimed metadata against sidecar.
if !m_value.is_empty() && sidecar.mime_type != m_value {
return Err(format!(
"imeta m ({m_value}) does not match stored MIME ({})",
sidecar.mime_type
));
}
if size_value > 0 && sidecar.size != size_value {
return Err(format!(
"imeta size ({size_value}) does not match stored size ({})",
sidecar.size
));
}
// 4. If thumb is claimed, HEAD the thumbnail object too.
if !thumb_value.is_empty() {
let thumb_key = format!("{x_value}.thumb.jpg");
let thumb_exists = storage
.head(&thumb_key)
.await
.map_err(|e| format!("storage error checking thumbnail: {e}"))?;
if !thumb_exists {
return Err(format!(
"imeta thumb references missing thumbnail: {x_value}"
));
}
}
}
Ok(())
}
/// Extract the 64-char hex hash from a `/media/{hash}.{ext}` or `/media/{hash}.thumb.jpg` URL.
fn extract_hash_from_media_url(url: &str) -> Option<&str> {
let after = url.rsplit("/media/").next()?;
let hash = after.split('.').next()?;
if hash.len() == 64 && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) {
Some(hash)
} else {
None
}
}
/// Extract the primary extension from a `/media/{hash}.{ext}` URL (not thumb).
fn extract_ext_from_media_url(url: &str) -> Option<&str> {
let after = url.rsplit("/media/").next()?;
let segments: Vec<&str> = after.split('.').collect();
if segments.len() == 2 {
Some(segments[1])
} else {
None // bare hash or thumb — no primary ext to check
}
}
/// Map MIME to canonical extension (must match sprout-media's mime_to_ext).
fn mime_to_canonical_ext(mime: &str) -> &str {
match mime {
"image/jpeg" => "jpg",
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "bin",
}
}
/// Validate that a URL references a valid local media blob path.
///
/// Accepts:
/// - Relative: `/media/<sha256>.<ext>` or `/media/<sha256>.thumb.jpg`
/// - Absolute: `<media_base_url>/<sha256>.<ext>` or `<media_base_url>/<sha256>.thumb.jpg`
///
/// Where sha256 is exactly 64 lowercase hex chars and ext is an allowed image extension.
/// Thumbnails are always JPEG — only `.thumb.jpg` is accepted.
/// Rejects percent-encoded traversal, query strings, fragments, and external origins.
fn is_local_media_url(url: &str, media_base_url: &str) -> bool {
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
// Extract the path portion after /media/
let path_after_media = if let Some(rest) = url.strip_prefix("/media/") {
rest
} else {
let base = media_base_url.trim_end_matches('/');
let prefix = format!("{}/", base);
if let Some(rest) = url.strip_prefix(&prefix) {
rest
} else {
return false;
}
};
// Reject query strings and fragments
if path_after_media.contains('?') || path_after_media.contains('#') {
return false;
}
// Reject percent-encoding (no legitimate blob path needs it)
if path_after_media.contains('%') {
return false;
}
// Parse segments: must be {sha256}.{ext} or {sha256}.thumb.{ext}
let segments: Vec<&str> = path_after_media.split('.').collect();
match segments.len() {
2 => {
// {sha256}.{ext}
let hash = segments[0];
let ext = segments[1];
hash.len() == 64
&& hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
&& ALLOWED_EXTS.contains(&ext)
}
3 => {
// {sha256}.thumb.jpg — thumbnails are always JPEG
let hash = segments[0];
hash.len() == 64
&& hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))
&& segments[1] == "thumb"
&& segments[2] == "jpg"
}
_ => false,
}
}
/// Extract the effective message author from a stored event.
///
/// REST-created messages are signed by the relay keypair and attribute the real
@@ -241,6 +530,10 @@ pub struct SendMessageBody {
pub content: String,
/// Hex-encoded event ID of the parent message (for replies).
pub parent_event_id: Option<String>,
/// Hex-encoded pubkeys to mention in the message (added as `p` tags).
/// Distinct from the author-attribution `p` tag which is always added.
#[serde(default)]
pub mention_pubkeys: Vec<String>,
/// When `true`, a reply is also surfaced in the channel feed (broadcast).
#[serde(default)]
pub broadcast_to_channel: bool,
@@ -269,6 +562,10 @@ pub struct SendMessageBody {
pub diff_truncated: Option<bool>,
/// Plain-text alternative summary for clients that cannot render diffs.
pub diff_alt: Option<String>,
/// Media attachment tags (imeta only). Whitelist-validated.
/// Each inner Vec is a single tag, e.g. `["imeta", "url https://...", "m image/jpeg"]`.
#[serde(default)]
pub media_tags: Vec<Vec<String>>,
}
/// Send a new channel message or reply to an existing thread.
@@ -302,7 +599,8 @@ pub async fn send_message(
return Err(api_error(StatusCode::FORBIDDEN, "channel is archived"));
}
if body.content.trim().is_empty() {
// Image-only messages: allow empty text content when media_tags are present.
if body.content.trim().is_empty() && body.media_tags.is_empty() {
return Err(api_error(StatusCode::BAD_REQUEST, "content is required"));
}
@@ -409,6 +707,21 @@ pub async fn send_message(
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
];
// Mention p tags — distinct from the author-attribution p tag above.
for mention_hex in &body.mention_pubkeys {
// Validate hex format (32-byte pubkey = 64 hex chars)
if mention_hex.len() == 64
&& mention_hex
.chars()
.all(|c| matches!(c, '0'..='9' | 'a'..='f'))
{
tags.push(
Tag::parse(&["p", mention_hex])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
);
}
}
// Thread reply tags (NIP-10 style).
if let (Some(ref root_bytes), Some(ref parent_bytes)) = (&root_id_bytes, &parent_id_bytes) {
let root_hex = nostr_hex::encode(root_bytes);
@@ -445,6 +758,21 @@ pub async fn send_message(
tags.extend(diff_tags);
}
// Validate imeta tags: syntactic checks, then verify blobs exist in storage.
validate_imeta_tags(&body.media_tags, &state.config.media.public_base_url)
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &e))?;
verify_imeta_blobs(&body.media_tags, &state.media_storage)
.await
.map_err(|e| api_error(StatusCode::BAD_REQUEST, &e))?;
for tag in &body.media_tags {
tags.push(
Tag::parse(tag).map_err(|e| {
api_error(StatusCode::BAD_REQUEST, &format!("invalid imeta tag: {e}"))
})?,
);
}
let event = EventBuilder::new(kind, &body.content, tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| internal_error(&format!("event signing error: {e}")))?;
@@ -884,3 +1212,158 @@ pub async fn get_thread(
"next_cursor": next_cursor,
})))
}
#[cfg(test)]
mod tests {
use super::*;
const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
const BASE: &str = "https://relay.example.com/media";
#[test]
fn test_local_media_url_relative() {
assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE));
assert!(is_local_media_url(&format!("/media/{HASH}.png"), BASE));
assert!(is_local_media_url(&format!("/media/{HASH}.gif"), BASE));
assert!(is_local_media_url(&format!("/media/{HASH}.webp"), BASE));
}
#[test]
fn test_local_media_url_absolute() {
assert!(is_local_media_url(&format!("{BASE}/{HASH}.jpg"), BASE));
}
#[test]
fn test_local_media_url_thumb_jpg_only() {
assert!(is_local_media_url(
&format!("/media/{HASH}.thumb.jpg"),
BASE
));
// Other thumb extensions rejected
assert!(!is_local_media_url(
&format!("/media/{HASH}.thumb.png"),
BASE
));
assert!(!is_local_media_url(
&format!("/media/{HASH}.thumb.webp"),
BASE
));
}
#[test]
fn test_local_media_url_rejects_external() {
assert!(!is_local_media_url(
&format!("https://evil.com/media/{HASH}.jpg"),
BASE
));
}
#[test]
fn test_local_media_url_rejects_query_string() {
assert!(!is_local_media_url(
&format!("/media/{HASH}.jpg?foo=bar"),
BASE
));
}
#[test]
fn test_local_media_url_rejects_fragment() {
assert!(!is_local_media_url(
&format!("/media/{HASH}.jpg#frag"),
BASE
));
}
#[test]
fn test_local_media_url_rejects_percent_encoding() {
assert!(!is_local_media_url(&format!("/media/{HASH}%2e.jpg"), BASE));
assert!(!is_local_media_url("/media/%2e%2e/etc/passwd", BASE));
}
#[test]
fn test_local_media_url_rejects_uppercase_hash() {
let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789";
assert!(!is_local_media_url(&format!("/media/{upper}.jpg"), BASE));
}
#[test]
fn test_local_media_url_rejects_short_hash() {
assert!(!is_local_media_url("/media/abc123.jpg", BASE));
}
#[test]
fn test_local_media_url_rejects_bad_ext() {
assert!(!is_local_media_url(&format!("/media/{HASH}.svg"), BASE));
assert!(!is_local_media_url(&format!("/media/{HASH}.exe"), BASE));
// .jpeg is not canonical — uploads produce .jpg only
assert!(!is_local_media_url(&format!("/media/{HASH}.jpeg"), BASE));
}
/// Thumb validation requires BOTH is_local_media_url AND .thumb.jpg suffix.
/// A full-size blob URL must not be accepted as a thumbnail.
#[test]
fn test_thumb_must_be_thumb_jpg() {
let thumb = format!("/media/{HASH}.thumb.jpg");
let blob = format!("/media/{HASH}.jpg");
assert!(is_local_media_url(&thumb, BASE) && thumb.ends_with(".thumb.jpg"));
assert!(is_local_media_url(&blob, BASE));
assert!(!blob.ends_with(".thumb.jpg"));
}
// ── imeta consistency cross-checks ──────────────────────────────────────
#[test]
fn test_imeta_url_hash_must_match_x() {
let other = "b".repeat(64);
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.jpg"),
"m image/jpeg".into(),
format!("x {other}"),
"size 100".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("url hash does not match x"), "{err}");
}
#[test]
fn test_imeta_url_ext_must_match_m() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.png"),
"m image/jpeg".into(),
format!("x {HASH}"),
"size 100".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("url extension does not match m"), "{err}");
}
#[test]
fn test_imeta_thumb_hash_must_match_x() {
let other = "c".repeat(64);
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.jpg"),
"m image/jpeg".into(),
format!("x {HASH}"),
"size 100".into(),
format!("thumb /media/{other}.thumb.jpg"),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("thumb hash does not match x"), "{err}");
}
#[test]
fn test_imeta_consistent_tags_pass() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.jpg"),
"m image/jpeg".into(),
format!("x {HASH}"),
"size 100".into(),
format!("thumb /media/{HASH}.thumb.jpg"),
];
assert!(validate_imeta_tags(&[tag], BASE).is_ok());
}
}

View File

@@ -26,6 +26,8 @@ pub mod dms;
pub mod events;
/// Personalized home feed endpoint.
pub mod feed;
/// Blossom-compatible media upload, retrieval, and existence check endpoints.
pub mod media;
/// Channel membership endpoints.
pub mod members;
/// Message and thread endpoints.
@@ -60,7 +62,10 @@ pub use dms::{add_dm_member_handler, list_dms_handler, open_dm_handler};
pub use events::get_event;
pub use feed::feed_handler;
pub use members::{add_members, join_channel, leave_channel, list_members, remove_member};
pub use messages::{delete_message, get_thread, list_messages, send_message};
pub use messages::{
delete_message, get_thread, list_messages, send_message, validate_imeta_tags,
verify_imeta_blobs,
};
pub use presence::{presence_handler, set_presence_handler};
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};
pub use search::search_handler;

View File

@@ -53,6 +53,8 @@ pub struct Config {
/// API tokens or Okta JWTs bypass the allowlist entirely.
/// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`.
pub pubkey_allowlist_enabled: bool,
/// Media storage configuration (S3/MinIO).
pub media: sprout_media::MediaConfig,
}
impl Config {
@@ -135,6 +137,47 @@ impl Config {
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let media = sprout_media::MediaConfig {
s3_endpoint: std::env::var("SPROUT_S3_ENDPOINT")
.unwrap_or_else(|_| "http://localhost:9000".to_string()),
s3_access_key: std::env::var("SPROUT_S3_ACCESS_KEY")
.unwrap_or_else(|_| "sprout_dev".to_string()),
s3_secret_key: std::env::var("SPROUT_S3_SECRET_KEY")
.unwrap_or_else(|_| "sprout_dev_secret".to_string()),
s3_bucket: std::env::var("SPROUT_S3_BUCKET")
.unwrap_or_else(|_| "sprout-media".to_string()),
max_image_bytes: std::env::var("SPROUT_MAX_IMAGE_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(50 * 1024 * 1024),
max_gif_bytes: std::env::var("SPROUT_MAX_GIF_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10 * 1024 * 1024),
public_base_url: std::env::var("SPROUT_MEDIA_BASE_URL")
.unwrap_or_else(|_| "http://localhost:3000/media".to_string()),
server_domain: std::env::var("SPROUT_MEDIA_SERVER_DOMAIN")
.ok()
.filter(|s| !s.is_empty())
.or_else(|| {
// Auto-derive from RELAY_URL so desktop uploads work out-of-the-box
// without requiring an extra env var in dev mode.
url::Url::parse(
&relay_url
.replace("ws://", "http://")
.replace("wss://", "https://"),
)
.ok()
.and_then(|u| {
let host = u.host_str()?.to_string();
match u.port() {
Some(p) => Some(format!("{host}:{p}")),
None => Some(host),
}
})
}),
};
Ok(Self {
bind_addr,
database_url,
@@ -151,6 +194,7 @@ impl Config {
relay_private_key,
uds_path,
pubkey_allowlist_enabled,
media,
})
}
}
@@ -187,4 +231,45 @@ mod tests {
std::env::remove_var("SPROUT_BIND_ADDR");
assert!(matches!(result, Err(ConfigError::InvalidBindAddr(_))));
}
#[test]
fn server_domain_auto_derived_from_relay_url() {
let _guard = ENV_MUTEX.lock().unwrap();
// Clear explicit override so auto-derive kicks in
std::env::remove_var("SPROUT_MEDIA_SERVER_DOMAIN");
std::env::set_var("RELAY_URL", "ws://localhost:3000");
let config = Config::from_env().expect("config");
std::env::remove_var("RELAY_URL");
assert_eq!(
config.media.server_domain.as_deref(),
Some("localhost:3000")
);
}
#[test]
fn server_domain_auto_derived_default_port() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::remove_var("SPROUT_MEDIA_SERVER_DOMAIN");
std::env::set_var("RELAY_URL", "wss://relay.example.com");
let config = Config::from_env().expect("config");
std::env::remove_var("RELAY_URL");
assert_eq!(
config.media.server_domain.as_deref(),
Some("relay.example.com")
);
}
#[test]
fn server_domain_explicit_override_wins() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("SPROUT_MEDIA_SERVER_DOMAIN", "custom.example.com");
std::env::set_var("RELAY_URL", "ws://localhost:3000");
let config = Config::from_env().expect("config");
std::env::remove_var("SPROUT_MEDIA_SERVER_DOMAIN");
std::env::remove_var("RELAY_URL");
assert_eq!(
config.media.server_domain.as_deref(),
Some("custom.example.com")
);
}
}

View File

@@ -360,6 +360,40 @@ pub async fn handle_event(event: Event, conn: Arc<ConnectionState>, state: Arc<A
}
}
// Validate imeta tags on WebSocket-submitted events (same rules as REST path).
// Extract imeta tags from the event and validate them.
let imeta_tags: Vec<Vec<String>> = event
.tags
.iter()
.filter(|t| t.kind().to_string() == "imeta")
.map(|t| {
// Use as_slice() to capture ALL tag elements — no index cap.
// The first element is the tag kind ("imeta"), already included.
t.as_slice().iter().map(|s| s.to_string()).collect()
})
.collect();
if !imeta_tags.is_empty() {
if let Err(e) =
crate::api::validate_imeta_tags(&imeta_tags, &state.config.media.public_base_url)
{
conn.send(RelayMessage::ok(
&event_id_hex,
false,
&format!("invalid: {e}"),
));
return;
}
// Verify referenced blobs exist in storage and metadata matches.
if let Err(e) = crate::api::verify_imeta_blobs(&imeta_tags, &state.media_storage).await {
conn.send(RelayMessage::ok(
&event_id_hex,
false,
&format!("invalid: {e}"),
));
return;
}
}
let (stored_event, was_inserted) = match state.db.insert_event(&event, channel_id).await {
Ok(result) => result,
Err(sprout_db::DbError::AuthEventRejected) => {

View File

@@ -99,6 +99,14 @@ async fn main() -> anyhow::Result<()> {
keys
};
config
.media
.validate()
.map_err(|e| anyhow::anyhow!("invalid media config: {e}"))?;
let media_storage = sprout_media::MediaStorage::new(&config.media)
.map_err(|e| anyhow::anyhow!("failed to initialize media storage: {e}"))?;
info!("Media storage connected");
let state = Arc::new(AppState::new(
config.clone(),
db,
@@ -109,6 +117,7 @@ async fn main() -> anyhow::Result<()> {
search,
Arc::clone(&workflow_engine),
relay_keypair,
media_storage,
));
// Wire the action sink — must happen after AppState (which creates

View File

@@ -21,8 +21,28 @@ use crate::nip11::{relay_info_handler, RelayInfo};
use crate::state::AppState;
/// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration.
///
/// Uses a dual sub-router pattern so media routes can carry a 50 MB body limit
/// while all other routes remain capped at 1 MB. Each sub-router attaches its
/// own `RequestBodyLimitLayer` before merging; the outer layer adds tracing and
/// CORS once over the combined router.
pub fn build_router(state: Arc<AppState>) -> Router {
Router::new()
// ── Media routes: body limit derived from config ─────────────────────────
// Transport limit is max_image_bytes (50MB). GIFs have a stricter app-level cap (10MB)
// enforced in validation.rs after MIME detection. This means GIF uploads up to 50MB are
// buffered before rejection — acceptable for V1; streaming validation deferred to V2.
let media_body_limit = state.config.media.max_image_bytes as usize;
let media_router = Router::new()
.route("/media/upload", put(api::media::upload_blob))
.route(
"/media/{sha256_ext}",
get(api::media::get_blob).head(api::media::head_blob),
)
.layer(RequestBodyLimitLayer::new(media_body_limit))
.with_state(state.clone());
// ── All other routes: 1 MB body limit ────────────────────────────────────
let api_router = Router::new()
.route("/", get(nip11_or_ws_handler))
.route("/info", get(relay_info_handler))
.route("/.well-known/nostr.json", get(api::nip05::nostr_nip05))
@@ -145,11 +165,16 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/api/users/batch", post(api::get_users_batch))
// Feed route
.route("/api/feed", get(api::feed_handler))
.layer(TraceLayer::new_for_http())
.layer(build_cors_layer(&state.config.cors_origins))
// Reject request bodies larger than 1 MB to prevent resource exhaustion.
.layer(RequestBodyLimitLayer::new(1024 * 1024))
.with_state(state)
.with_state(state.clone());
// Merge — each sub-router carries its own body limit.
// TraceLayer and CORS are applied once over the combined router.
api_router
.merge(media_router)
.layer(TraceLayer::new_for_http())
.layer(build_cors_layer(&state.config.cors_origins))
}
/// Content-negotiated: NIP-11 JSON for plain HTTP, WebSocket upgrade otherwise.

View File

@@ -14,6 +14,7 @@ use deadpool_redis;
use sprout_audit::AuditService;
use sprout_auth::AuthService;
use sprout_db::Db;
use sprout_media::MediaStorage;
use sprout_pubsub::PubSubManager;
use sprout_search::SearchService;
use sprout_workflow::WorkflowEngine;
@@ -149,6 +150,8 @@ pub struct AppState {
/// consumer skips them to avoid double delivery. Entries expire after
/// 60 seconds via moka's TTL eviction — bounded regardless of subscriber health.
pub local_event_ids: Arc<moka::sync::Cache<[u8; 32], ()>>,
/// Media storage client (S3/MinIO).
pub media_storage: Arc<MediaStorage>,
}
impl AppState {
@@ -164,6 +167,7 @@ impl AppState {
search: SearchService,
workflow_engine: Arc<WorkflowEngine>,
relay_keypair: nostr::Keys,
media_storage: MediaStorage,
) -> Self {
let max_connections = config.max_connections;
let max_concurrent_handlers = config.max_concurrent_handlers;
@@ -189,6 +193,7 @@ impl AppState {
.time_to_live(std::time::Duration::from_secs(60))
.build(),
),
media_storage: Arc::new(media_storage),
}
}

View File

@@ -29,9 +29,10 @@ uuid = { workspace = true }
futures-util = { workspace = true }
reqwest = { workspace = true }
base64 = "0.22"
sha2 = "0.10"
hex = { workspace = true }
rand = { workspace = true }
sha2 = { workspace = true }
chrono = { workspace = true }
hex = "0.4"
[[bin]]
name = "sprout-test-cli"

View File

@@ -0,0 +1,383 @@
//! End-to-end media upload tests (Blossom / NIP-96 style).
//!
//! Requires: relay running at localhost:3000, MinIO running at localhost:9000.
//! All tests are `#[ignore]` so they don't run in CI by default.
//!
//! # Running
//!
//! ```text
//! cargo test -p sprout-test-client --test e2e_media -- --ignored --nocapture
//! ```
//!
//! Override the relay URL:
//!
//! ```text
//! RELAY_HTTP_URL=http://localhost:3000 cargo test -p sprout-test-client --test e2e_media -- --ignored
//! ```
use std::time::Duration;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use reqwest::Client;
use sha2::{Digest, Sha256};
// ── URL helpers ───────────────────────────────────────────────────────────────
fn relay_http_url() -> String {
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
}
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("failed to build HTTP client")
}
// ── Blossom auth helpers ──────────────────────────────────────────────────────
/// Sign a kind:24242 Blossom upload auth event for the given sha256.
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_u64();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(&["t", "upload"]).expect("t tag"),
Tag::parse(&["x", sha256]).expect("x tag"),
Tag::parse(&["expiration", &exp_str]).expect("expiration tag"),
];
EventBuilder::new(Kind::from(24242), "Upload test", tags)
.sign_with_keys(keys)
.expect("sign blossom auth")
}
/// Build `Authorization: Nostr <base64url(json)>` header value.
fn blossom_auth_header(event: &nostr::Event) -> String {
format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
)
}
// ── Minimal 1×1 JPEG ─────────────────────────────────────────────────────────
/// A valid 1×1 red JPEG (339 bytes). Used for fast upload tests.
fn tiny_jpeg() -> Vec<u8> {
vec![
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xFF, 0xD9,
]
}
// ── Tests ─────────────────────────────────────────────────────────────────────
/// Upload a tiny JPEG, then GET it back and verify the bytes match.
/// Also checks BlobDescriptor fields and thumbnail endpoint.
#[tokio::test]
#[ignore]
async fn test_upload_and_get() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
println!("sha256: {sha256}");
println!("relay: {}", relay_http_url());
// PUT /media/upload
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", &sha256)
.body(jpeg.clone())
.send()
.await
.expect("upload PUT failed");
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
println!("PUT /media/upload → {status}: {body_text}");
assert_eq!(status, 200, "upload should succeed");
// Parse BlobDescriptor
let descriptor: serde_json::Value =
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
println!("BlobDescriptor: {descriptor:#}");
assert_eq!(
descriptor["sha256"].as_str().unwrap(),
sha256,
"sha256 must match"
);
assert!(
descriptor["url"].as_str().unwrap().contains(&sha256),
"url must contain sha256"
);
assert!(
descriptor["size"].as_u64().unwrap() > 0,
"size must be positive"
);
assert!(
descriptor["type"].as_str().is_some(),
"mime type must be present"
);
// dim and blurhash are best-effort (image processing may not run on 1x1 JPEG)
println!(
"dim: {:?}, blurhash: {:?}",
descriptor["dim"], descriptor["blurhash"]
);
// GET /media/{sha256}.jpg — bytes must match
let get_url = format!("{}/media/{sha256}.jpg", relay_http_url());
let get_resp = client
.get(&get_url)
.send()
.await
.expect("GET /media/{sha256}.jpg failed");
assert_eq!(get_resp.status(), 200, "GET should return 200");
let returned_bytes = get_resp.bytes().await.unwrap();
assert_eq!(
returned_bytes.as_ref(),
jpeg.as_slice(),
"GET must return original bytes"
);
// HEAD /media/{sha256}.jpg — must return 200 with content-type
let head_resp = client
.head(&get_url)
.send()
.await
.expect("HEAD /media/{sha256}.jpg failed");
assert_eq!(head_resp.status(), 200, "HEAD should return 200");
assert!(
head_resp.headers().get("content-type").is_some(),
"HEAD must include content-type"
);
// GET thumbnail — /media/{sha256}.thumb.jpg
let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url());
let thumb_resp = client
.get(&thumb_url)
.send()
.await
.expect("GET thumbnail failed");
println!("GET thumbnail → {}", thumb_resp.status());
// Thumbnail may be same as original for 1x1 images — just check 200
assert_eq!(thumb_resp.status(), 200, "thumbnail should return 200");
}
/// Idempotency: uploading the same file twice returns the same BlobDescriptor.
#[tokio::test]
#[ignore]
async fn test_upload_idempotent() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let upload = |keys: &Keys| {
let auth = sign_blossom_auth(keys, &sha256);
client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", sha256.clone())
.body(jpeg.clone())
.send()
};
let r1: serde_json::Value = upload(&keys)
.await
.expect("first upload failed")
.json()
.await
.expect("first descriptor parse");
// Second upload — different key, same content
let keys2 = Keys::generate();
let r2: serde_json::Value = upload(&keys2)
.await
.expect("second upload failed")
.json()
.await
.expect("second descriptor parse");
assert_eq!(
r1["sha256"], r2["sha256"],
"sha256 must be identical on re-upload"
);
assert_eq!(r1["url"], r2["url"], "url must be identical on re-upload");
}
/// Upload without an Authorization header must return 401.
#[tokio::test]
#[ignore]
async fn test_upload_no_auth_returns_401() {
let client = http_client();
let jpeg = tiny_jpeg();
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Content-Type", "image/jpeg")
.body(jpeg)
.send()
.await
.expect("request failed");
println!("no-auth → {}", resp.status());
assert_eq!(resp.status(), 401, "upload without auth must be 401");
}
/// Upload without X-SHA-256 header must return 401 (BUD-11: mandatory).
#[tokio::test]
#[ignore]
async fn test_upload_missing_x_sha256_returns_401() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
// Intentionally omit X-SHA-256
.body(jpeg)
.send()
.await
.expect("request failed");
println!("missing-x-sha256 → {}", resp.status());
assert_eq!(resp.status(), 401, "upload without X-SHA-256 must be 401");
}
/// Upload where the `x` tag sha256 doesn't match the actual body must return 401.
#[tokio::test]
#[ignore]
async fn test_upload_hash_mismatch_returns_400() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let wrong_hash = "f".repeat(64); // definitely not the real sha256
let auth = sign_blossom_auth(&keys, &wrong_hash);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", &wrong_hash)
.body(jpeg)
.send()
.await
.expect("request failed");
println!("hash-mismatch → {}", resp.status());
assert_eq!(resp.status(), 401, "hash mismatch must be 401");
}
/// GET a sha256 that was never uploaded must return 404.
#[tokio::test]
#[ignore]
async fn test_get_nonexistent_returns_404() {
let client = http_client();
let missing_sha256 = "0".repeat(64);
let url = format!("{}/media/{missing_sha256}.jpg", relay_http_url());
let resp = client.get(&url).send().await.expect("GET failed");
println!("missing blob → {}", resp.status());
assert_eq!(resp.status(), 404, "missing blob must be 404");
}
/// Upload a real image from the filesystem (set TEST_IMAGE_PATH env var).
/// Verifies the full round-trip: upload → BlobDescriptor → GET bytes match.
#[tokio::test]
#[ignore]
async fn test_upload_real_image() {
let image_path = match std::env::var("TEST_IMAGE_PATH") {
Ok(p) => p,
Err(_) => {
println!("Skipping: TEST_IMAGE_PATH not set");
return;
}
};
let client = http_client();
let keys = Keys::generate();
let bytes = std::fs::read(&image_path).expect("read image file");
let sha256 = hex::encode(Sha256::digest(&bytes));
let size = bytes.len();
println!("image: {image_path}");
println!("size: {size} bytes");
println!("sha256: {sha256}");
let auth = sign_blossom_auth(&keys, &sha256);
let resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("Content-Type", "image/jpeg")
.header("X-SHA-256", &sha256)
.body(bytes.clone())
.send()
.await
.expect("upload PUT failed");
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
println!("PUT /media/upload → {status}: {body_text}");
assert_eq!(status, 200, "upload should succeed");
let descriptor: serde_json::Value =
serde_json::from_str(&body_text).expect("BlobDescriptor JSON");
println!("BlobDescriptor: {descriptor:#}");
assert_eq!(descriptor["sha256"].as_str().unwrap(), sha256);
assert_eq!(descriptor["size"].as_u64().unwrap(), size as u64);
assert!(descriptor["url"].as_str().unwrap().contains(&sha256));
assert!(
descriptor["dim"].as_str().is_some(),
"real image should have dim"
);
assert!(
descriptor["blurhash"].as_str().is_some(),
"real image should have blurhash"
);
// GET bytes back and verify
let get_url = descriptor["url"].as_str().unwrap();
let get_resp = client.get(get_url).send().await.expect("GET failed");
assert_eq!(get_resp.status(), 200);
let returned = get_resp.bytes().await.unwrap();
assert_eq!(
returned.as_ref(),
bytes.as_slice(),
"GET must return original bytes"
);
println!("✅ Real image upload round-trip passed");
}

View File

@@ -0,0 +1,660 @@
//! Extended media upload integration tests — auth edge cases, content validation,
//! multi-format uploads, WebSocket imeta validation.
//!
//! Run: cargo test -p sprout-test-client --test e2e_media_extended -- --ignored --nocapture
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use reqwest::Client;
use sha2::{Digest, Sha256};
use std::time::Duration;
fn relay_http_url() -> String {
std::env::var("RELAY_HTTP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string())
}
fn relay_ws_url() -> String {
relay_http_url()
.replace("http://", "ws://")
.replace("https://", "wss://")
}
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(15))
.build()
.expect("http client")
}
fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_u64();
let tags = vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", sha256]).unwrap(),
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
];
EventBuilder::new(Kind::from(24242), "Upload test", tags)
.sign_with_keys(keys)
.unwrap()
}
fn blossom_auth_header(event: &nostr::Event) -> String {
format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
)
}
async fn upload(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response {
let sha256 = hex::encode(Sha256::digest(body));
let auth = sign_blossom_auth(keys, &sha256);
client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.body(body.to_vec())
.send()
.await
.expect("upload request")
}
// ── Minimal test images ─────────────────────────────────────────────────────
fn tiny_jpeg() -> Vec<u8> {
vec![
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
0x1F, 0x00, 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
0xFF, 0xC4, 0x00, 0xB5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, 0x05,
0x04, 0x04, 0x00, 0x00, 0x01, 0x7D, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21,
0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08,
0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A,
0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37,
0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56,
0x57, 0x58, 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75,
0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x92, 0x93,
0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9,
0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6,
0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2,
0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7,
0xF8, 0xF9, 0xFA, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0x7B, 0x94,
0x11, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0xFF, 0xD9,
]
}
fn tiny_png() -> Vec<u8> {
// Valid 2x2 red PNG generated by ffmpeg
vec![
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x02, 0x08, 0x02, 0x00, 0x00, 0x00, 0xfd,
0xd4, 0x9a, 0x73, 0x00, 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x00, 0x01,
0x00, 0x00, 0x00, 0x01, 0x00, 0x4f, 0x25, 0xc4, 0xd6, 0x00, 0x00, 0x00, 0x10, 0x49, 0x44,
0x41, 0x54, 0x78, 0x9c, 0x63, 0xfc, 0xc3, 0x00, 0x02, 0x2c, 0x60, 0x92, 0x01, 0x00, 0x0d,
0x04, 0x01, 0x02, 0xbf, 0x50, 0x15, 0xb3, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44,
0xae, 0x42, 0x60, 0x82,
]
}
fn tiny_gif() -> Vec<u8> {
vec![
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a
0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00, // LSD 1x1
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // GCT
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Descriptor
0x02, 0x02, 0x4C, 0x01, 0x00, // Image Data
0x3B, // Trailer
]
}
fn tiny_webp() -> Vec<u8> {
// Valid 2x2 red lossy WebP generated by ffmpeg
vec![
0x52, 0x49, 0x46, 0x46, 0x3c, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38,
0x20, 0x30, 0x00, 0x00, 0x00, 0xd0, 0x01, 0x00, 0x9d, 0x01, 0x2a, 0x02, 0x00, 0x02, 0x00,
0x02, 0x00, 0x34, 0x25, 0xa0, 0x02, 0x74, 0xba, 0x01, 0xf8, 0x00, 0x03, 0xb0, 0x00, 0xfe,
0xf0, 0xe8, 0xf7, 0xff, 0x20, 0xb9, 0x61, 0x75, 0xc8, 0xd7, 0xff, 0x20, 0x3f, 0xe4, 0x07,
0xfc, 0x80, 0xff, 0xf8, 0xf2, 0x00, 0x00, 0x00,
]
}
// ── Auth edge case helpers ──────────────────────────────────────────────────
fn sign_custom_auth(keys: &Keys, kind: u16, content: &str, tags: Vec<Tag>) -> nostr::Event {
EventBuilder::new(Kind::from(kind), content, tags)
.sign_with_keys(keys)
.unwrap()
}
async fn upload_with_auth(
client: &Client,
auth_event: &nostr::Event,
sha256: &str,
body: &[u8],
) -> reqwest::Response {
client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(auth_event))
.header("X-SHA-256", sha256)
.body(body.to_vec())
.send()
.await
.expect("upload request")
}
// ═══════════════════════════════════════════════════════════════════════════════
// MULTI-FORMAT UPLOAD TESTS
// ═══════════════════════════════════════════════════════════════════════════════
#[tokio::test]
#[ignore]
async fn test_upload_png_roundtrip() {
let client = http_client();
let keys = Keys::generate();
let png = tiny_png();
let resp = upload(&client, &keys, &png).await;
assert_eq!(resp.status(), 200, "PNG upload should succeed");
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "image/png");
assert!(desc["url"].as_str().unwrap().ends_with(".png"));
println!("✅ PNG upload: {}", desc["url"]);
// GET back
let get = client
.get(desc["url"].as_str().unwrap())
.send()
.await
.unwrap();
assert_eq!(get.status(), 200);
assert_eq!(get.bytes().await.unwrap().as_ref(), png.as_slice());
println!("✅ PNG GET roundtrip verified");
}
#[tokio::test]
#[ignore]
async fn test_upload_gif_roundtrip() {
let client = http_client();
let keys = Keys::generate();
let gif = tiny_gif();
let resp = upload(&client, &keys, &gif).await;
assert_eq!(resp.status(), 200, "GIF upload should succeed");
let desc: serde_json::Value = resp.json().await.unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "image/gif");
assert!(desc["url"].as_str().unwrap().ends_with(".gif"));
println!("✅ GIF upload: {}", desc["url"]);
let get = client
.get(desc["url"].as_str().unwrap())
.send()
.await
.unwrap();
assert_eq!(get.status(), 200);
assert_eq!(get.bytes().await.unwrap().as_ref(), gif.as_slice());
println!("✅ GIF GET roundtrip verified");
}
#[tokio::test]
#[ignore]
async fn test_upload_webp_roundtrip() {
let client = http_client();
let keys = Keys::generate();
let webp = tiny_webp();
let resp = upload(&client, &keys, &webp).await;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
println!("WebP upload → {status}: {body}");
assert_eq!(status, 200, "WebP upload should succeed");
let desc: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(desc["type"].as_str().unwrap(), "image/webp");
assert!(desc["url"].as_str().unwrap().ends_with(".webp"));
println!("✅ WebP upload: {}", desc["url"]);
}
// ═══════════════════════════════════════════════════════════════════════════════
// AUTH EDGE CASE TESTS
// ═══════════════════════════════════════════════════════════════════════════════
#[tokio::test]
#[ignore]
async fn test_auth_wrong_kind() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_u64();
let auth = sign_custom_auth(
&keys,
27235,
"Upload test",
vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "wrong kind must be 401");
println!("✅ Wrong kind → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_missing_t_tag() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_u64();
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "missing t tag must be 401");
println!("✅ Missing t tag → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_missing_expiration() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "missing expiration must be 401");
println!("✅ Missing expiration → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_expired_token() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_u64();
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &(now - 60).to_string()]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "expired token must be 401");
println!("✅ Expired token → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_empty_content() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_u64();
let auth = sign_custom_auth(
&keys,
24242,
"",
vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "empty content must be 401");
println!("✅ Empty content → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_server_tag_mismatch() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_u64();
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
Tag::parse(&["server", "evil.example.com"]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 401, "server tag mismatch must be 401");
println!("✅ Server tag mismatch → 401");
}
#[tokio::test]
#[ignore]
async fn test_auth_server_tag_correct() {
let client = http_client();
let keys = Keys::generate();
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let now = Timestamp::now().as_u64();
let auth = sign_custom_auth(
&keys,
24242,
"Upload test",
vec![
Tag::parse(&["t", "upload"]).unwrap(),
Tag::parse(&["x", &sha256]).unwrap(),
Tag::parse(&["expiration", &(now + 300).to_string()]).unwrap(),
Tag::parse(&["server", "localhost:3000"]).unwrap(),
],
);
let resp = upload_with_auth(&client, &auth, &sha256, &jpeg).await;
assert_eq!(resp.status(), 200, "correct server tag must be 200");
println!("✅ Correct server tag → 200");
}
// ═══════════════════════════════════════════════════════════════════════════════
// CONTENT VALIDATION TESTS
// ═══════════════════════════════════════════════════════════════════════════════
#[tokio::test]
#[ignore]
async fn test_reject_svg() {
let client = http_client();
let keys = Keys::generate();
let svg = b"<?xml version=\"1.0\"?><svg xmlns=\"http://www.w3.org/2000/svg\"></svg>";
let resp = upload(&client, &keys, svg).await;
let status = resp.status().as_u16();
assert!(
status == 400 || status == 415,
"SVG must be 400 or 415, got {status}"
);
println!("✅ SVG → {status}");
}
#[tokio::test]
#[ignore]
async fn test_reject_pdf() {
let client = http_client();
let keys = Keys::generate();
let pdf = b"%PDF-1.4 fake pdf content here for testing";
let resp = upload(&client, &keys, pdf).await;
// PDF might be 400 (unknown) or 415 (disallowed) depending on infer detection
let status = resp.status().as_u16();
assert!(
status == 400 || status == 415,
"PDF must be 400 or 415, got {status}"
);
println!("✅ PDF → {status}");
}
#[tokio::test]
#[ignore]
async fn test_reject_zero_bytes() {
let client = http_client();
let keys = Keys::generate();
let resp = upload(&client, &keys, b"").await;
assert_eq!(resp.status(), 400, "zero bytes must be 400");
println!("✅ Zero bytes → 400");
}
#[tokio::test]
#[ignore]
async fn test_reject_random_bytes() {
let client = http_client();
let keys = Keys::generate();
let random: Vec<u8> = (0..1000).map(|i| (i * 37 % 256) as u8).collect();
let resp = upload(&client, &keys, &random).await;
let status = resp.status().as_u16();
assert!(
status == 400 || status == 415,
"random bytes must be rejected, got {status}"
);
println!("✅ Random bytes → {status}");
}
// ═══════════════════════════════════════════════════════════════════════════════
// CONCURRENT UPLOAD TEST
// ═══════════════════════════════════════════════════════════════════════════════
#[tokio::test]
#[ignore]
async fn test_concurrent_upload_same_file() {
let client = http_client();
let jpeg = tiny_jpeg();
let keys1 = Keys::generate();
let keys2 = Keys::generate();
let c1 = client.clone();
let c2 = client.clone();
let j1 = jpeg.clone();
let j2 = jpeg.clone();
let (r1, r2) = tokio::join!(async { upload(&c1, &keys1, &j1).await }, async {
upload(&c2, &keys2, &j2).await
},);
assert_eq!(r1.status(), 200, "concurrent upload 1 must succeed");
assert_eq!(r2.status(), 200, "concurrent upload 2 must succeed");
let d1: serde_json::Value = r1.json().await.unwrap();
let d2: serde_json::Value = r2.json().await.unwrap();
assert_eq!(d1["sha256"], d2["sha256"], "same content = same hash");
assert_eq!(d1["url"], d2["url"], "same content = same URL");
println!("✅ Concurrent upload: both succeeded, same sha256/url");
}
// ═══════════════════════════════════════════════════════════════════════════════
// WEBSOCKET IMETA VALIDATION
// ═══════════════════════════════════════════════════════════════════════════════
#[tokio::test]
#[ignore]
async fn test_ws_valid_imeta() {
use sprout_test_client::SproutTestClient;
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let http = http_client();
// Create channel via REST
let ch: serde_json::Value = http
.post(format!("{}/api/channels", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.json(&serde_json::json!({
"name": format!("ws-imeta-test-{}", rand::random::<u32>()),
"channel_type": "stream",
"visibility": "open"
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let channel_id = ch["id"].as_str().unwrap();
println!("Channel: {channel_id}");
// Upload a JPEG to get a valid sha256
let jpeg = tiny_jpeg();
let sha256 = hex::encode(Sha256::digest(&jpeg));
let resp = upload(&http, &keys, &jpeg).await;
assert_eq!(resp.status(), 200);
// Connect via WebSocket
let mut client = SproutTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
// Send event with valid imeta
let event = EventBuilder::new(
Kind::from(9),
"image via ws",
vec![
Tag::parse(&["h", channel_id]).unwrap(),
Tag::parse(&[
"imeta",
&format!("url http://localhost:3000/media/{sha256}.jpg"),
"m image/jpeg",
&format!("x {sha256}"),
"size 347",
])
.unwrap(),
],
)
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.unwrap();
assert!(
ok.accepted,
"valid imeta via WS must be accepted: {:?}",
ok.message
);
println!("✅ WS valid imeta accepted");
client.disconnect().await.unwrap();
}
#[tokio::test]
#[ignore]
async fn test_ws_invalid_imeta_external_url() {
use sprout_test_client::SproutTestClient;
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let http = http_client();
let ch: serde_json::Value = http
.post(format!("{}/api/channels", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.json(&serde_json::json!({
"name": format!("ws-imeta-bad-{}", rand::random::<u32>()),
"channel_type": "stream",
"visibility": "open"
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let channel_id = ch["id"].as_str().unwrap();
let sha = "a".repeat(64);
let mut client = SproutTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
let event = EventBuilder::new(
Kind::from(9),
"bad imeta",
vec![
Tag::parse(&["h", channel_id]).unwrap(),
Tag::parse(&[
"imeta",
&format!("url https://evil.com/media/{sha}.jpg"),
"m image/jpeg",
&format!("x {sha}"),
"size 347",
])
.unwrap(),
],
)
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.unwrap();
assert!(!ok.accepted, "external URL imeta via WS must be rejected");
assert!(
ok.message.contains("invalid"),
"rejection message must contain 'invalid': {:?}",
ok.message
);
println!(
"✅ WS invalid imeta (external URL) rejected: {:?}",
ok.message
);
client.disconnect().await.unwrap();
}
#[tokio::test]
#[ignore]
async fn test_ws_invalid_imeta_missing_fields() {
use sprout_test_client::SproutTestClient;
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
let http = http_client();
let ch: serde_json::Value = http
.post(format!("{}/api/channels", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.json(&serde_json::json!({
"name": format!("ws-imeta-miss-{}", rand::random::<u32>()),
"channel_type": "stream",
"visibility": "open"
}))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let channel_id = ch["id"].as_str().unwrap();
let sha = "b".repeat(64);
let mut client = SproutTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
// Only url, missing m/x/size
let event = EventBuilder::new(
Kind::from(9),
"incomplete imeta",
vec![
Tag::parse(&["h", channel_id]).unwrap(),
Tag::parse(&[
"imeta",
&format!("url http://localhost:3000/media/{sha}.jpg"),
])
.unwrap(),
],
)
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.unwrap();
assert!(!ok.accepted, "incomplete imeta via WS must be rejected");
println!("✅ WS incomplete imeta rejected: {:?}", ok.message);
client.disconnect().await.unwrap();
}

View File

@@ -24,6 +24,7 @@ allow = [
"CC0-1.0",
"CDLA-Permissive-2.0",
"MITNFA",
"MPL-2.0",
]
confidence-threshold = 0.8

View File

@@ -32,10 +32,11 @@ const rules = [
const overrides = new Map([
["src/app/AppShell.tsx", 700],
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
["src/features/messages/ui/MessageComposer.tsx", 650], // media upload handlers (paste, drop, dialog)
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 650],
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
["src/shared/api/tauri.ts", 900],
["src/shared/api/tauri.ts", 950],
]);
async function walkFiles(directory) {

View File

@@ -1969,6 +1969,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "infer"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847"
dependencies = [
"cfb",
]
[[package]]
name = "infer"
version = "0.19.0"
@@ -3459,6 +3468,30 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -4019,6 +4052,9 @@ version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"hex",
"infer 0.16.0",
"libc",
"nostr 0.37.0",
"reqwest 0.12.28",
"serde",
@@ -4027,9 +4063,13 @@ dependencies = [
"sprout-core",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-websocket",
"tauri-plugin-window-state",
"tokio",
"url",
"windows-sys 0.59.0",
]
[[package]]
@@ -4361,6 +4401,46 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
dependencies = [
"anyhow",
"dunce",
"glob",
"percent-encoding",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"tauri-utils",
"thiserror 2.0.18",
"toml 0.9.12+spec-1.1.0",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.3"
@@ -4483,7 +4563,7 @@ dependencies = [
"glob",
"html5ever",
"http",
"infer",
"infer 0.19.0",
"json-patch",
"kuchikiki",
"log",

View File

@@ -17,15 +17,26 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-window-state = "2"
tauri-plugin-websocket = "2"
tauri-plugin-dialog = "2"
infer = "0.16"
hex = "0.4"
tokio = { version = "1", features = ["fs", "sync"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
nostr = "0.37"
reqwest = { version = "0.12", features = ["json"] }
url = "2"
sprout-core = { path = "../../crates/sprout-core" }
base64 = "0.22"
sha2 = "0.10"

View File

@@ -10,6 +10,7 @@
"core:window:allow-close",
"opener:default",
"websocket:default",
"window-state:default"
"window-state:default",
"dialog:default"
]
}

View File

@@ -0,0 +1,336 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tauri::State;
use crate::app_state::AppState;
use crate::relay::relay_api_base_url;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobDescriptor {
pub url: String,
pub sha256: String,
pub size: u64,
#[serde(rename = "type")]
pub mime_type: String,
pub uploaded: i64,
pub dim: Option<String>,
pub blurhash: Option<String>,
pub thumb: Option<String>,
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/// Extract the server authority from a URL for BUD-11 server tag scoping.
///
/// Returns `host` for default ports (80/443), `host:port` for non-default ports.
fn extract_server_authority(url_str: &str) -> Option<String> {
let parsed = url::Url::parse(url_str).ok()?;
let host = parsed.host_str()?;
match parsed.port() {
Some(port) => Some(format!("{host}:{port}")),
None => Some(host.to_string()),
}
}
/// Resolve the real filesystem path of an already-opened file descriptor.
///
/// Returns the path the kernel associates with the inode, not the pathname
/// used to open it. Immune to post-open renames/symlink swaps.
#[cfg(target_os = "macos")]
fn fd_real_path(file: &std::fs::File) -> Result<std::path::PathBuf, String> {
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
let mut buf = vec![0u8; libc::PATH_MAX as usize];
let ret = unsafe { libc::fcntl(fd, libc::F_GETPATH, buf.as_mut_ptr()) };
if ret == -1 {
return Err(format!(
"fcntl F_GETPATH failed: {}",
std::io::Error::last_os_error()
));
}
let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let s = std::str::from_utf8(&buf[..nul]).map_err(|e| e.to_string())?;
Ok(std::path::PathBuf::from(s))
}
#[cfg(target_os = "linux")]
fn fd_real_path(file: &std::fs::File) -> Result<std::path::PathBuf, String> {
use std::os::unix::io::AsRawFd;
let fd = file.as_raw_fd();
std::fs::read_link(format!("/proc/self/fd/{fd}")).map_err(|e| e.to_string())
}
#[cfg(target_os = "windows")]
fn fd_real_path(file: &std::fs::File) -> Result<std::path::PathBuf, String> {
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
GetFinalPathNameByHandleW, FILE_NAME_NORMALIZED,
};
let handle = file.as_raw_handle() as isize;
let mut buf = vec![0u16; 1024];
let len = unsafe {
GetFinalPathNameByHandleW(handle, buf.as_mut_ptr(), buf.len() as u32, FILE_NAME_NORMALIZED)
};
if len == 0 {
return Err(format!(
"GetFinalPathNameByHandleW failed: {}",
std::io::Error::last_os_error()
));
}
let path_str = String::from_utf16_lossy(&buf[..len as usize]);
// Strip \\?\ prefix that Windows adds
let cleaned = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
Ok(std::path::PathBuf::from(cleaned))
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn fd_real_path(_file: &std::fs::File) -> Result<std::path::PathBuf, String> {
Err("fd_real_path not supported on this platform".to_string())
}
/// MIME allowlist — must match the server's `ALLOWED_MIME_TYPES`.
const ALLOWED_MIME: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
let mime = infer::get(body)
.map(|t| t.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
if !ALLOWED_MIME.contains(&mime.as_str()) {
return Err(format!("unsupported file type: {mime}"));
}
Ok(mime)
}
fn sign_blossom_upload_auth(keys: &Keys, sha256: &str) -> Result<nostr::Event, String> {
let now = Timestamp::now().as_u64();
let mut tags = vec![
Tag::parse(vec!["t", "upload"]).map_err(|e| e.to_string())?,
Tag::parse(vec!["x", sha256]).map_err(|e| e.to_string())?,
Tag::parse(vec!["expiration", &(now + 300).to_string()]).map_err(|e| e.to_string())?,
];
let base_url = relay_api_base_url();
if let Some(domain) = extract_server_authority(&base_url) {
tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?);
}
EventBuilder::new(Kind::from(24242), "Upload sprout-media")
.tags(tags)
.sign_with_keys(keys)
.map_err(|e| e.to_string())
}
/// Execute the upload HTTP request. Shared by all upload entry points.
async fn do_upload(
body: Vec<u8>,
mime: &str,
state: &State<'_, AppState>,
) -> Result<BlobDescriptor, String> {
let sha256 = format!("{:x}", Sha256::digest(&body));
let auth_event = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
sign_blossom_upload_auth(&keys, &sha256)?
};
let auth_header = format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes())
);
let base_url = relay_api_base_url();
let mut req = state
.http_client
.put(format!("{base_url}/media/upload"))
.header("Authorization", &auth_header)
.header("Content-Type", mime)
.header("X-SHA-256", &sha256);
if let Some(ref token) = state.configured_api_token {
req = req.header("X-Auth-Token", token.as_str());
} else if let Ok(guard) = state.session_token.lock() {
if let Some(ref token) = *guard {
req = req.header("X-Auth-Token", token.as_str());
}
}
let resp = req
.body(body)
.send()
.await
.map_err(|e| format!("upload failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
return Err(format!("upload failed ({status}): {text}"));
}
resp.json::<BlobDescriptor>()
.await
.map_err(|e| format!("parse failed: {e}"))
}
// ── Commands ─────────────────────────────────────────────────────────────────
/// Upload a file that is already in the OS temp directory.
///
/// Trust boundary: only reads files inside `temp_dir()`. Opens the fd first,
/// then resolves the fd's real path to verify containment (TOCTOU-safe).
#[tauri::command]
pub async fn upload_media(
file_path: String,
is_temp: bool,
state: State<'_, AppState>,
) -> Result<BlobDescriptor, String> {
let path = std::path::Path::new(&file_path);
let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let fd_path = fd_real_path(&file)?;
let canonical_temp = std::env::temp_dir()
.canonicalize()
.unwrap_or_else(|_| std::env::temp_dir());
if !fd_path.starts_with(&canonical_temp) {
return Err("upload source must be in system temp directory".to_string());
}
use std::io::Read;
let mut body = Vec::new();
file.read_to_end(&mut body)
.map_err(|e| format!("failed to read file: {e}"))?;
drop(file);
if is_temp {
let _ = std::fs::remove_file(&fd_path);
}
let mime = detect_and_validate_mime(&body)?;
do_upload(body, &mime, &state).await
}
/// Open a native file dialog, read the selected file, and upload it.
///
/// All file I/O happens in trusted Rust — the renderer never touches the
/// filesystem. This is the secure path for the 📎 paperclip button.
///
/// **Residual TOCTOU note:** The Tauri dialog plugin returns a pathname, not
/// a file handle, so there is a small race window between dialog return and
/// `File::open()`. This is an inherent limitation of the OS file-picker API
/// (no platform exposes a handle/bookmark from the open-file dialog in a way
/// the Tauri plugin surfaces). The risk is bounded: the attacker must be local
/// and must win a race against an immediate open. Server-side content validation
/// (MIME, image decode, size caps) provides defense-in-depth.
#[tauri::command]
pub async fn pick_and_upload_media(
app: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Option<BlobDescriptor>, String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter("Images", &["jpg", "jpeg", "png", "gif", "webp"])
.pick_file(move |path| {
let _ = tx.send(path);
});
let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?;
let file_path = match selected {
Some(p) => p,
None => return Ok(None),
};
// Use the same fd-first pattern as upload_media: open the file to pin
// the inode, then read from the handle. Prevents a local attacker from
// swapping the selected path between dialog return and read.
let path = file_path.as_path().ok_or("invalid path")?;
let mut file = std::fs::File::open(path).map_err(|e| e.to_string())?;
use std::io::Read;
let mut body = Vec::new();
file.read_to_end(&mut body)
.map_err(|e| format!("failed to read file: {e}"))?;
drop(file);
let mime = detect_and_validate_mime(&body)?;
do_upload(body, &mime, &state).await.map(Some)
}
/// Upload raw bytes directly (for paste and drag-drop).
///
/// The renderer already has the bytes in memory from the clipboard/drag event.
/// Passing them via IPC avoids granting the renderer filesystem write access.
#[tauri::command]
pub async fn upload_media_bytes(
data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<BlobDescriptor, String> {
if data.is_empty() {
return Err("empty upload".to_string());
}
let mime = detect_and_validate_mime(&data)?;
do_upload(data, &mime, &state).await
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_server_authority_default_ports() {
assert_eq!(
extract_server_authority("https://relay.example.com"),
Some("relay.example.com".to_string())
);
assert_eq!(
extract_server_authority("https://relay.example.com:443"),
Some("relay.example.com".to_string())
);
assert_eq!(
extract_server_authority("http://relay.example.com:80"),
Some("relay.example.com".to_string())
);
}
#[test]
fn test_extract_server_authority_non_default_ports() {
assert_eq!(
extract_server_authority("http://localhost:3000"),
Some("localhost:3000".to_string())
);
assert_eq!(
extract_server_authority("https://relay.example.com:8443"),
Some("relay.example.com:8443".to_string())
);
}
#[test]
fn test_extract_server_authority_ipv6() {
assert_eq!(
extract_server_authority("http://[::1]:3000"),
Some("[::1]:3000".to_string())
);
}
#[test]
fn test_extract_server_authority_invalid() {
assert_eq!(extract_server_authority("not-a-url"), None);
assert_eq!(extract_server_authority(""), None);
}
#[test]
fn test_detect_and_validate_mime_jpeg() {
// Minimal JPEG: SOI + EOI
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0];
assert_eq!(detect_and_validate_mime(&jpeg).unwrap(), "image/jpeg");
}
#[test]
fn test_detect_and_validate_mime_rejects_text() {
let text = b"hello world";
assert!(detect_and_validate_mime(text).is_err());
}
}

View File

@@ -45,6 +45,8 @@ pub async fn send_channel_message(
channel_id: String,
content: String,
parent_event_id: Option<String>,
media_tags: Option<Vec<Vec<String>>>,
mention_pubkeys: Option<Vec<String>>,
state: State<'_, AppState>,
) -> Result<SendChannelMessageResponse, String> {
let path = format!("/api/channels/{channel_id}/messages");
@@ -53,6 +55,8 @@ pub async fn send_channel_message(
content: content.trim(),
parent_event_id: parent_event_id.as_deref(),
broadcast_to_channel: false,
media_tags,
mention_pubkeys: mention_pubkeys.unwrap_or_default(),
},
);

View File

@@ -1,6 +1,7 @@
mod agents;
mod channels;
mod identity;
mod media;
mod messages;
mod profile;
mod tokens;
@@ -8,6 +9,7 @@ mod tokens;
pub use agents::*;
pub use channels::*;
pub use identity::*;
pub use media::*;
pub use messages::*;
pub use profile::*;
pub use tokens::*;

View File

@@ -19,6 +19,7 @@ pub fn run() {
.build(),
)
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_dialog::init())
.manage(build_app_state())
.invoke_handler(tauri::generate_handler![
get_identity,
@@ -53,6 +54,9 @@ pub fn run() {
add_reaction,
remove_reaction,
get_event,
upload_media,
pick_and_upload_media,
upload_media_bytes,
list_tokens,
mint_token,
revoke_token,

View File

@@ -174,6 +174,10 @@ pub struct SendChannelMessageBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_event_id: Option<&'a str>,
pub broadcast_to_channel: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_tags: Option<Vec<Vec<String>>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub mention_pubkeys: Vec<String>,
}
#[derive(Serialize)]

View File

@@ -620,11 +620,12 @@ export function AppShell() {
onCancelReply={() => {
setReplyTargetId(null);
}}
onSend={async (content, mentionPubkeys) => {
onSend={async (content, mentionPubkeys, mediaTags) => {
await sendMessageMutation.mutateAsync({
content,
mentionPubkeys,
parentEventId: replyTargetId,
mediaTags,
});
setReplyTargetId(null);
}}

View File

@@ -62,6 +62,7 @@ function createOptimisticMessage(
currentMessages: RelayEvent[],
mentionPubkeys: string[] = [],
parentEventId: string | null = null,
mediaTags: string[][] = [],
): RelayEvent {
const tags: string[][] = [];
@@ -81,6 +82,10 @@ function createOptimisticMessage(
}
}
for (const tag of mediaTags) {
tags.push(tag);
}
return {
id: `optimistic-${crypto.randomUUID()}`,
pubkey: identity.pubkey,
@@ -218,10 +223,16 @@ export function useSendMessageMutation(
content: string;
mentionPubkeys?: string[];
parentEventId?: string | null;
mediaTags?: string[][];
},
MessageQueryContext | undefined
>({
mutationFn: async ({ content, mentionPubkeys, parentEventId }) => {
mutationFn: async ({
content,
mentionPubkeys,
parentEventId,
mediaTags,
}) => {
if (!channel || channel.channelType === "forum") {
throw new Error("This channel does not support message sending yet.");
}
@@ -230,7 +241,9 @@ export function useSendMessageMutation(
throw new Error("No identity available for sending messages.");
}
if (parentEventId) {
// Media-bearing messages MUST go through REST so the relay's imeta
// validation runs. The WebSocket path does not validate imeta tags.
if (parentEventId || (mediaTags && mediaTags.length > 0)) {
const cachedMessages =
queryClient.getQueryData<RelayEvent[]>([
"channel-messages",
@@ -239,28 +252,52 @@ export function useSendMessageMutation(
const result = await sendChannelMessage(
channel.id,
content,
parentEventId,
parentEventId ?? null,
mediaTags,
mentionPubkeys,
);
// Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta.
// For replies, buildReplyTags already includes ["p", author] and ["h", channel].
// For non-replies (media-only), we add them ourselves.
const replyTags = parentEventId
? buildReplyTags(
channel.id,
identity.pubkey,
parentEventId,
resolveReplyRootId(parentEventId, cachedMessages),
)
: [];
const baseTags = parentEventId
? replyTags // buildReplyTags includes h + author p
: [
["h", channel.id],
["p", identity.pubkey],
]; // non-reply: add ourselves
return {
id: result.eventId,
pubkey: identity.pubkey,
created_at: result.createdAt,
kind: KIND_STREAM_MESSAGE,
tags: buildReplyTags(
channel.id,
identity.pubkey,
parentEventId,
resolveReplyRootId(parentEventId, cachedMessages),
),
tags: [
...baseTags,
...(mentionPubkeys ?? []).map((pk) => ["p", pk]),
...(mediaTags ?? []),
],
content: content.trim(),
sig: "",
};
}
return relayClient.sendMessage(channel.id, content, mentionPubkeys ?? []);
return relayClient.sendMessage(
channel.id,
content,
mentionPubkeys ?? [],
[],
);
},
onMutate: async ({ content, mentionPubkeys, parentEventId }) => {
onMutate: async ({ content, mentionPubkeys, parentEventId, mediaTags }) => {
if (!channel || !identity || channel.channelType === "forum") {
return undefined;
}
@@ -277,6 +314,7 @@ export function useSendMessageMutation(
previousMessages,
mentionPubkeys ?? [],
parentEventId ?? null,
mediaTags ?? [],
);
queryClient.setQueryData<RelayEvent[]>(

View File

@@ -0,0 +1,52 @@
export type ImetaEntry = {
url: string;
m: string;
x: string;
size: number;
dim?: string;
blurhash?: string;
alt?: string;
thumb?: string;
};
export function parseImetaTags(tags: string[][]): Map<string, ImetaEntry> {
const map = new Map<string, ImetaEntry>();
for (const tag of tags) {
if (tag[0] !== "imeta") continue;
const entry: Partial<ImetaEntry> = {};
for (const part of tag.slice(1)) {
const spaceIdx = part.indexOf(" ");
if (spaceIdx === -1) continue;
const key = part.slice(0, spaceIdx);
const val = part.slice(spaceIdx + 1);
switch (key) {
case "url":
entry.url = val;
break;
case "m":
entry.m = val;
break;
case "x":
entry.x = val;
break;
case "size":
entry.size = parseInt(val, 10);
break;
case "dim":
entry.dim = val;
break;
case "blurhash":
entry.blurhash = val;
break;
case "alt":
entry.alt = val;
break;
case "thumb":
entry.thumb = val;
break;
}
}
if (entry.url) map.set(entry.url, entry as ImetaEntry);
}
return map;
}

View File

@@ -3,6 +3,11 @@ import * as React from "react";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import {
type BlobDescriptor,
pickAndUploadMedia,
uploadMediaBytes,
} from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import {
@@ -16,7 +21,11 @@ type MessageComposerProps = {
disabled?: boolean;
isSending?: boolean;
onCancelReply?: () => void;
onSend: (content: string, mentionPubkeys: string[]) => Promise<void>;
onSend: (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
) => Promise<void>;
placeholder?: string;
replyTarget?: {
author: string;
@@ -67,6 +76,13 @@ export function MessageComposer({
const [mentionSelectedIndex, setMentionSelectedIndex] = React.useState(0);
const mentionMapRef = React.useRef<Map<string, string>>(new Map());
// Upload state
const [uploadState, setUploadState] = React.useState<{
status: "idle" | "uploading" | "error";
message?: string;
}>({ status: "idle" });
const [pendingImeta, setPendingImeta] = React.useState<BlobDescriptor[]>([]);
const membersQuery = useChannelMembersQuery(channelId);
const members = membersQuery.data ?? [];
const managedAgentsQuery = useManagedAgentsQuery();
@@ -179,23 +195,149 @@ export function MessageComposer({
[members, managedAgentNamesByPubkey],
);
// Shared handler: got a descriptor back from any upload path.
const onUploaded = React.useCallback((descriptor: BlobDescriptor) => {
const markdown = `\n![image](${descriptor.url})\n`;
setContent((prev) => prev + markdown);
setPendingImeta((prev) => [...prev, descriptor]);
setUploadState({ status: "idle" });
}, []);
// 📎 Paperclip: native file dialog + read + upload, all in trusted Rust.
// The renderer never touches the filesystem.
const handlePaperclip = React.useCallback(async () => {
setUploadState({ status: "uploading" });
try {
const descriptor = await pickAndUploadMedia();
if (descriptor) {
onUploaded(descriptor);
} else {
setUploadState({ status: "idle" }); // user cancelled dialog
}
} catch (err) {
setUploadState({ status: "error", message: String(err) });
}
}, [onUploaded]);
// 🖱️ Drop: read bytes in JS, send via IPC to Rust for upload.
// No filesystem access needed — bytes come from the drag event.
const handleDrop = React.useCallback(
async (event: React.DragEvent<HTMLFormElement>) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;
const file = files[0];
if (!file) return;
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
];
if (!ALLOWED_TYPES.includes(file.type)) {
setUploadState({
status: "error",
message: "Only JPEG, PNG, GIF, and WebP images are supported",
});
return;
}
setUploadState({ status: "uploading" });
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
onUploaded(descriptor);
} catch (err) {
setUploadState({ status: "error", message: String(err) });
}
},
[onUploaded],
);
const handleDragOver = React.useCallback(
(event: React.DragEvent<HTMLFormElement>) => {
event.preventDefault();
},
[],
);
// 📋 Paste: read bytes from clipboard, send via IPC to Rust for upload.
const handlePaste = React.useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(event.clipboardData.items);
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
];
const imageItem = items.find((item) => ALLOWED_TYPES.includes(item.type));
if (!imageItem) return;
event.preventDefault();
const file = imageItem.getAsFile();
if (!file) return;
setUploadState({ status: "uploading" });
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
onUploaded(descriptor);
} catch (err) {
setUploadState({ status: "error", message: String(err) });
}
},
[onUploaded],
);
const submitMessage = React.useCallback(async () => {
const trimmed = content.trim();
if (!trimmed || disabled || isSending) {
const hasMedia = pendingImeta.length > 0;
if ((!trimmed && !hasMedia) || disabled || isSending) {
return;
}
const pubkeys = extractMentionPubkeys(trimmed);
// Build imeta tags from pending descriptors
const mediaTags =
pendingImeta.length > 0
? pendingImeta.map((d) => [
"imeta",
`url ${d.url}`,
`m ${d.type}`,
`x ${d.sha256}`,
`size ${d.size}`,
...(d.dim ? [`dim ${d.dim}`] : []),
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
...(d.thumb ? [`thumb ${d.thumb}`] : []),
])
: undefined;
const savedContent = trimmed;
const savedImeta = [...pendingImeta];
setContent("");
setPendingImeta([]);
mentionMapRef.current.clear();
setMentionQuery(null);
try {
await onSend(trimmed, pubkeys);
await onSend(trimmed, pubkeys, mediaTags);
} catch {
setContent(trimmed);
setContent(savedContent);
setPendingImeta(savedImeta);
}
}, [content, disabled, isSending, onSend, extractMentionPubkeys]);
}, [
content,
disabled,
isSending,
onSend,
extractMentionPubkeys,
pendingImeta,
]);
const handleSubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
@@ -328,12 +470,18 @@ export function MessageComposer({
textareaRef.current?.focus();
}, [disabled, replyTarget]);
const isUploading = uploadState.status === "uploading";
return (
<footer className="border-t border-border/80 bg-background p-4">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
<form
className="relative rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
data-testid="message-composer"
onDragOver={handleDragOver}
onDrop={(e) => {
void handleDrop(e);
}}
onSubmit={(event) => {
handleSubmit(event);
}}
@@ -369,6 +517,19 @@ export function MessageComposer({
</div>
) : null}
{uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {uploadState.message}
<button
className="ml-2 underline"
onClick={() => setUploadState({ status: "idle" })}
type="button"
>
Dismiss
</button>
</div>
) : null}
<Textarea
aria-label="Message channel"
className="min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 shadow-none focus-visible:ring-0"
@@ -376,6 +537,9 @@ export function MessageComposer({
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
onPaste={(e) => {
void handlePaste(e);
}}
placeholder={
placeholder ??
(replyTarget
@@ -389,8 +553,21 @@ export function MessageComposer({
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Button disabled size="icon" type="button" variant="ghost">
<Button
disabled={disabled || isUploading}
onClick={() => {
void handlePaperclip();
}}
size="icon"
title="Attach image"
type="button"
variant="ghost"
>
{isUploading ? (
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<Paperclip className="h-4 w-4" />
)}
</Button>
<Button disabled size="icon" type="button" variant="ghost">
<SmilePlus className="h-4 w-4" />
@@ -400,7 +577,11 @@ export function MessageComposer({
<Button
className="gap-2"
data-testid="send-message"
disabled={disabled || isSending || content.trim().length === 0}
disabled={
disabled ||
isSending ||
(content.trim().length === 0 && pendingImeta.length === 0)
}
title="Send (Enter)"
type="submit"
>

View File

@@ -70,6 +70,7 @@ class RelayClient {
channelId: string,
content: string,
mentionPubkeys: string[] = [],
extraTags: string[][] = [],
) {
await this.ensureConnected();
@@ -77,6 +78,9 @@ class RelayClient {
for (const pubkey of mentionPubkeys) {
tags.push(["p", pubkey]);
}
for (const tag of extraTags) {
tags.push(tag);
}
const event = await signRelayEvent({
kind: KIND_STREAM_MESSAGE,

View File

@@ -591,6 +591,8 @@ export async function sendChannelMessage(
channelId: string,
content: string,
parentEventId?: string | null,
mediaTags?: string[][],
mentionPubkeys?: string[],
): Promise<SendChannelMessageResult> {
const response = await invokeTauri<RawSendChannelMessageResult>(
"send_channel_message",
@@ -598,6 +600,8 @@ export async function sendChannelMessage(
channelId,
content,
parentEventId,
mediaTags: mediaTags ?? null,
mentionPubkeys: mentionPubkeys ?? null,
},
);
@@ -610,6 +614,37 @@ export async function sendChannelMessage(
};
}
export type BlobDescriptor = {
url: string;
sha256: string;
size: number;
type: string;
uploaded: number;
dim?: string;
blurhash?: string;
thumb?: string;
};
export async function uploadMedia(
filePath: string,
isTemp: boolean,
): Promise<BlobDescriptor> {
return invokeTauri<BlobDescriptor>("upload_media", {
filePath,
isTemp,
});
}
export async function pickAndUploadMedia(): Promise<BlobDescriptor | null> {
return invokeTauri<BlobDescriptor | null>("pick_and_upload_media", {});
}
export async function uploadMediaBytes(
data: number[],
): Promise<BlobDescriptor> {
return invokeTauri<BlobDescriptor>("upload_media_bytes", { data });
}
export async function addReaction(
eventId: string,
emoji: string,

View File

@@ -126,6 +126,54 @@ services:
com.sprout.env: "dev"
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: sprout-minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: sprout_dev
MINIO_ROOT_PASSWORD: sprout_dev_secret
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio-data:/data
networks:
- sprout-net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
deploy:
resources:
limits:
memory: 256m
labels:
com.sprout.service: "minio"
com.sprout.env: "dev"
restart: unless-stopped
minio-init:
image: minio/mc:latest
container_name: sprout-minio-init
depends_on:
minio:
condition: service_healthy
networks:
- sprout-net
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 sprout_dev sprout_dev_secret &&
mc mb --ignore-existing local/sprout-media &&
mc anonymous set none local/sprout-media
"
labels:
com.sprout.service: "minio-init"
com.sprout.env: "dev"
restart: "no"
volumes:
mysql-data:
name: sprout-mysql-data
@@ -135,6 +183,10 @@ volumes:
name: sprout-typesense-data
labels:
com.sprout.volume: "typesense"
minio-data:
name: sprout-minio-data
labels:
com.sprout.volume: "minio"
networks:
sprout-net: