feat: Phase 1 video upload support (Blossom-compliant-ish) (#285)

This commit is contained in:
tlongwell-block
2026-04-10 12:23:29 -04:00
committed by GitHub
parent a791566c91
commit 61efb883a1
26 changed files with 3719 additions and 116 deletions

63
Cargo.lock generated
View File

@@ -1971,6 +1971,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -2157,6 +2163,20 @@ dependencies = [
"pxfm",
]
[[package]]
name = "mp4"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3"
dependencies = [
"byteorder",
"bytes",
"num-rational",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "negentropy"
version = "0.3.1"
@@ -2283,6 +2303,18 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
"serde",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3084,6 +3116,19 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.37"
@@ -3753,18 +3798,23 @@ dependencies = [
"blurhash",
"bytes",
"chrono",
"futures-core",
"futures-util",
"hex",
"image",
"imagesize",
"infer",
"mp4",
"nostr",
"rust-s3",
"serde",
"serde_json",
"sha2 0.11.0",
"sprout-core",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-util",
"tracing",
]
@@ -4218,6 +4268,19 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "thiserror"
version = "1.0.69"

View File

@@ -25,6 +25,11 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
blurhash = "0.2"
imagesize = "0.14"
bytes = "1"
mp4 = "0.14"
tempfile = "3"
tokio-util = { version = "0.7", features = ["io"] }
futures-util = "0.3"
futures-core = "0.3"
[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }

View File

@@ -15,6 +15,7 @@ use crate::error::MediaError;
pub fn verify_blossom_auth_event(
auth_event: &nostr::Event,
server_domain: Option<&str>,
max_age_secs: u64,
) -> Result<(), MediaError> {
// 1. Verify Schnorr signature
auth_event
@@ -84,8 +85,7 @@ pub fn verify_blossom_auth_event(
if created > now + 5 {
return Err(MediaError::TimestampOutOfWindow);
}
const MAX_AGE_SECS: u64 = 600; // 10 minutes
if now > created + MAX_AGE_SECS {
if now > created + max_age_secs {
return Err(MediaError::TimestampOutOfWindow);
}
@@ -117,8 +117,9 @@ pub fn verify_blossom_upload_auth(
auth_event: &nostr::Event,
sha256: &str,
server_domain: Option<&str>,
max_age_secs: u64,
) -> Result<(), MediaError> {
verify_blossom_auth_event(auth_event, server_domain)?;
verify_blossom_auth_event(auth_event, server_domain, max_age_secs)?;
// At least one x tag must match the body sha256 (BUD-11 §6)
let has_matching_x = auth_event
@@ -156,7 +157,7 @@ mod tests {
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());
assert!(verify_blossom_upload_auth(&event, &sha256, None, 600).is_ok());
}
#[test]
@@ -164,7 +165,7 @@ mod tests {
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());
assert!(verify_blossom_auth_event(&event, None, 600).is_ok());
}
#[test]
@@ -174,7 +175,7 @@ mod tests {
let event = build_valid_auth(&keys, &sha256);
let wrong_hash = "b".repeat(64);
assert!(matches!(
verify_blossom_upload_auth(&event, &wrong_hash, None),
verify_blossom_upload_auth(&event, &wrong_hash, None, 600),
Err(MediaError::HashMismatch)
));
}
@@ -194,7 +195,7 @@ mod tests {
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, None),
verify_blossom_upload_auth(&event, &sha256, None, 600),
Err(MediaError::InvalidAuthKind)
));
}
@@ -216,7 +217,7 @@ mod tests {
.sign_with_keys(&keys)
.unwrap();
// Should pass because at least one x tag matches
assert!(verify_blossom_upload_auth(&event, &sha256, None).is_ok());
assert!(verify_blossom_upload_auth(&event, &sha256, None, 600).is_ok());
}
#[test]
@@ -236,14 +237,16 @@ mod tests {
.unwrap();
// Should fail — server tag present but doesn't match our domain
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, Some("sprout.example.com")),
verify_blossom_upload_auth(&event, &sha256, Some("sprout.example.com"), 600),
Err(MediaError::ServerMismatch)
));
// Should pass when our domain matches
assert!(verify_blossom_upload_auth(&event, &sha256, Some("other.example.com")).is_ok());
assert!(
verify_blossom_upload_auth(&event, &sha256, Some("other.example.com"), 600).is_ok()
);
// Should fail when server_domain is None — fail closed
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, None),
verify_blossom_upload_auth(&event, &sha256, None, 600),
Err(MediaError::ServerMismatch)
));
}
@@ -254,7 +257,7 @@ mod tests {
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());
assert!(verify_blossom_upload_auth(&event, &sha256, Some("any.domain.com"), 600).is_ok());
}
#[test]
@@ -273,7 +276,7 @@ mod tests {
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_auth_event(&event, None),
verify_blossom_auth_event(&event, None, 600),
Err(MediaError::InvalidAuthEvent)
));
}

View File

@@ -1,5 +1,9 @@
//! Media storage configuration.
fn default_max_video_bytes() -> u64 {
524_288_000 // 500 MB
}
/// Configuration for media storage (S3/MinIO).
#[derive(Debug, Clone, serde::Deserialize)]
pub struct MediaConfig {
@@ -15,6 +19,9 @@ pub struct MediaConfig {
pub max_image_bytes: u64,
/// Maximum upload size for animated GIFs (bytes). Default: 10 MB.
pub max_gif_bytes: u64,
/// Maximum upload size for video files (bytes). Default: 500 MB.
#[serde(default = "default_max_video_bytes")]
pub max_video_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.
@@ -46,6 +53,9 @@ impl MediaConfig {
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());
}
if self.max_video_bytes == 0 {
return Err("max_video_bytes must be > 0".to_string());
}
Ok(())
}
}

View File

@@ -54,6 +54,27 @@ pub enum MediaError {
TokenRevoked,
#[error("pubkey mismatch")]
PubkeyMismatch,
/// Video codec is not H.264 (avc1).
#[error("unsupported video codec: only H.264 (avc1) is accepted")]
WrongCodec,
/// Video duration exceeds the 600-second limit.
#[error("video too long: duration exceeds 600 seconds")]
DurationTooLong,
/// Video resolution exceeds 3840×2160.
#[error("video resolution too high: maximum is 3840x2160")]
ResolutionTooHigh,
/// MP4 moov atom appears after mdat — not fast-start.
#[error("moov atom not at front of file (not fast-start)")]
MoovNotAtFront,
/// Container is not MP4 (e.g. MOV, MKV).
#[error("unsupported container: only MP4 is accepted")]
UnsupportedContainer,
/// MP4 metadata could not be parsed.
#[error("invalid video data")]
InvalidVideo,
/// I/O error during streaming upload.
#[error("io error: {0}")]
Io(String),
}
impl From<image::ImageError> for MediaError {
@@ -109,10 +130,16 @@ impl IntoResponse for MediaError {
)
}
Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()),
Self::UnsupportedContainer => (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()),
Self::WrongCodec
| Self::DurationTooLong
| Self::ResolutionTooHigh
| Self::MoovNotAtFront
| Self::InvalidVideo => (StatusCode::BAD_REQUEST, self.to_string()),
Self::UnknownContentType | Self::InvalidImage => {
(StatusCode::BAD_REQUEST, self.to_string())
}
Self::StorageError(_) | Self::Internal => {
Self::Io(_) | Self::StorageError(_) | Self::Internal => {
tracing::error!(error = %self, "media storage error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
}

View File

@@ -13,6 +13,7 @@ pub mod validation;
pub use config::MediaConfig;
pub use error::MediaError;
pub use storage::{BlobHeadMeta, BlobMeta, MediaStorage};
pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage};
pub use types::BlobDescriptor;
pub use upload::process_upload;
pub use upload::{process_upload, process_video_upload};
pub use validation::{validate_video_file, VideoMeta};

View File

@@ -1,11 +1,17 @@
//! S3/MinIO storage client.
use std::path::Path;
use std::pin::Pin;
use crate::config::MediaConfig;
use crate::error::MediaError;
use bytes::Bytes;
use s3::creds::Credentials;
use s3::{Bucket, Region};
use serde::{Deserialize, Serialize};
use crate::config::MediaConfig;
use crate::error::MediaError;
/// A stream of byte chunks from S3, usable with `axum::body::Body::from_stream()`.
pub type ByteStream = Pin<Box<dyn futures_core::Stream<Item = Result<Bytes, MediaError>> + Send>>;
/// S3-compatible object storage client.
pub struct MediaStorage {
@@ -33,7 +39,10 @@ impl MediaStorage {
Ok(Self { bucket })
}
/// Store an object.
/// Store an object from a byte slice.
///
/// Used for images, sidecars, and thumbnails. For large video files use
/// [`put_file`] to avoid loading the entire blob into RAM.
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)
@@ -41,6 +50,30 @@ impl MediaStorage {
Ok(())
}
/// Stream a file from disk into S3 without loading it into RAM.
///
/// Uses rust-s3's `put_object_stream_with_content_type` which reads from
/// the file incrementally via an 8 MiB `BufReader`. The full file is never
/// held in memory simultaneously. Intended for video blobs (up to 500 MB).
pub async fn put_file(
&self,
key: &str,
path: &Path,
content_type: &str,
) -> Result<(), MediaError> {
const BUF: usize = 8 * 1024 * 1024; // 8 MiB read buffer
let file = tokio::fs::File::open(path)
.await
.map_err(|e| MediaError::Io(e.to_string()))?;
let mut reader = tokio::io::BufReader::with_capacity(BUF, file);
self.bucket
.put_object_stream_with_content_type(&mut reader, key, 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 {
@@ -50,6 +83,41 @@ impl MediaStorage {
}
}
/// Retrieve a byte range from an object via S3-native `Range` GET.
///
/// `start` and `end` are inclusive byte offsets. Only the requested slice
/// is transferred from S3 — the full object is never loaded into RAM.
/// Intended for HTTP 206 range responses on large video blobs.
pub async fn get_range(&self, key: &str, start: u64, end: u64) -> Result<Vec<u8>, MediaError> {
match self.bucket.get_object_range(key, start, Some(end)).await {
Ok(response) => Ok(response.to_vec()),
Err(s3::error::S3Error::HttpFailWithBody(404, _)) => Err(MediaError::NotFound),
Err(e) => Err(MediaError::StorageError(e.to_string())),
}
}
/// Stream an object's bytes from S3 without loading into RAM.
///
/// Returns a pinned stream of `Result<Bytes, MediaError>` chunks.
/// The full object is never buffered — intended for streaming large
/// blobs (video) directly into HTTP responses via `Body::from_stream()`.
pub async fn get_stream(&self, key: &str) -> Result<ByteStream, MediaError> {
let response = self
.bucket
.get_object_stream(key)
.await
.map_err(|e| MediaError::StorageError(e.to_string()))?;
if response.status_code == 404 {
return Err(MediaError::NotFound);
}
let stream = futures_util::StreamExt::map(response.bytes, |chunk| {
chunk.map_err(|e| MediaError::StorageError(e.to_string()))
});
Ok(Box::pin(stream))
}
/// 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 {
@@ -117,4 +185,7 @@ pub struct BlobMeta {
/// Unix timestamp when the blob was first uploaded.
#[serde(default)]
pub uploaded_at: i64,
/// Video duration in seconds. `None` for non-video blobs.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_secs: Option<f64>,
}

View File

@@ -25,4 +25,7 @@ pub struct BlobDescriptor {
/// Thumbnail URL.
#[serde(skip_serializing_if = "Option::is_none")]
pub thumb: Option<String>,
/// Video duration in seconds. `None` for non-video blobs.
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<f64>,
}

View File

@@ -2,6 +2,7 @@
use bytes::Bytes;
use sha2::{Digest, Sha256};
use tokio::io::AsyncWriteExt;
use crate::auth::verify_blossom_upload_auth;
use crate::config::MediaConfig;
@@ -9,9 +10,12 @@ 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};
use crate::validation::{mime_to_ext, validate_content, validate_video_file};
/// Process an upload end-to-end: validate, store, thumbnail, return descriptor.
///
/// This is the image path — body is already fully buffered in RAM. Do NOT use
/// this for video uploads; use [`process_video_upload`] instead.
pub async fn process_upload(
storage: &MediaStorage,
config: &MediaConfig,
@@ -26,7 +30,8 @@ pub async fn process_upload(
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())?;
// Images: 10-minute auth window is plenty.
verify_blossom_upload_auth(&auth, &sha256, cfg.server_domain.as_deref(), 600)?;
Ok((mime, sha256, ext))
})
.await
@@ -83,6 +88,201 @@ pub async fn process_upload(
}
}
/// Process a video upload end-to-end using a streaming pipeline.
///
/// Unlike [`process_upload`], this function:
/// 1. Streams the request body to a [`tempfile::NamedTempFile`] while computing
/// SHA-256 incrementally — the full body is never in RAM simultaneously.
/// 2. Verifies the Blossom auth event `x` tag against the computed hash.
/// 3. Runs full MP4 validation (codec, duration, resolution, moov placement).
/// 4. Stores the blob via [`MediaStorage::put_file`] (streaming read from disk).
/// 5. Writes a sidecar with `duration_secs` (no thumbnail — desktop handles that).
///
/// Returns a [`BlobDescriptor`] with the `duration` field populated.
pub async fn process_video_upload(
storage: &MediaStorage,
config: &MediaConfig,
auth_event: &nostr::Event,
body_stream: impl futures_core::Stream<Item = Result<Bytes, axum::Error>> + Send + 'static,
content_length: Option<u64>,
) -> Result<BlobDescriptor, MediaError> {
// --- 1. Stream body to temp file, compute SHA-256 incrementally ---
let tmp = tempfile::NamedTempFile::new().map_err(|e| MediaError::Io(e.to_string()))?;
let tmp_path = tmp.path().to_path_buf();
let max_bytes = config.max_video_bytes;
// Fast-fail: reject oversized uploads before streaming starts.
if let Some(cl) = content_length {
if cl > max_bytes {
return Err(MediaError::FileTooLarge {
size: cl,
max: max_bytes,
});
}
}
let (sha256_hex, file_size, first_bytes) = {
use tokio_util::io::StreamReader;
// Convert axum::Error stream to std::io::Error stream for StreamReader.
// Box::pin is required because StreamReader needs a pinned stream.
// Belt-and-suspenders body-limit detection: axum wraps LengthLimitError
// in its error chain but doesn't expose the inner type for downcasting.
// We check multiple Display strings so that if axum changes the wording,
// at least one pattern still matches. test_body_limit_error_detection
// will catch a regression if ALL patterns break.
let mapped = futures_util::StreamExt::map(body_stream, |r| {
r.map_err(|e| {
let msg = e.to_string();
if msg.contains("length limit")
|| msg.contains("body limit")
|| msg.contains("LengthLimitError")
{
std::io::Error::new(std::io::ErrorKind::WriteZero, msg)
} else {
std::io::Error::other(e)
}
})
});
let mut reader = StreamReader::new(Box::pin(mapped));
let mut file = tokio::fs::File::create(&tmp_path)
.await
.map_err(|e| MediaError::Io(e.to_string()))?;
let mut hasher = Sha256::new();
let mut total: u64 = 0;
// Accumulate enough leading bytes for magic-byte detection.
// 4 KiB is the standard sniff buffer — infer checks signatures at
// various offsets, and some formats need more than just the first few
// bytes. This is tiny relative to any real upload.
const MIN_SNIFF_BYTES: usize = 4096;
let mut sniff_buf: Vec<u8> = Vec::with_capacity(MIN_SNIFF_BYTES);
let mut buf = vec![0u8; 64 * 1024]; // 64 KiB read buffer
loop {
use tokio::io::AsyncReadExt;
let n = match reader.read(&mut buf).await {
Ok(n) => n,
Err(e) if e.kind() == std::io::ErrorKind::WriteZero => {
// Body limit exceeded — return 413 instead of 500.
// `total` is bytes received before the cutoff — honest, not exact.
return Err(MediaError::FileTooLarge {
size: total,
max: max_bytes,
});
}
Err(e) => return Err(MediaError::Io(e.to_string())),
};
if n == 0 {
break;
}
total += n as u64;
if total > max_bytes {
return Err(MediaError::FileTooLarge {
size: total,
max: max_bytes,
});
}
hasher.update(&buf[..n]);
file.write_all(&buf[..n])
.await
.map_err(|e| MediaError::Io(e.to_string()))?;
if sniff_buf.len() < MIN_SNIFF_BYTES {
let need = MIN_SNIFF_BYTES - sniff_buf.len();
sniff_buf.extend_from_slice(&buf[..n.min(need)]);
}
}
file.flush()
.await
.map_err(|e| MediaError::Io(e.to_string()))?;
let sha256_hex = hex::encode(hasher.finalize());
(sha256_hex, total, sniff_buf)
};
// --- 2. Magic-byte check (video/mp4 only) ---
// sniff_buf has up to MIN_SNIFF_BYTES (4 KiB) of leading bytes — enough for
// infer::get() to detect MP4 ftyp even if the first network chunk was tiny.
let mime = infer::get(&first_bytes)
.map(|t| t.mime_type().to_string())
.ok_or(MediaError::UnknownContentType)?;
if mime != "video/mp4" {
return Err(MediaError::DisallowedContentType(mime));
}
// --- 3. Verify Blossom auth: x tag must match computed SHA-256 ---
let auth = auth_event.clone();
let sha256_for_auth = sha256_hex.clone();
let server_domain = config.server_domain.clone();
tokio::task::spawn_blocking(move || {
// Videos: 1-hour window — large uploads on slow connections need headroom.
verify_blossom_upload_auth(&auth, &sha256_for_auth, server_domain.as_deref(), 3600)
})
.await
.map_err(|_| MediaError::Internal)??;
// --- 4. Full MP4 validation on the temp file ---
let tmp_path_clone = tmp_path.clone();
let cfg = config.clone();
let video_meta =
tokio::task::spawn_blocking(move || validate_video_file(&tmp_path_clone, &cfg))
.await
.map_err(|_| MediaError::Internal)??;
let ext = "mp4";
let key = format!("{sha256_hex}.{ext}");
let meta_key = format!("_meta/{sha256_hex}.json");
// --- 5. Idempotency check ---
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_hex).await?;
return Ok(build_descriptor(
config,
&sha256_hex,
ext,
&mime,
file_size,
Some(&meta),
meta.uploaded_at,
));
}
let uploaded_at = chrono::Utc::now().timestamp();
// --- 6. Stream blob from temp file to S3 ---
storage.put_file(&key, &tmp_path, &mime).await?;
drop(tmp); // Free temp file disk space immediately after S3 upload.
// --- 7. Write sidecar (no thumbnail for video — desktop handles that) ---
let meta = BlobMeta {
dim: format!("{}x{}", video_meta.width, video_meta.height),
blurhash: String::new(),
thumb_url: String::new(),
ext: ext.to_string(),
mime_type: mime.clone(),
size: file_size,
uploaded_at,
duration_secs: Some(video_meta.duration_secs),
};
let meta_json = serde_json::to_vec(&meta)?;
storage
.put(&meta_key, &meta_json, "application/json")
.await?;
Ok(build_descriptor(
config,
&sha256_hex,
ext,
&mime,
file_size,
Some(&meta),
uploaded_at,
))
}
/// Generate thumbnail, blurhash, and sidecar metadata, then store them.
/// Returns the completed [`BlobMeta`] on success.
async fn generate_and_store_metadata(
@@ -129,14 +329,184 @@ fn build_descriptor(
meta: Option<&BlobMeta>,
uploaded_at: i64,
) -> BlobDescriptor {
let duration = meta.and_then(|m| m.duration_secs);
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()),
dim: meta.and_then(|m| (!m.dim.is_empty()).then(|| m.dim.clone())),
blurhash: meta.and_then(|m| (!m.blurhash.is_empty()).then(|| m.blurhash.clone())),
thumb: meta.and_then(|m| (!m.thumb_url.is_empty()).then(|| m.thumb_url.clone())),
duration,
}
}
#[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,
max_video_bytes: 524_288_000,
public_base_url: "https://media.example.com".to_string(),
server_domain: None,
}
}
#[test]
fn test_build_descriptor_video_omits_empty_thumb_and_blurhash() {
// Video uploads produce a BlobMeta with empty thumb_url and blurhash.
// build_descriptor must convert these to None so they're omitted from JSON.
let config = test_config();
let meta = BlobMeta {
dim: "320x240".to_string(),
blurhash: String::new(), // empty — video has no blurhash
thumb_url: String::new(), // empty — video has no thumbnail
ext: "mp4".to_string(),
mime_type: "video/mp4".to_string(),
size: 5_000_000,
uploaded_at: 1700000000,
duration_secs: Some(29.5),
};
let desc = build_descriptor(
&config,
"abc123",
"mp4",
"video/mp4",
5_000_000,
Some(&meta),
1700000000,
);
// Empty strings must become None, not Some("")
assert!(
desc.blurhash.is_none(),
"blurhash should be None for video, got {:?}",
desc.blurhash
);
assert!(
desc.thumb.is_none(),
"thumb should be None for video, got {:?}",
desc.thumb
);
// Non-empty fields should be present
assert_eq!(desc.dim, Some("320x240".to_string()));
assert_eq!(desc.duration, Some(29.5));
// Verify JSON serialization omits the empty fields entirely
let json = serde_json::to_value(&desc).unwrap();
assert!(
json.get("blurhash").is_none(),
"blurhash should be absent from JSON"
);
assert!(
json.get("thumb").is_none(),
"thumb should be absent from JSON"
);
assert!(json.get("dim").is_some(), "dim should be present in JSON");
assert!(
json.get("duration").is_some(),
"duration should be present in JSON"
);
}
#[test]
fn test_build_descriptor_image_includes_thumb_and_blurhash() {
// Image uploads produce a BlobMeta with populated thumb_url and blurhash.
let config = test_config();
let hash = "a".repeat(64);
let meta = BlobMeta {
dim: "800x600".to_string(),
blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string(),
thumb_url: format!("https://media.example.com/{hash}.thumb.jpg"),
ext: "jpg".to_string(),
mime_type: "image/jpeg".to_string(),
size: 100_000,
uploaded_at: 1700000000,
duration_secs: None,
};
let desc = build_descriptor(
&config,
&hash,
"jpg",
"image/jpeg",
100_000,
Some(&meta),
1700000000,
);
assert_eq!(
desc.blurhash,
Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string())
);
assert!(desc.thumb.is_some());
assert!(desc.duration.is_none());
// Verify JSON: duration should be absent, blurhash and thumb present
let json = serde_json::to_value(&desc).unwrap();
assert!(json.get("blurhash").is_some());
assert!(json.get("thumb").is_some());
assert!(
json.get("duration").is_none(),
"duration should be absent for images"
);
}
#[test]
fn test_body_limit_error_detection() {
// Verify that body-limit errors are mapped to WriteZero (which
// process_video_upload converts to FileTooLarge / 413).
// Must match the detection logic in process_video_upload exactly.
let detect = |msg: &str| -> std::io::ErrorKind {
if msg.contains("length limit")
|| msg.contains("body limit")
|| msg.contains("LengthLimitError")
{
std::io::ErrorKind::WriteZero
} else {
std::io::ErrorKind::Other
}
};
// All known patterns should trigger WriteZero.
assert_eq!(
detect("length limit exceeded"),
std::io::ErrorKind::WriteZero
);
assert_eq!(detect("body limit exceeded"), std::io::ErrorKind::WriteZero);
assert_eq!(detect("LengthLimitError"), std::io::ErrorKind::WriteZero);
// Non-limit errors should remain as Other.
assert_eq!(detect("connection reset"), std::io::ErrorKind::Other);
}
#[test]
fn test_build_descriptor_no_meta() {
// When meta is None, all optional fields should be None.
let config = test_config();
let desc = build_descriptor(
&config,
"abc123",
"jpg",
"image/jpeg",
100,
None,
1700000000,
);
assert!(desc.dim.is_none());
assert!(desc.blurhash.is_none());
assert!(desc.thumb.is_none());
assert!(desc.duration.is_none());
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,8 +7,8 @@
use std::sync::Arc;
use axum::http::header;
use axum::{
body::Bytes,
extract::{FromRequestParts, Path, State},
http::{request::Parts, HeaderMap, StatusCode},
response::{IntoResponse, Response},
@@ -47,9 +47,14 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
// 1. Extract and validate Blossom auth event
let auth_event = extract_blossom_auth(headers)?;
// Use the permissive window (3600s) here because we don't know the
// content type yet. The upload functions re-verify with the correct
// per-type window (600s for images, 3600s for video) after the body
// has been consumed and the SHA-256 computed.
sprout_media::auth::verify_blossom_auth_event(
&auth_event,
state.config.media.server_domain.as_deref(),
3600,
)?;
// 2. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload)
@@ -98,6 +103,7 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
/// - `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)
/// - `Content-Type: video/mp4` — routes to video validation path; all other types use image path
/// - Raw binary body (the file bytes)
///
/// Returns a [`BlobDescriptor`] JSON on success.
@@ -107,19 +113,48 @@ impl FromRequestParts<Arc<AppState>> for AuthenticatedUpload {
pub async fn upload_blob(
State(state): State<Arc<AppState>>,
auth: AuthenticatedUpload,
body: Bytes,
headers: HeaderMap,
body: axum::body::Body,
) -> Result<Json<BlobDescriptor>, MediaError> {
let descriptor = sprout_media::process_upload(
&state.media_storage,
&state.config.media,
&auth.auth_event,
body,
)
.await?;
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let descriptor = if content_type.starts_with("video/") {
// Video path: stream body directly to disk — never fully buffered in RAM.
let content_length = headers
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok());
sprout_media::process_video_upload(
&state.media_storage,
&state.config.media,
&auth.auth_event,
body.into_data_stream(),
content_length,
)
.await?
} else {
// Image path: collect body to bytes (bounded by transport-layer limit).
let max = state.config.media.max_image_bytes;
let bytes = axum::body::to_bytes(body, max as usize)
.await
.map_err(|_| MediaError::FileTooLarge { size: 0, max })?;
sprout_media::process_upload(
&state.media_storage,
&state.config.media,
&auth.auth_event,
bytes,
)
.await?
};
// Normalize MIME to a known set to bound label cardinality.
let mime_label = match descriptor.mime_type.as_str() {
"image/jpeg" | "image/png" | "image/gif" | "image/webp" => &descriptor.mime_type,
"image/jpeg" | "image/png" | "image/gif" | "image/webp" | "video/mp4" => {
&descriptor.mime_type
}
_ => "other",
};
metrics::counter!("sprout_media_uploads_total", "mime" => mime_label.to_owned()).increment(1);
@@ -160,7 +195,7 @@ pub async fn upload_blob(
/// 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"];
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"];
let segments: Vec<&str> = sha256_ext.split('.').collect();
@@ -196,15 +231,32 @@ fn validate_media_path(sha256_ext: &str) -> Result<(), MediaError> {
Ok(())
}
/// GET /media/{sha256_ext} — Blossom BUD-01 serve blob.
/// Maximum bytes returned in a single 206 range response (16 MiB).
///
/// Caps memory per request and prevents clients from using range requests to
/// bypass the intent of chunked delivery. Clients that need more simply issue
/// additional range requests.
const MAX_RANGE_CHUNK: u64 = 16 * 1024 * 1024;
/// GET /media/{sha256_ext} — Blossom BUD-01 serve blob, with HTTP 206 range support.
///
/// `sha256_ext` is either:
/// - `<sha256>.<ext>` — direct key (e.g. `abc123.jpg`)
/// - `<sha256>` — bare hash; extension resolved from sidecar
/// - `<sha256>.thumb.jpg` — thumbnail variant
///
/// Range request behaviour (RFC 9110 §14.2):
/// - No `Range` header → 200 with full body
/// - `Range: bytes=START-END` → 206 with slice; `Content-Range: bytes START-END/TOTAL`
/// - Unsatisfiable range (start ≥ total) → 416 with `Content-Range: bytes */TOTAL`
/// - Suffix ranges (`bytes=-N`) → 206 with last N bytes (RFC 9110 §14.1.2)
/// - Chunk capped at 16 MiB; clients request additional ranges for the rest
///
/// All responses include `Accept-Ranges: bytes` so video players know seeking is supported.
pub async fn get_blob(
State(state): State<Arc<AppState>>,
Path(sha256_ext): Path<String>,
req_headers: HeaderMap,
) -> Result<Response, MediaError> {
validate_media_path(&sha256_ext)?;
@@ -240,19 +292,132 @@ pub async fn get_blob(
};
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())
// Parse optional Range header.
let range_header = req_headers
.get(header::RANGE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_owned());
// Extract single-range value, if present. Multi-range (comma-separated) is
// unsupported — we ignore it and serve the full body per RFC 9110 §14.2:
// "A server MAY ignore the Range header field."
let single_range = range_header.filter(|r| !r.contains(','));
match single_range {
None => {
// Full response — 200 OK. Stream from S3 — never loads full blob into RAM.
let total = state
.media_storage
.head_with_metadata(&key)
.await?
.ok_or(MediaError::NotFound)?
.size;
let stream = state.media_storage.get_stream(&key).await?;
let resp = axum::response::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, &content_type)
.header(header::CONTENT_LENGTH, total.to_string())
.header(header::CONTENT_DISPOSITION, "inline")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::CONTENT_SECURITY_POLICY, "default-src 'none'")
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.header(header::ACCEPT_RANGES, "bytes")
.body(axum::body::Body::from_stream(stream))
.map_err(|_| MediaError::Internal)?;
Ok(resp)
}
Some(range_str) => {
// Single-range request — HEAD first to get total size without loading the blob.
let total = state
.media_storage
.head_with_metadata(&key)
.await?
.ok_or(MediaError::NotFound)?
.size;
// Parse range: "bytes=START-END", "bytes=START-", or "bytes=-N" (suffix).
let parsed = parse_byte_range(&range_str, total);
match parsed {
Some((start, end)) => {
if start >= total {
return axum::response::Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(axum::body::Body::empty())
.map_err(|_| MediaError::Internal);
}
// Clamp end to total-1, then cap chunk size.
let end = end.min(total.saturating_sub(1));
let end = end
.min(start.saturating_add(MAX_RANGE_CHUNK - 1))
.min(total.saturating_sub(1));
// S3-native range GET — never loads the full blob into RAM.
let chunk = state.media_storage.get_range(&key, start, end).await?;
let content_range = format!("bytes {start}-{end}/{total}");
Ok(axum::response::Response::builder()
.status(StatusCode::PARTIAL_CONTENT)
.header(header::CONTENT_TYPE, &content_type)
.header(header::CONTENT_RANGE, content_range)
.header(header::CONTENT_LENGTH, chunk.len().to_string())
.header(header::CONTENT_DISPOSITION, "inline")
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::CONTENT_SECURITY_POLICY, "default-src 'none'")
.header(header::X_CONTENT_TYPE_OPTIONS, "nosniff")
.body(axum::body::Body::from(chunk))
.map_err(|_| MediaError::Internal)?)
}
None => Ok(axum::response::Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header(header::CONTENT_RANGE, format!("bytes */{total}"))
.body(axum::body::Body::empty())
.map_err(|_| MediaError::Internal)?),
}
}
}
}
/// Parse a `Range: bytes=START-END` header value.
///
/// Returns `Some((start, end))` for a valid absolute or suffix range.
/// Supported forms:
/// - `bytes=START-END` → absolute range
/// - `bytes=START-` → from START to end of file
/// - `bytes=-N` → last N bytes (suffix range, per RFC 9110 §14.1.2)
///
/// Returns `None` for malformed values or non-bytes units — callers respond with 416.
fn parse_byte_range(range: &str, total: u64) -> Option<(u64, u64)> {
let range = range.strip_prefix("bytes=")?;
// Suffix range: "bytes=-N" → last N bytes of the file.
if let Some(suffix) = range.strip_prefix('-') {
let n: u64 = suffix.parse().ok()?;
if n == 0 || total == 0 {
return None;
}
let start = total.saturating_sub(n);
return Some((start, total - 1));
}
let (start_str, end_str) = range.split_once('-')?;
let start: u64 = start_str.parse().ok()?;
// Open-ended range: "bytes=START-" → from start to end of file.
let end: u64 = if end_str.is_empty() {
u64::MAX
} else {
end_str.parse().ok()?
};
if start > end {
return None;
}
Some((start, end))
}
/// HEAD /media/{sha256_ext} — Blossom BUD-01 existence check.
@@ -304,6 +469,7 @@ pub async fn head_blob(
[
("content-type", content_type.as_str()),
("content-length", size_str.as_str()),
("accept-ranges", "bytes"),
("cache-control", "public, max-age=31536000, immutable"),
],
)
@@ -326,7 +492,7 @@ async fn resolve_s3_key(
storage: &sprout_media::MediaStorage,
sha256_ext: &str,
) -> Result<String, MediaError> {
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"];
if sha256_ext.contains('.') {
Ok(sha256_ext.to_string())
@@ -456,7 +622,7 @@ mod tests {
#[test]
fn test_validate_media_path_hash_ext() {
for ext in &["jpg", "png", "gif", "webp"] {
for ext in &["jpg", "png", "gif", "webp", "mp4"] {
assert!(validate_media_path(&format!("{VALID_HASH}.{ext}")).is_ok());
}
}
@@ -502,4 +668,65 @@ mod tests {
fn test_validate_media_path_rejects_empty() {
assert!(validate_media_path("").is_err());
}
// ── Range request parsing ─────────────────────────────────────────────────
#[test]
fn test_parse_byte_range_basic() {
assert_eq!(parse_byte_range("bytes=0-499", 1000), Some((0, 499)));
assert_eq!(parse_byte_range("bytes=500-999", 1000), Some((500, 999)));
}
#[test]
fn test_parse_byte_range_open_ended() {
// "bytes=500-" means from 500 to end of file
assert_eq!(parse_byte_range("bytes=500-", 1000), Some((500, u64::MAX)));
}
#[test]
fn test_parse_byte_range_suffix() {
// "bytes=-500" on a 1000-byte file → last 500 bytes
assert_eq!(parse_byte_range("bytes=-500", 1000), Some((500, 999)));
}
#[test]
fn test_parse_byte_range_suffix_larger_than_file() {
// Suffix larger than file → clamp to start of file
assert_eq!(parse_byte_range("bytes=-5000", 1000), Some((0, 999)));
}
#[test]
fn test_parse_byte_range_suffix_zero() {
// "bytes=-0" is nonsensical → None
assert_eq!(parse_byte_range("bytes=-0", 1000), None);
}
#[test]
fn test_parse_byte_range_suffix_empty_file() {
// Suffix on empty file → None
assert_eq!(parse_byte_range("bytes=-500", 0), None);
}
#[test]
fn test_parse_byte_range_rejects_inverted() {
// start > end is invalid
assert_eq!(parse_byte_range("bytes=999-0", 1000), None);
}
#[test]
fn test_parse_byte_range_rejects_non_bytes_unit() {
assert_eq!(parse_byte_range("items=0-10", 1000), None);
}
#[test]
fn test_parse_byte_range_rejects_malformed() {
assert_eq!(parse_byte_range("bytes=abc-def", 1000), None);
assert_eq!(parse_byte_range("garbage", 1000), None);
assert_eq!(parse_byte_range("bytes=", 1000), None);
}
#[test]
fn test_parse_byte_range_zero_start() {
assert_eq!(parse_byte_range("bytes=0-0", 1000), Some((0, 0)));
}
}

View File

@@ -28,10 +28,19 @@ use super::{
/// 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",
"url", "m", "x", "size", "dim", "blurhash", "alt", "thumb", "fallback", "duration",
"bitrate", "image",
];
const SINGLETON_KEYS: &[&str] = &[
"url", "m", "x", "size", "dim", "blurhash", "thumb", "alt", "duration", "bitrate", "image",
];
const ALLOWED_MIME: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
];
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") {
@@ -76,7 +85,7 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result
"m" => {
if !ALLOWED_MIME.contains(&value) {
return Err(
"imeta m must be image/jpeg, image/png, image/gif, or image/webp"
"imeta m must be a supported MIME type (image/jpeg, image/png, image/gif, image/webp, video/mp4)"
.into(),
);
}
@@ -108,6 +117,46 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result
}
thumb_value = value.to_string();
}
"duration" => {
// NIP-71 standard field: seconds as float, strictly positive.
// Zero-duration videos are semantically invalid; server-side
// validate_video_file() also catches this via mvhd timescale.
if let Ok(d) = value.parse::<f64>() {
if d <= 0.0 || d.is_nan() || d.is_infinite() {
return Err("imeta duration must be a positive finite number".into());
}
} else {
return Err("imeta duration must be a valid float".into());
}
}
"bitrate" => {
// NIP-71 standard field: bits/sec as integer, positive
if value.parse::<u64>().map_or(true, |b| b == 0) {
return Err("imeta bitrate must be a positive integer".into());
}
}
"image" => {
// NIP-71 poster frame — must be a local media URL with an image extension.
// Poster frames are independent blobs, NOT thumbnails.
// Video URLs (e.g. .mp4) and thumbnail URLs (.thumb.jpg) are rejected.
const IMAGE_EXTS: &[&str] = &["jpg", "png", "gif", "webp"];
if !is_local_media_url(value, media_base_url) {
return Err("imeta image must be a local /media/ path".into());
}
if value.contains(".thumb.") {
return Err(
"imeta image must reference a standalone poster frame, not a thumbnail"
.into(),
);
}
let ext = value.rsplit('.').next().unwrap_or("");
if !IMAGE_EXTS.contains(&ext) {
return Err(
"imeta image must reference an image file (jpg, png, gif, webp), not video"
.into(),
);
}
}
_ => {}
}
}
@@ -116,6 +165,18 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result
return Err("imeta tag must include url, m, x, and size".into());
}
// Video-only NIP-71 fields must not appear on image blobs.
let is_video = m_value == "video/mp4";
if !is_video {
for key in &["duration", "bitrate", "image"] {
if seen_keys.contains(*key) {
return Err(format!(
"imeta {key} is only valid for video/mp4, not {m_value}"
));
}
}
}
// 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 {
@@ -128,7 +189,9 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result
return Err("imeta url extension does not match m".into());
}
}
// Thumb hash must match x (same blob, different variant).
// Thumb URL hash segment must match x — thumbnails are keyed by their
// parent blob's hash (e.g. {video_hash}.thumb.jpg), not by their own
// content hash. This checks URL consistency, not content identity.
if !thumb_value.is_empty() {
if let Some(thumb_hash) = extract_hash_from_media_url(&thumb_value) {
if thumb_hash != x_value {
@@ -136,6 +199,9 @@ pub fn validate_imeta_tags(tags: &[Vec<String>], media_base_url: &str) -> Result
}
}
}
// NIP-71 poster frame (`image`) is an independent blob with its own
// content hash — it cannot match the video's `x` hash. Validated as a
// local media URL with an image extension only (no hash cross-check).
}
Ok(())
}
@@ -155,6 +221,8 @@ pub async fn verify_imeta_blobs(
let mut m_value = String::new();
let mut size_value: u64 = 0;
let mut thumb_value = String::new();
let mut image_value = String::new();
let mut duration_value: f64 = 0.0;
for part in tag.iter().skip(1) {
let mut parts = part.splitn(2, ' ');
@@ -165,6 +233,8 @@ pub async fn verify_imeta_blobs(
"m" => m_value = value.to_string(),
"size" => size_value = value.parse().unwrap_or(0),
"thumb" => thumb_value = value.to_string(),
"image" => image_value = value.to_string(),
"duration" => duration_value = value.parse().unwrap_or(0.0),
_ => {}
}
}
@@ -202,6 +272,15 @@ pub async fn verify_imeta_blobs(
sidecar.size
));
}
// Duration cross-check: if sidecar has duration and client claims one,
// they must agree within 0.1s tolerance (float rounding from mvhd).
if let Some(stored_dur) = sidecar.duration_secs {
if duration_value > 0.0 && (duration_value - stored_dur).abs() > 0.1 {
return Err(format!(
"imeta duration ({duration_value}) does not match stored duration ({stored_dur})"
));
}
}
// 4. If thumb is claimed, HEAD the thumbnail object too.
if !thumb_value.is_empty() {
@@ -216,6 +295,51 @@ pub async fn verify_imeta_blobs(
));
}
}
// 5. If image (poster frame) is claimed, verify sidecar + blob.
// Poster frames are independent blobs — extract hash from the image
// URL itself, not from x_value. Sidecar must exist (serving is gated
// on it) and MIME must be an image type.
if !image_value.is_empty() {
let img_hash = extract_hash_from_media_url(&image_value)
.ok_or_else(|| format!("imeta image URL has no extractable hash: {image_value}"))?;
let img_sidecar = storage
.get_sidecar(img_hash)
.await
.map_err(|_| format!("imeta image references nonexistent poster: {img_hash}"))?;
// Poster frame must be an image, not video or other type.
const IMAGE_MIMES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
if !IMAGE_MIMES.contains(&img_sidecar.mime_type.as_str()) {
return Err(format!(
"imeta image poster MIME must be image type, got {}",
img_sidecar.mime_type
));
}
// URL extension must match sidecar's canonical extension.
// Mismatch means the URL would 404 on serve (GET resolves via sidecar).
if let Some(url_ext) = extract_ext_from_media_url(&image_value) {
if url_ext != img_sidecar.ext {
return Err(format!(
"imeta image extension ({url_ext}) does not match stored extension ({})",
img_sidecar.ext
));
}
}
let img_key = format!("{img_hash}.{}", img_sidecar.ext);
let img_exists = storage
.head(&img_key)
.await
.map_err(|e| format!("storage error checking poster image: {e}"))?;
if !img_exists {
return Err(format!(
"imeta image references missing poster frame: {img_hash}"
));
}
}
}
Ok(())
}
@@ -249,6 +373,7 @@ fn mime_to_canonical_ext(mime: &str) -> &str {
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
"video/mp4" => "mp4",
_ => "bin",
}
}
@@ -263,7 +388,7 @@ fn mime_to_canonical_ext(mime: &str) -> &str {
/// 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"];
const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"];
// Extract the path portion after /media/
let path_after_media = if let Some(rest) = url.strip_prefix("/media/") {
@@ -939,4 +1064,182 @@ mod tests {
fn kinds_negative_is_error() {
assert!(parse_kinds(Some("-1")).is_err());
}
// ── video / NIP-71 imeta tests ──────────────────────────────────────────
#[test]
fn test_imeta_video_mp4_accepted() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
];
assert!(validate_imeta_tags(&[tag], BASE).is_ok());
}
#[test]
fn test_imeta_duration_valid() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
"duration 29.5".into(),
];
assert!(validate_imeta_tags(&[tag], BASE).is_ok());
}
#[test]
fn test_imeta_duration_negative_rejected() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
"duration -5".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("positive finite number"), "{err}");
}
#[test]
fn test_imeta_duration_zero_rejected() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
"duration 0".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("positive finite number"), "{err}");
}
#[test]
fn test_imeta_duration_non_float_rejected() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
"duration abc".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("valid float"), "{err}");
}
#[test]
fn test_imeta_bitrate_valid() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
"bitrate 1500000".into(),
];
assert!(validate_imeta_tags(&[tag], BASE).is_ok());
}
#[test]
fn test_imeta_bitrate_zero_rejected() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
"bitrate 0".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("positive integer"), "{err}");
}
#[test]
fn test_imeta_image_poster_frame_accepted() {
// Poster frame is an independent blob with its own hash — different from
// the video's x hash. This must be accepted (no hash cross-check).
let poster_hash = "b".repeat(64);
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
format!("image /media/{poster_hash}.jpg"),
];
assert!(validate_imeta_tags(&[tag], BASE).is_ok());
}
#[test]
fn test_imeta_image_video_url_rejected() {
// NIP-71 image field is a still poster frame — .mp4 must be rejected
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
format!("image /media/{HASH}.mp4"),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(
err.contains("image file") && err.contains("not video"),
"{err}"
);
}
#[test]
fn test_imeta_image_thumbnail_url_rejected() {
// Poster frame must be a standalone blob, not a thumbnail variant.
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/mp4".into(),
format!("x {HASH}"),
"size 5000000".into(),
format!("image /media/{HASH}.thumb.jpg"),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("standalone poster frame"), "{err}");
}
#[test]
fn test_imeta_duration_on_image_rejected() {
// Video-only NIP-71 fields must not appear on image blobs.
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.jpg"),
"m image/jpeg".into(),
format!("x {HASH}"),
"size 100000".into(),
"duration 5.0".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("only valid for video/mp4"), "{err}");
}
#[test]
fn test_imeta_video_quicktime_rejected() {
let tag = vec![
"imeta".into(),
format!("url /media/{HASH}.mp4"),
"m video/quicktime".into(),
format!("x {HASH}"),
"size 5000000".into(),
];
let err = validate_imeta_tags(&[tag], BASE).unwrap_err();
assert!(err.contains("supported MIME type"), "{err}");
}
#[test]
fn test_local_media_url_mp4_accepted() {
assert!(is_local_media_url(&format!("/media/{HASH}.mp4"), BASE));
}
}

View File

@@ -177,6 +177,10 @@ impl Config {
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10 * 1024 * 1024),
max_video_bytes: std::env::var("SPROUT_MAX_VIDEO_BYTES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(500 * 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")

View File

@@ -30,11 +30,16 @@ use crate::state::AppState;
/// own `RequestBodyLimitLayer` before merging; the outer layer adds tracing and
/// CORS once over the combined router.
pub fn build_router(state: Arc<AppState>) -> Router {
// ── 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;
// ── Media routes: body limit covers both images and video ────────────────
// Transport cap is the larger of image and video limits. Video uploads stream
// to disk (never fully buffered); images collect to bytes within this limit.
// Per-MIME app-level limits (GIF: 10 MB) are enforced in sprout-media
// validation after MIME detection.
let media_body_limit = state
.config
.media
.max_image_bytes
.max(state.config.media.max_video_bytes) as usize;
let media_router = Router::new()
.route("/media/upload", put(api::media::upload_blob))
.route(

View File

@@ -0,0 +1,658 @@
//! End-to-end video upload tests (Blossom protocol, MP4/H.264).
//!
//! 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_video -- --ignored --nocapture
//! ```
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use reqwest::{Client, StatusCode};
use sha2::{Digest, Sha256};
use std::time::Duration;
// ── 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(30))
.build()
.expect("failed to build HTTP client")
}
// ── Blossom auth helpers ──────────────────────────────────────────────────────
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")
}
fn blossom_auth_header(event: &nostr::Event) -> String {
format!(
"Nostr {}",
URL_SAFE_NO_PAD.encode(event.as_json().as_bytes())
)
}
// ── Minimal MP4 builder ───────────────────────────────────────────────────────
/// Build a minimal but structurally valid fast-start MP4 (H.264, 1s, 320×240).
///
/// Layout: ftyp | moov(mvhd + trak(tkhd + mdia(mdhd + hdlr + minf(vmhd + dinf + stbl)))) | mdat
/// This is enough for `infer` to detect video/mp4 and for the `mp4` crate to parse.
fn build_test_mp4() -> Vec<u8> {
fn box_wrap(fourcc: &[u8; 4], payload: &[u8]) -> Vec<u8> {
let size = (8 + payload.len()) as u32;
let mut b = Vec::new();
b.extend_from_slice(&size.to_be_bytes());
b.extend_from_slice(fourcc);
b.extend_from_slice(payload);
b
}
// ftyp
let ftyp = {
let mut b = Vec::new();
b.extend_from_slice(&20u32.to_be_bytes());
b.extend_from_slice(b"ftyp");
b.extend_from_slice(b"isom");
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(b"isom");
b
};
// mvhd (version 0, timescale=1000, duration=1000ms)
let mvhd_payload = {
let mut b = vec![0u8; 4]; // version=0, flags=0
b.extend_from_slice(&0u32.to_be_bytes()); // creation_time
b.extend_from_slice(&0u32.to_be_bytes()); // modification_time
b.extend_from_slice(&1000u32.to_be_bytes()); // timescale
b.extend_from_slice(&1000u32.to_be_bytes()); // duration
b.extend_from_slice(&0x00010000u32.to_be_bytes()); // rate
b.extend_from_slice(&0x0100u16.to_be_bytes()); // volume
b.extend_from_slice(&[0u8; 10]); // reserved
// identity matrix (9 × u32)
for &v in &[0x00010000u32, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] {
b.extend_from_slice(&v.to_be_bytes());
}
b.extend_from_slice(&[0u8; 24]); // pre_defined
b.extend_from_slice(&2u32.to_be_bytes()); // next_track_id
b
};
let mvhd = box_wrap(b"mvhd", &mvhd_payload);
// tkhd
let tkhd_payload = {
let mut b = vec![0u8, 0, 0, 3]; // version=0, flags=3
b.extend_from_slice(&0u32.to_be_bytes()); // creation
b.extend_from_slice(&0u32.to_be_bytes()); // modification
b.extend_from_slice(&1u32.to_be_bytes()); // track_id
b.extend_from_slice(&0u32.to_be_bytes()); // reserved
b.extend_from_slice(&1000u32.to_be_bytes()); // duration
b.extend_from_slice(&[0u8; 8]); // reserved
b.extend_from_slice(&0i16.to_be_bytes()); // layer
b.extend_from_slice(&0i16.to_be_bytes()); // alternate_group
b.extend_from_slice(&0u16.to_be_bytes()); // volume
b.extend_from_slice(&0u16.to_be_bytes()); // reserved
for &v in &[0x00010000u32, 0, 0, 0, 0x00010000, 0, 0, 0, 0x40000000] {
b.extend_from_slice(&v.to_be_bytes());
}
b.extend_from_slice(&(320u32 << 16).to_be_bytes()); // width 16.16
b.extend_from_slice(&(240u32 << 16).to_be_bytes()); // height 16.16
b
};
let tkhd = box_wrap(b"tkhd", &tkhd_payload);
// mdhd
let mdhd_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&1000u32.to_be_bytes()); // timescale
b.extend_from_slice(&1000u32.to_be_bytes()); // duration
b.extend_from_slice(&0u16.to_be_bytes()); // language
b.extend_from_slice(&0u16.to_be_bytes()); // pre_defined
b
};
let mdhd = box_wrap(b"mdhd", &mdhd_payload);
// hdlr (video)
let hdlr_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u32.to_be_bytes()); // pre_defined
b.extend_from_slice(b"vide");
b.extend_from_slice(&[0u8; 12]); // reserved
b.extend_from_slice(b"VideoHandler\0");
b
};
let hdlr = box_wrap(b"hdlr", &hdlr_payload);
// vmhd
let vmhd_payload = {
let mut b = vec![0u8, 0, 0, 1]; // flags=1
b.extend_from_slice(&0u16.to_be_bytes());
b.extend_from_slice(&[0u8; 6]);
b
};
let vmhd = box_wrap(b"vmhd", &vmhd_payload);
// dinf -> dref -> url
let url_box = box_wrap(b"url ", &[0, 0, 0, 1]);
let dref_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&url_box);
b
};
let dref = box_wrap(b"dref", &dref_payload);
let dinf = box_wrap(b"dinf", &dref);
// stsd -> avc1 (H.264)
let avc1_entry = {
let mut b = vec![0u8; 6]; // reserved
b.extend_from_slice(&1u16.to_be_bytes()); // data_ref_idx
b.extend_from_slice(&[0u8; 2]); // pre_defined
b.extend_from_slice(&[0u8; 2]); // reserved
b.extend_from_slice(&[0u8; 12]); // pre_defined
b.extend_from_slice(&320u16.to_be_bytes()); // width
b.extend_from_slice(&240u16.to_be_bytes()); // height
b.extend_from_slice(&0x00480000u32.to_be_bytes()); // horiz_res
b.extend_from_slice(&0x00480000u32.to_be_bytes()); // vert_res
b.extend_from_slice(&0u32.to_be_bytes()); // reserved
b.extend_from_slice(&1u16.to_be_bytes()); // frame_count
b.extend_from_slice(&[0u8; 32]); // compressorname
b.extend_from_slice(&0x0018u16.to_be_bytes()); // depth
b.extend_from_slice(&(-1i16).to_be_bytes()); // pre_defined
// avcC
let avcc = vec![
0x01, 0x42, 0x00, 0x1E, 0xFF, 0xE1, 0x00, 0x00, 0x01, 0x00, 0x00,
];
b.extend_from_slice(&box_wrap(b"avcC", &avcc));
b
};
let avc1 = box_wrap(b"avc1", &avc1_entry);
let stsd_payload = {
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&avc1);
b
};
let stsd = box_wrap(b"stsd", &stsd_payload);
// Minimal sample tables
let stts = box_wrap(b"stts", &{
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1000u32.to_be_bytes());
b
});
let stsc = box_wrap(b"stsc", &{
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b
});
let stsz = box_wrap(b"stsz", &{
let mut b = vec![0u8; 4];
b.extend_from_slice(&0u32.to_be_bytes());
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes());
b
});
let stco = box_wrap(b"stco", &{
let mut b = vec![0u8; 4];
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&28u32.to_be_bytes());
b
});
let stbl_payload = [&stsd[..], &stts, &stsc, &stsz, &stco].concat();
let stbl = box_wrap(b"stbl", &stbl_payload);
let minf_payload = [&vmhd[..], &dinf, &stbl].concat();
let minf = box_wrap(b"minf", &minf_payload);
let mdia_payload = [&mdhd[..], &hdlr, &minf].concat();
let mdia = box_wrap(b"mdia", &mdia_payload);
let trak_payload = [&tkhd[..], &mdia].concat();
let trak = box_wrap(b"trak", &trak_payload);
let moov_payload = [&mvhd[..], &trak].concat();
let moov = box_wrap(b"moov", &moov_payload);
let mdat = box_wrap(b"mdat", &[]);
[ftyp, moov, mdat].concat()
}
// ── Tests ─────────────────────────────────────────────────────────────────────
/// Upload a valid MP4 video via Blossom, verify the BlobDescriptor includes
/// video-specific fields (duration, dim) and the blob is retrievable.
#[tokio::test]
#[ignore]
async fn test_video_upload_and_get() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.expect("upload request");
assert_eq!(resp.status(), StatusCode::OK, "upload should succeed");
let desc: serde_json::Value = resp.json().await.expect("json body");
assert_eq!(desc["sha256"].as_str().unwrap(), sha256);
assert_eq!(desc["type"].as_str().unwrap(), "video/mp4");
assert!(desc["size"].as_u64().unwrap() > 0);
// Video descriptor should have duration
assert!(
desc.get("duration").is_some(),
"video descriptor should include duration"
);
// GET the blob back
let get_url = desc["url"].as_str().unwrap();
let get_resp = client.get(get_url).send().await.expect("GET blob");
assert_eq!(get_resp.status(), StatusCode::OK);
let body = get_resp.bytes().await.expect("body bytes");
assert_eq!(body.len(), mp4.len());
}
/// Upload an MP4 as Content-Type: image/jpeg — should be rejected.
/// This tests the Content-Type spoofing fix: validate_content() rejects
/// video/mp4 from the image path.
#[tokio::test]
#[ignore]
async fn test_video_content_type_spoofing_rejected() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
// Upload MP4 bytes but claim it's image/jpeg
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.header("Content-Type", "image/jpeg")
.body(mp4)
.send()
.await
.expect("upload request");
// Should be rejected — either 415 (DisallowedContentType) or 400
assert!(
resp.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE
|| resp.status() == StatusCode::BAD_REQUEST,
"MP4 uploaded as image/jpeg should be rejected, got {}",
resp.status()
);
}
/// Range request on a video blob should return 206 Partial Content.
#[tokio::test]
#[ignore]
async fn test_video_range_request_206() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
// Upload first
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.expect("upload");
assert_eq!(resp.status(), StatusCode::OK);
let desc: serde_json::Value = resp.json().await.unwrap();
let blob_url = desc["url"].as_str().unwrap();
// Range request: first 100 bytes
let range_resp = client
.get(blob_url)
.header("Range", "bytes=0-99")
.send()
.await
.expect("range GET");
assert_eq!(range_resp.status(), StatusCode::PARTIAL_CONTENT);
assert!(range_resp.headers().get("content-range").is_some());
assert!(range_resp
.headers()
.get("accept-ranges")
.is_some_and(|v| v == "bytes"));
let body = range_resp.bytes().await.unwrap();
assert_eq!(body.len(), 100);
assert_eq!(&body[..], &mp4[..100]);
}
/// Unsatisfiable range request should return 416.
#[tokio::test]
#[ignore]
async fn test_video_range_request_416() {
let client = http_client();
let keys = Keys::generate();
let mp4 = build_test_mp4();
let sha256 = hex::encode(Sha256::digest(&mp4));
// Upload first
let auth = sign_blossom_auth(&keys, &sha256);
let url = format!("{}/media/upload", relay_http_url());
let resp = client
.put(&url)
.header("Authorization", blossom_auth_header(&auth))
.header("X-SHA-256", &sha256)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.expect("upload");
assert_eq!(resp.status(), StatusCode::OK);
let desc: serde_json::Value = resp.json().await.unwrap();
let blob_url = desc["url"].as_str().unwrap();
// Request a range beyond the file size
let range_resp = client
.get(blob_url)
.header(
"Range",
format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000),
)
.send()
.await
.expect("range GET");
assert_eq!(
range_resp.status(),
StatusCode::RANGE_NOT_SATISFIABLE,
"out-of-range request should return 416"
);
}
/// Upload without auth should return 401.
#[tokio::test]
#[ignore]
async fn test_video_upload_no_auth_returns_401() {
let client = http_client();
let mp4 = build_test_mp4();
let url = format!("{}/media/upload", relay_http_url());
let resp = client
.put(&url)
.header("Content-Type", "video/mp4")
.body(mp4)
.send()
.await
.expect("upload request");
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"upload without auth should return 401"
);
}
// ── Poster frame + imeta integration tests ───────────────────────────────────
fn relay_ws_url() -> String {
relay_http_url()
.replace("http://", "ws://")
.replace("https://", "wss://")
}
/// Minimal valid JPEG (1x1 pixel) — used as a poster frame blob.
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,
]
}
/// Upload a video + poster, then send a message with imeta `image` field
/// referencing the poster. The relay must accept the event.
#[tokio::test]
#[ignore]
async fn test_video_poster_imeta_accepted_via_ws() {
use sprout_test_client::SproutTestClient;
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// 1. Create a channel
let channel_uuid = uuid::Uuid::new_v4();
let channel_id = channel_uuid.to_string();
let create_event = EventBuilder::new(
Kind::from(9007),
"",
vec![
Tag::parse(&["h", &channel_id]).unwrap(),
Tag::parse(&["name", &format!("video-poster-test-{channel_id}")]).unwrap(),
Tag::parse(&["channel_type", "stream"]).unwrap(),
Tag::parse(&["visibility", "open"]).unwrap(),
],
)
.sign_with_keys(&keys)
.unwrap();
let resp = client
.post(format!("{}/api/events", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&create_event).unwrap())
.send()
.await
.unwrap();
assert!(resp.status().is_success(), "channel creation failed");
// 2. Upload video
let mp4 = build_test_mp4();
let video_sha = hex::encode(Sha256::digest(&mp4));
let video_auth = sign_blossom_auth(&keys, &video_sha);
let video_resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&video_auth))
.header("X-SHA-256", &video_sha)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.unwrap();
assert_eq!(video_resp.status(), StatusCode::OK, "video upload failed");
let video_desc: serde_json::Value = video_resp.json().await.unwrap();
let video_size = video_desc["size"].as_u64().unwrap();
// 3. Upload poster (tiny JPEG)
let poster = tiny_jpeg();
let poster_sha = hex::encode(Sha256::digest(&poster));
let poster_auth = sign_blossom_auth(&keys, &poster_sha);
let poster_resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&poster_auth))
.header("X-SHA-256", &poster_sha)
.body(poster.to_vec())
.send()
.await
.unwrap();
assert_eq!(poster_resp.status(), StatusCode::OK, "poster upload failed");
// 4. Send message with imeta referencing both video and poster
let mut ws = SproutTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
let base = relay_http_url();
let event = EventBuilder::new(
Kind::from(9),
format!("![video]({base}/media/{video_sha}.mp4)"),
vec![
Tag::parse(&["h", &channel_id]).unwrap(),
Tag::parse(&[
"imeta",
&format!("url {base}/media/{video_sha}.mp4"),
"m video/mp4",
&format!("x {video_sha}"),
&format!("size {video_size}"),
&format!("image {base}/media/{poster_sha}.jpg"),
])
.unwrap(),
],
)
.sign_with_keys(&keys)
.unwrap();
let ok = ws.send_event(event).await.unwrap();
assert!(
ok.accepted,
"video+poster imeta must be accepted: {:?}",
ok.message
);
ws.disconnect().await.unwrap();
}
/// Send a message with imeta `image` pointing to the video URL (not an image).
/// The relay must reject this — poster must be an image file, not video.
#[tokio::test]
#[ignore]
async fn test_video_poster_imeta_rejects_video_as_poster() {
use sprout_test_client::SproutTestClient;
let client = http_client();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// 1. Create channel
let channel_uuid = uuid::Uuid::new_v4();
let channel_id = channel_uuid.to_string();
let create_event = EventBuilder::new(
Kind::from(9007),
"",
vec![
Tag::parse(&["h", &channel_id]).unwrap(),
Tag::parse(&["name", &format!("poster-reject-test-{channel_id}")]).unwrap(),
Tag::parse(&["channel_type", "stream"]).unwrap(),
Tag::parse(&["visibility", "open"]).unwrap(),
],
)
.sign_with_keys(&keys)
.unwrap();
let resp = client
.post(format!("{}/api/events", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&create_event).unwrap())
.send()
.await
.unwrap();
assert!(resp.status().is_success());
// 2. Upload video
let mp4 = build_test_mp4();
let video_sha = hex::encode(Sha256::digest(&mp4));
let video_auth = sign_blossom_auth(&keys, &video_sha);
let video_resp = client
.put(format!("{}/media/upload", relay_http_url()))
.header("Authorization", blossom_auth_header(&video_auth))
.header("X-SHA-256", &video_sha)
.header("Content-Type", "video/mp4")
.body(mp4.clone())
.send()
.await
.unwrap();
assert_eq!(video_resp.status(), StatusCode::OK);
let video_desc: serde_json::Value = video_resp.json().await.unwrap();
let video_size = video_desc["size"].as_u64().unwrap();
// 3. Send message with imeta `image` pointing to the VIDEO (not an image)
let mut ws = SproutTestClient::connect(&relay_ws_url(), &keys)
.await
.unwrap();
let base = relay_http_url();
let event = EventBuilder::new(
Kind::from(9),
"bad poster",
vec![
Tag::parse(&["h", &channel_id]).unwrap(),
Tag::parse(&[
"imeta",
&format!("url {base}/media/{video_sha}.mp4"),
"m video/mp4",
&format!("x {video_sha}"),
&format!("size {video_size}"),
// BAD: image field points to the video itself (.mp4 extension)
&format!("image {base}/media/{video_sha}.mp4"),
])
.unwrap(),
],
)
.sign_with_keys(&keys)
.unwrap();
let ok = ws.send_event(event).await.unwrap();
assert!(
!ok.accepted,
"video URL as poster must be rejected, but was accepted"
);
ws.disconnect().await.unwrap();
}

View File

@@ -42,6 +42,8 @@ const overrides = new Map([
["src/features/tokens/ui/TokenSettingsCard.tsx", 800],
["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
["src-tauri/src/lib.rs", 550], // sprout-media:// proxy now forwards Range headers + propagates Content-Range/Accept-Ranges for video seeking
["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
["src-tauri/src/commands/agents.rs", 849], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks
["src-tauri/src/managed_agents/runtime.rs", 650], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests

View File

@@ -18,6 +18,11 @@ pub struct BlobDescriptor {
pub dim: Option<String>,
pub blurhash: Option<String>,
pub thumb: Option<String>,
/// Video duration in seconds. `None` for non-video blobs.
pub duration: Option<f64>,
/// NIP-71 poster frame URL. `None` for non-video blobs or if extraction failed.
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
}
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -95,8 +100,14 @@ 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"];
/// MIME allowlist — must match the server's allowed types.
const ALLOWED_MIME: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
];
fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
let mime = infer::get(body)
@@ -108,12 +119,17 @@ fn detect_and_validate_mime(body: &[u8]) -> Result<String, String> {
Ok(mime)
}
fn sign_blossom_upload_auth(keys: &Keys, sha256: &str) -> Result<nostr::Event, String> {
fn sign_blossom_upload_auth(
keys: &Keys,
sha256: &str,
expiry_secs: u64,
) -> 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())?,
Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()])
.map_err(|e| e.to_string())?,
];
let base_url = relay_api_base_url();
if let Some(domain) = extract_server_authority(&base_url) {
@@ -126,6 +142,10 @@ fn sign_blossom_upload_auth(keys: &Keys, sha256: &str) -> Result<nostr::Event, S
}
/// Execute the upload HTTP request. Shared by all upload entry points.
// TODO(v2): Stream large video files to the relay instead of buffering in RAM.
// Current approach works for videos up to ~100MB but will OOM on 500MB files.
// Fix: use reqwest's Body::wrap_stream() to stream from the temp file directly.
// The server already supports streaming upload via process_video_upload.
async fn do_upload(
body: Vec<u8>,
mime: &str,
@@ -133,9 +153,17 @@ async fn do_upload(
) -> Result<BlobDescriptor, String> {
let sha256 = hex::encode(Sha256::digest(&body));
// Video uploads get a 1-hour auth window to survive slow connections;
// images use 5 minutes. Must match the server-side max_age_secs values
// in process_upload (600s) and process_video_upload (3600s).
let expiry_secs = if mime.starts_with("video/") {
3600
} else {
300
};
let auth_event = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
sign_blossom_upload_auth(&keys, &sha256)?
sign_blossom_upload_auth(&keys, &sha256, expiry_secs)?
};
let auth_header = format!(
@@ -213,6 +241,243 @@ pub async fn upload_media(
do_upload(body, &mime, &state).await
}
// ── Video transcode helpers ──────────────────────────────────────────────────
/// Check if ffmpeg is available on PATH.
fn find_ffmpeg() -> Result<(), String> {
match std::process::Command::new("ffmpeg")
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
{
Ok(s) if s.success() => Ok(()),
Ok(_) => Err(
"ffmpeg was found but returned an error — it may be broken or misconfigured"
.to_string(),
),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(
"ffmpeg is required for video uploads but was not found.\n\n\
Install it:\n \
macOS: brew install ffmpeg\n \
Linux: sudo apt install ffmpeg\n \
Windows: winget install ffmpeg"
.to_string(),
),
Err(e) => Err(format!("failed to check for ffmpeg: {e}")),
}
}
/// Detect if a file is a video based on magic bytes.
fn is_video_file(buf: &[u8]) -> bool {
infer::get(buf).map_or(false, |t| t.mime_type().starts_with("video/"))
}
/// Maximum wall-clock time for an ffmpeg transcode before we kill it.
/// 10 minutes is generous for any reasonable video; pathological inputs
/// (crafted to cause exponential decode time) get killed instead of
/// blocking a Tokio worker thread indefinitely.
const FFMPEG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
/// Run an ffmpeg command with a wall-clock timeout.
///
/// Spawns the child process, polls `try_wait()` every 500ms, and kills it
/// if the deadline is exceeded. Returns the same `Output` as `Command::output()`.
///
/// **IMPORTANT**: callers MUST pass `-loglevel error` (or `quiet`) to ffmpeg.
/// This function reads stderr only after the child exits. If ffmpeg writes
/// enough progress/diagnostic output to fill the OS pipe buffer (~64 KiB),
/// the child blocks on write() and never exits — causing a false timeout.
/// `-loglevel error` suppresses progress spam, keeping stderr small.
fn run_ffmpeg_with_timeout(
cmd: &mut std::process::Command,
timeout: std::time::Duration,
) -> Result<std::process::Output, String> {
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn ffmpeg: {e}"))?;
let deadline = std::time::Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => {
// Process exited — collect output.
let stdout = child.stdout.take().map_or_else(Vec::new, |mut s| {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
buf
});
let stderr = child.stderr.take().map_or_else(Vec::new, |mut s| {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut s, &mut buf);
buf
});
return Ok(std::process::Output {
status,
stdout,
stderr,
});
}
Ok(None) => {
// Still running — check deadline.
if std::time::Instant::now() > deadline {
let _ = child.kill();
let _ = child.wait(); // reap zombie
return Err(format!("ffmpeg timed out after {}s", timeout.as_secs()));
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
Err(e) => return Err(format!("failed to wait on ffmpeg: {e}")),
}
}
}
/// Transcode any video file to H.264/AAC/MP4/fast-start via ffmpeg.
///
/// Always re-encodes — handles HEVC, VP9, ProRes, non-faststart MP4, 10-bit,
/// wrong pixel format, MOV containers, etc. Output is guaranteed to pass the
/// relay's `validate_video_file()`.
///
/// Returns the path to a temp file. Caller must clean up.
fn transcode_to_mp4(source: &std::path::Path) -> Result<std::path::PathBuf, String> {
find_ffmpeg()?;
// UUID-based temp path — unique across concurrent uploads.
let output =
std::env::temp_dir().join(format!("sprout-transcode-{}.mp4", uuid::Uuid::new_v4()));
let result = run_ffmpeg_with_timeout(
std::process::Command::new("ffmpeg")
.args(["-y", "-loglevel", "error"]) // suppress progress spam — prevents stderr pipe deadlock
.arg("-i")
.arg(source) // OsStr — handles non-UTF-8 paths on Unix
.args([
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
FFMPEG_TIMEOUT,
)?;
if !result.status.success() {
let _ = std::fs::remove_file(&output);
let stderr = String::from_utf8_lossy(&result.stderr);
let detail = stderr
.lines()
.rev()
.find(|l| !l.is_empty() && !l.starts_with(" "))
.unwrap_or("unknown error");
return Err(format!("Video conversion failed: {detail}"));
}
Ok(output)
}
/// Extract a single JPEG poster frame from a transcoded MP4 via ffmpeg.
///
/// Seeks to 1 second (avoids black leader frames), falls back to first frame
/// for videos shorter than 1 second. Output is scaled to 640px wide with even
/// dimensions. Returns the path to a temp JPEG. Caller must clean up.
///
/// Best-effort: returns `Err` on failure — callers should log and continue
/// without a poster rather than failing the entire video upload.
fn extract_poster_frame(mp4_path: &std::path::Path) -> Result<std::path::PathBuf, String> {
let output = std::env::temp_dir().join(format!("sprout-poster-{}.jpg", uuid::Uuid::new_v4()));
// Poster extraction is a single-frame decode — 30s is generous.
let poster_timeout = std::time::Duration::from_secs(30);
// Try seeking to 1s first (avoids black first frames from fade-ins).
let result = run_ffmpeg_with_timeout(
std::process::Command::new("ffmpeg")
.args(["-y", "-loglevel", "error"])
.arg("-ss")
.arg("1")
.arg("-i")
.arg(mp4_path)
.args(["-vframes", "1", "-vf", "scale=640:-2", "-q:v", "2"])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
poster_timeout,
)?;
// If seek to 1s failed (video shorter than 1s), retry from first frame.
if !result.status.success()
|| !output.exists()
|| std::fs::metadata(&output).map_or(true, |m| m.len() == 0)
{
if !result.status.success() {
let stderr = String::from_utf8_lossy(&result.stderr);
eprintln!("sprout-desktop: poster seek-to-1s failed, trying first frame: {stderr}");
}
let _ = std::fs::remove_file(&output);
let fallback = run_ffmpeg_with_timeout(
std::process::Command::new("ffmpeg")
.args(["-y", "-loglevel", "error"])
.arg("-i")
.arg(mp4_path)
.args(["-vframes", "1", "-vf", "scale=640:-2", "-q:v", "2"])
.arg(&output)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped()),
poster_timeout,
)?;
if !fallback.status.success() || !output.exists() {
let stderr = String::from_utf8_lossy(&fallback.stderr);
eprintln!("sprout-desktop: poster frame extraction failed: {stderr}");
let _ = std::fs::remove_file(&output);
return Err("ffmpeg could not extract a poster frame".to_string());
}
}
Ok(output)
}
/// Transcode video and extract poster frame. Returns (video_bytes, Option<poster_bytes>).
///
/// Poster extraction is best-effort — if it fails, returns `None` for the poster
/// and the video bytes are still valid. All temp files are cleaned up.
fn transcode_and_extract_poster(
source: &std::path::Path,
) -> Result<(Vec<u8>, Option<Vec<u8>>), String> {
let transcoded = transcode_to_mp4(source)?;
// Extract poster from the transcoded file (not the original — guarantees decodability).
let poster_bytes = match extract_poster_frame(&transcoded) {
Ok(poster_path) => {
let bytes = std::fs::read(&poster_path).ok();
let _ = std::fs::remove_file(&poster_path);
bytes
}
Err(e) => {
eprintln!("sprout-desktop: poster extraction failed (non-fatal): {e}");
None
}
};
let video_bytes =
std::fs::read(&transcoded).map_err(|e| format!("failed to read transcoded file: {e}"));
let _ = std::fs::remove_file(&transcoded);
Ok((video_bytes?, poster_bytes))
}
/// 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
@@ -235,7 +500,12 @@ pub async fn pick_and_upload_media(
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.add_filter("Images", &["jpg", "jpeg", "png", "gif", "webp"])
.add_filter(
"Media",
&[
"jpg", "jpeg", "png", "gif", "webp", "mp4", "mov", "mkv", "webm", "avi",
],
)
.pick_file(move |path| {
let _ = tx.send(path);
});
@@ -246,26 +516,65 @@ pub async fn pick_and_upload_media(
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())?;
let path = file_path.as_path().ok_or("invalid path")?.to_path_buf();
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);
// Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a
// local attacker from swapping the file between dialog return and read.
let mut file = std::fs::File::open(&path).map_err(|e| e.to_string())?;
// All sync I/O (sniff, transcode, read) runs off the async runtime to
// avoid blocking Tokio worker threads during long ffmpeg transcodes.
let (body, poster_bytes) =
tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, Option<Vec<u8>>), String> {
use std::io::Read;
// Sniff magic bytes from the pinned fd — no re-open, no TOCTOU.
let mut header = [0u8; 4096];
let n = file.read(&mut header).map_err(|e| e.to_string())?;
if is_video_file(&header[..n]) {
// ffmpeg needs a path, not an fd. Resolve the fd's real path
// so we pass the actual inode's location, not the original
// (potentially swapped) pathname. Same pattern as upload_media.
// IMPORTANT: keep `file` alive (fd open) until after transcode
// completes — this prevents the inode from being unlinked or
// the resolved path from becoming stale during the ffmpeg run.
let fd_path = fd_real_path(&file)?;
let result = transcode_and_extract_poster(&fd_path);
drop(file); // release fd only after ffmpeg is done
result
} else {
// Image: read the rest from the already-open fd (TOCTOU-safe).
let mut bytes = header[..n].to_vec();
file.read_to_end(&mut bytes)
.map_err(|e| format!("failed to read file: {e}"))?;
Ok((bytes, None))
}
})
.await
.map_err(|e| format!("transcode task failed: {e}"))??;
let mime = detect_and_validate_mime(&body)?;
do_upload(body, &mime, &state).await.map(Some)
// Upload video first, then poster (best-effort). If poster upload fails,
// the video descriptor is returned without an image field.
let mut descriptor = do_upload(body, &mime, &state).await?;
if let Some(poster) = poster_bytes {
match do_upload(poster, "image/jpeg", &state).await {
Ok(poster_desc) => descriptor.image = Some(poster_desc.url),
Err(e) => eprintln!("sprout-desktop: poster upload failed (non-fatal): {e}"),
}
}
Ok(Some(descriptor))
}
/// 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.
/// If the bytes are a video, they're written to a temp file, transcoded via
/// ffmpeg, and the transcoded output is uploaded instead.
#[tauri::command]
pub async fn upload_media_bytes(
data: Vec<u8>,
@@ -274,8 +583,41 @@ pub async fn upload_media_bytes(
if data.is_empty() {
return Err("empty upload".to_string());
}
let mime = detect_and_validate_mime(&data)?;
do_upload(data, &mime, &state).await
let (body, poster_bytes) = if is_video_file(&data) {
// Video: write to temp → transcode + extract poster → read results.
// All blocking I/O runs off the async runtime via spawn_blocking.
tokio::task::spawn_blocking(move || -> Result<(Vec<u8>, Option<Vec<u8>>), String> {
let tmp_input =
std::env::temp_dir().join(format!("sprout-drop-{}", uuid::Uuid::new_v4()));
// Cleanup guard: remove temp file on ALL exit paths (including write failure).
let result = (|| {
std::fs::write(&tmp_input, &data)
.map_err(|e| format!("failed to write temp file: {e}"))?;
transcode_and_extract_poster(&tmp_input)
})();
let _ = std::fs::remove_file(&tmp_input);
result
})
.await
.map_err(|e| format!("transcode task failed: {e}"))??
} else {
(data, None)
};
let mime = detect_and_validate_mime(&body)?;
// Upload video first, then poster (best-effort).
let mut descriptor = do_upload(body, &mime, &state).await?;
if let Some(poster) = poster_bytes {
match do_upload(poster, "image/jpeg", &state).await {
Ok(poster_desc) => descriptor.image = Some(poster_desc.url),
Err(e) => eprintln!("sprout-desktop: poster upload failed (non-fatal): {e}"),
}
}
Ok(descriptor)
}
// ── Tests ────────────────────────────────────────────────────────────────────
@@ -338,4 +680,32 @@ mod tests {
let text = b"hello world";
assert!(detect_and_validate_mime(text).is_err());
}
#[test]
fn test_is_video_file_mp4() {
// Minimal ftyp box (MP4 magic bytes)
let ftyp: &[u8] = &[
0x00, 0x00, 0x00, 0x14, b'f', b't', b'y', b'p', b'i', b's', b'o', b'm', 0x00, 0x00,
0x00, 0x00, b'i', b's', b'o', b'm',
];
assert!(is_video_file(ftyp));
}
#[test]
fn test_is_video_file_jpeg_is_not_video() {
let jpeg = [0xFF, 0xD8, 0xFF, 0xE0];
assert!(!is_video_file(&jpeg));
}
#[test]
fn test_is_video_file_empty() {
assert!(!is_video_file(&[]));
}
#[test]
fn test_find_ffmpeg_runs() {
// This test verifies the function doesn't panic.
// It may pass or fail depending on whether ffmpeg is installed.
let _ = find_ffmpeg();
}
}

View File

@@ -205,6 +205,12 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> {
Ok(())
}
/// Defense-in-depth cap: refuse to buffer responses larger than this into RAM.
/// Range requests (≤16 MiB from server) always fit. Full GETs for huge videos
/// get a clear 413 instead of OOM — the <video> element always uses range
/// requests for seeking, so this only catches edge cases.
const MAX_PROXY_RESPONSE: u64 = 20 * 1024 * 1024;
/// Proxy media requests through the Rust backend so they traverse the WARP tunnel.
///
/// WKWebView's networking stack bypasses WARP, causing 403s from Cloudflare Access.
@@ -229,14 +235,21 @@ async fn handle_sprout_media(
return error_response(404, "not found");
}
let has_range = request.headers().contains_key("range");
let upstream_url = format!("{base}{path_and_query}");
let result = state
// Forward Range header if present — enables video seeking through the proxy.
let mut upstream = state
.http_client
.get(&upstream_url)
.timeout(std::time::Duration::from_secs(30))
.send()
.await;
.timeout(std::time::Duration::from_secs(60));
if let Some(range) = request.headers().get("range") {
if let Ok(v) = range.to_str() {
upstream = upstream.header("range", v);
}
}
let result = upstream.send().await;
match result {
Ok(resp) => {
@@ -248,12 +261,57 @@ async fn handle_sprout_media(
.unwrap_or("application/octet-stream")
.to_string();
// Propagate range-related headers so <video> seeking works.
let content_range = resp
.headers()
.get("content-range")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let accept_ranges = resp
.headers()
.get("accept-ranges")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let content_length = resp
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// OOM guard: if this is a non-range GET and the upstream body is
// larger than our cap, bail with 413 instead of buffering into RAM.
// Tauri's protocol handler requires Vec<u8> so we can't truly stream.
if !has_range {
if let Some(ref cl) = content_length {
if let Ok(len) = cl.parse::<u64>() {
if len > MAX_PROXY_RESPONSE {
return error_response(
413,
"response too large — use range requests for video playback",
);
}
}
}
}
match resp.bytes().await {
Ok(bytes) => http::Response::builder()
.status(status)
.header("content-type", &content_type)
.body(bytes.to_vec())
.unwrap_or_else(|_| error_response(500, "response build failed")),
Ok(bytes) => {
let mut builder = http::Response::builder()
.status(status)
.header("content-type", &content_type);
if let Some(ref cr) = content_range {
builder = builder.header("content-range", cr);
}
if let Some(ref ar) = accept_ranges {
builder = builder.header("accept-ranges", ar);
}
if let Some(ref cl) = content_length {
builder = builder.header("content-length", cl);
}
builder
.body(bytes.to_vec())
.unwrap_or_else(|_| error_response(500, "response build failed"))
}
Err(_) => error_response(502, "failed to read upstream body"),
}
}

View File

@@ -7,6 +7,8 @@ export type ImetaEntry = {
blurhash?: string;
alt?: string;
thumb?: string;
duration?: number;
image?: string;
};
export function parseImetaTags(tags: string[][]): Map<string, ImetaEntry> {
@@ -44,6 +46,12 @@ export function parseImetaTags(tags: string[][]): Map<string, ImetaEntry> {
case "thumb":
entry.thumb = val;
break;
case "duration":
entry.duration = parseFloat(val);
break;
case "image":
entry.image = val;
break;
}
}
if (entry.url) map.set(entry.url, entry as ImetaEntry);

View File

@@ -6,7 +6,17 @@ import {
uploadMediaBytes,
} from "@/shared/api/tauri";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"video/mp4",
"video/quicktime",
"video/x-matroska",
"video/webm",
"video/x-msvideo",
];
type UploadState = {
status: "idle" | "uploading" | "error";
@@ -26,7 +36,10 @@ export function useMediaUpload(
const onUploaded = React.useCallback(
(descriptor: BlobDescriptor) => {
const markdown = `\n![image](${descriptor.url})\n`;
const isVideo = descriptor.type === "video/mp4";
const markdown = isVideo
? `\n![video](${descriptor.url})\n`
: `\n![image](${descriptor.url})\n`;
setContent((prev) => prev + markdown);
setPendingImeta((prev) => [...prev, descriptor]);
setUploadState({ status: "idle" });
@@ -60,7 +73,8 @@ export function useMediaUpload(
if (!ALLOWED_TYPES.includes(file.type)) {
setUploadState({
status: "error",
message: "Only JPEG, PNG, GIF, and WebP images are supported",
message:
"Unsupported file type. Supported: JPEG, PNG, GIF, WebP, MP4, MOV, MKV, WebM, AVI",
});
return;
}
@@ -87,11 +101,11 @@ export function useMediaUpload(
const handlePaste = React.useCallback(
async (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = Array.from(event.clipboardData.items);
const imageItem = items.find((item) => ALLOWED_TYPES.includes(item.type));
if (!imageItem) return;
const mediaItem = items.find((item) => ALLOWED_TYPES.includes(item.type));
if (!mediaItem) return;
event.preventDefault();
const file = imageItem.getAsFile();
const file = mediaItem.getAsFile();
if (!file) return;
setUploadState({ status: "uploading" });

View File

@@ -303,6 +303,8 @@ export function MessageComposer({
...(d.dim ? [`dim ${d.dim}`] : []),
...(d.blurhash ? [`blurhash ${d.blurhash}`] : []),
...(d.thumb ? [`thumb ${d.thumb}`] : []),
...(d.duration != null ? [`duration ${d.duration}`] : []),
...(d.image ? [`image ${d.image}`] : []),
])
: undefined;

View File

@@ -8,6 +8,7 @@ import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { BotIdenticon } from "./BotIdenticon";
@@ -59,6 +60,11 @@ export const MessageRow = React.memo(
[profiles, message.tags],
);
const imetaByUrl = React.useMemo(
() => (message.tags ? parseImetaTags(message.tags) : undefined),
[message.tags],
);
const { channels } = useChannelNavigation();
const channelNames = React.useMemo(
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
@@ -107,6 +113,7 @@ export const MessageRow = React.memo(
channelNames={channelNames}
className="max-w-3xl"
content={message.body}
imetaByUrl={imetaByUrl}
mentionNames={mentionNames}
tight
/>

View File

@@ -723,6 +723,8 @@ export type BlobDescriptor = {
dim?: string;
blurhash?: string;
thumb?: string;
duration?: number;
image?: string;
};
export async function uploadMedia(

View File

@@ -15,7 +15,7 @@
// Matches: https://anything.com/media/{64-hex}.{ext}
// Also matches thumbnails: /media/{64-hex}.thumb.jpg
const RELAY_MEDIA_RE =
/^(?:https?:\/\/[^/]+)\/media\/([\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp)(?:\?.*)?)$/;
/^(?:https?:\/\/[^/]+)\/media\/([\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp|mp4)(?:\?.*)?)$/;
/**
* If `url` looks like a Blossom relay media URL, rewrite it to go through

View File

@@ -11,11 +11,14 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import remarkChannelLinks from "@/shared/lib/remarkChannelLinks";
import remarkMentions from "@/shared/lib/remarkMentions";
type ImetaLookup = Map<string, { image?: string; thumb?: string }>;
type MarkdownProps = {
channelNames?: string[];
className?: string;
compact?: boolean;
content: string;
imetaByUrl?: ImetaLookup;
mentionNames?: string[];
tight?: boolean;
};
@@ -26,6 +29,7 @@ function createMarkdownComponents(
variant: MarkdownVariant,
channels: Channel[],
onOpenChannel: (channelId: string) => void,
imetaByUrl?: ImetaLookup,
): Components {
const paragraphClassName =
variant === "tight"
@@ -105,13 +109,35 @@ function createMarkdownComponents(
<h3 className="font-semibold tracking-tight">{children}</h3>
),
hr: () => <hr className="border-border/80" />,
img: ({ alt, src }) => (
<img
alt={alt}
className="max-h-96 rounded-2xl border border-border/70 object-cover"
src={src ? rewriteRelayUrl(src) : src}
/>
),
img: ({ alt, src }) => {
const resolvedSrc = src ? rewriteRelayUrl(src) : src;
if (resolvedSrc?.endsWith(".mp4")) {
// Look up poster frame from imeta tags (NIP-71 `image` field).
// Fall back to `thumb` for compatibility with older events.
const entry = src ? imetaByUrl?.get(src) : undefined;
const posterUrl = entry?.image ?? entry?.thumb;
const resolvedPoster = posterUrl
? rewriteRelayUrl(posterUrl)
: undefined;
return (
// biome-ignore lint/a11y/useMediaCaption: user-uploaded video, no captions available
<video
controls
preload="metadata"
poster={resolvedPoster}
className="max-h-96 rounded-2xl border border-border/70"
src={resolvedSrc}
/>
);
}
return (
<img
alt={alt}
className="max-h-96 rounded-2xl border border-border/70 object-cover"
src={resolvedSrc}
/>
);
},
li: ({ children }) => <li className={listItemClassName}>{children}</li>,
ol: ({ children }) => (
<ol className={cn("list-decimal", listClassName)}>{children}</ol>
@@ -198,6 +224,7 @@ function MarkdownInner({
className,
compact = false,
content,
imetaByUrl,
mentionNames,
tight = false,
}: MarkdownProps) {
@@ -211,10 +238,15 @@ function MarkdownInner({
const components = React.useMemo(
() =>
createMarkdownComponents(variant, channels, (channelId) => {
void goChannel(channelId);
}),
[goChannel, variant, channels],
createMarkdownComponents(
variant,
channels,
(channelId) => {
void goChannel(channelId);
},
imetaByUrl,
),
[goChannel, variant, channels, imetaByUrl],
);
// biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable
@@ -264,7 +296,8 @@ export const Markdown = React.memo(
prev.compact === next.compact &&
prev.tight === next.tight &&
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&
shallowArrayEqual(prev.channelNames, next.channelNames),
shallowArrayEqual(prev.channelNames, next.channelNames) &&
prev.imetaByUrl === next.imetaByUrl,
);
Markdown.displayName = "Markdown";

378
scripts/test-video-upload.sh Executable file
View File

@@ -0,0 +1,378 @@
#!/usr/bin/env bash
# test-video-upload.sh — Live validation of the Blossom video upload flow.
#
# Prerequisites:
# - Relay running at $RELAY_URL (default: http://localhost:3000)
# - Dev mode (SPROUT_REQUIRE_AUTH_TOKEN=false) or valid API token
# - ffmpeg, nak, curl, jq, shasum on PATH
#
# Usage:
# ./scripts/test-video-upload.sh # run all tests
# RELAY_URL=http://host:3000 ./scripts/... # custom relay URL
# NSEC=nsec1... ./scripts/... # use existing key
set -euo pipefail
RELAY_URL="${RELAY_URL:-http://localhost:3000}"
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
PASS=0
FAIL=0
pass() { echo " ✅ $1"; PASS=$((PASS + 1)); }
fail() { echo " ❌ $1"; FAIL=$((FAIL + 1)); }
# ── Dependencies ───────────────────────────────────────────────────────────────
for cmd in ffmpeg nak curl jq shasum; do
command -v "$cmd" >/dev/null 2>&1 || { echo "Missing: $cmd"; exit 1; }
done
# ── Key generation ─────────────────────────────────────────────────────────────
if [ -z "${NSEC:-}" ]; then
NSEC="$(nak key generate)"
echo "Generated key: $(echo "$NSEC" | nak key public)"
fi
NPUB="$(echo "$NSEC" | nak key public)"
echo "Using pubkey: $NPUB"
echo "Relay: $RELAY_URL"
echo ""
# ── Generate test MP4 ─────────────────────────────────────────────────────────
# Minimal 1-second H.264 video with moov at front (faststart).
TEST_MP4="$TMPDIR/test.mp4"
ffmpeg -y -f lavfi -i "color=c=blue:s=320x240:d=1" \
-c:v libx264 -profile:v baseline -pix_fmt yuv420p \
-movflags +faststart \
"$TEST_MP4" 2>/dev/null
FILE_SIZE=$(wc -c < "$TEST_MP4" | tr -d ' ')
SHA256=$(shasum -a 256 "$TEST_MP4" | cut -d' ' -f1)
echo "Test MP4: ${FILE_SIZE} bytes, sha256=${SHA256:0:16}..."
echo ""
# ── Helper: build Blossom auth header ──────────────────────────────────────────
# Creates a kind:24242 event with t=upload, x=<sha256>, expiration=+5min.
blossom_auth() {
local sha256="$1"
local now exp auth_event auth_b64
now=$(date +%s)
exp=$((now + 300))
auth_event=$(nak event \
--sec "$NSEC" \
-k 24242 \
-c "Upload test video" \
-t t=upload \
-t "x=$sha256" \
-t "expiration=$exp" \
2>/dev/null)
auth_b64=$(echo -n "$auth_event" | base64 | tr -d '\n')
echo "Nostr $auth_b64"
}
# ── Test 1: Upload MP4 ────────────────────────────────────────────────────────
echo "Test 1: Upload MP4 via PUT /media/upload"
AUTH="$(blossom_auth "$SHA256")"
UPLOAD_RESP=$(curl -s -w "\n%{http_code}" \
-X PUT "$RELAY_URL/media/upload" \
-H "Authorization: $AUTH" \
-H "Content-Type: video/mp4" \
-H "X-SHA-256: $SHA256" \
--data-binary "@$TEST_MP4")
UPLOAD_HTTP=$(echo "$UPLOAD_RESP" | tail -1)
UPLOAD_BODY=$(echo "$UPLOAD_RESP" | sed '$d')
if [ "$UPLOAD_HTTP" = "200" ]; then
pass "Upload returned 200"
BLOB_URL=$(echo "$UPLOAD_BODY" | jq -r '.url // empty')
if [ -n "$BLOB_URL" ]; then
pass "Response contains url: ${BLOB_URL:0:60}..."
else
fail "Response missing url field"
fi
# Check duration field present
DURATION=$(echo "$UPLOAD_BODY" | jq -r '.duration // empty')
if [ -n "$DURATION" ]; then
pass "Response contains duration: ${DURATION}s"
else
fail "Response missing duration field"
fi
else
fail "Upload returned $UPLOAD_HTTP (expected 200)"
echo " Body: $UPLOAD_BODY"
fi
echo ""
# ── Test 2: GET full blob ─────────────────────────────────────────────────────
echo "Test 2: GET /media/${SHA256}.mp4 (full download)"
GET_RESP=$(curl -s -o "$TMPDIR/downloaded.mp4" -w "%{http_code}" \
"$RELAY_URL/media/${SHA256}.mp4")
if [ "$GET_RESP" = "200" ]; then
pass "GET returned 200"
DL_SIZE=$(wc -c < "$TMPDIR/downloaded.mp4" | tr -d ' ')
if [ "$DL_SIZE" = "$FILE_SIZE" ]; then
pass "Downloaded size matches ($DL_SIZE bytes)"
else
fail "Size mismatch: expected $FILE_SIZE, got $DL_SIZE"
fi
else
fail "GET returned $GET_RESP (expected 200)"
fi
echo ""
# ── Test 3: HEAD with Accept-Ranges ──────────────────────────────────────────
echo "Test 3: HEAD /media/${SHA256}.mp4 (Accept-Ranges)"
HEAD_RESP=$(curl -s -I "$RELAY_URL/media/${SHA256}.mp4")
HEAD_HTTP=$(echo "$HEAD_RESP" | head -1 | grep -o '[0-9]\{3\}')
ACCEPT_RANGES=$(echo "$HEAD_RESP" | grep -i "accept-ranges" | tr -d '\r')
if [ "$HEAD_HTTP" = "200" ]; then
pass "HEAD returned 200"
else
fail "HEAD returned $HEAD_HTTP (expected 200)"
fi
if echo "$ACCEPT_RANGES" | grep -qi "bytes"; then
pass "Accept-Ranges: bytes present"
else
fail "Accept-Ranges header missing or wrong: '$ACCEPT_RANGES'"
fi
echo ""
# ── Test 4: Range GET (206 Partial Content) ──────────────────────────────────
echo "Test 4: Range GET bytes=0-499 (206 Partial Content)"
RANGE_RESP=$(curl -s -o "$TMPDIR/range.bin" -w "%{http_code}" \
-H "Range: bytes=0-499" \
"$RELAY_URL/media/${SHA256}.mp4")
if [ "$RANGE_RESP" = "206" ]; then
pass "Range GET returned 206"
RANGE_SIZE=$(wc -c < "$TMPDIR/range.bin" | tr -d ' ')
if [ "$RANGE_SIZE" = "500" ]; then
pass "Received exactly 500 bytes"
else
fail "Expected 500 bytes, got $RANGE_SIZE"
fi
else
fail "Range GET returned $RANGE_RESP (expected 206)"
fi
echo ""
# ── Test 5: Range GET past EOF (416) ─────────────────────────────────────────
echo "Test 5: Range GET bytes=999999999- (416 Range Not Satisfiable)"
RANGE416_RESP=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Range: bytes=999999999-" \
"$RELAY_URL/media/${SHA256}.mp4")
if [ "$RANGE416_RESP" = "416" ]; then
pass "Past-EOF range returned 416"
else
fail "Past-EOF range returned $RANGE416_RESP (expected 416)"
fi
echo ""
# ── Test 6: Content-Type spoofing rejection ──────────────────────────────────
# Send video/mp4 Content-Type but with a PNG body — should be rejected.
echo "Test 6: Content-Type spoofing (video/mp4 header, PNG body)"
PNG_FILE="$TMPDIR/fake.png"
# Minimal valid PNG (1x1 red pixel)
printf '\x89PNG\r\n\x1a\n' > "$PNG_FILE"
dd if=/dev/zero bs=100 count=1 >> "$PNG_FILE" 2>/dev/null
PNG_SHA=$(shasum -a 256 "$PNG_FILE" | cut -d' ' -f1)
SPOOF_AUTH="$(blossom_auth "$PNG_SHA")"
SPOOF_RESP=$(curl -s -w "\n%{http_code}" \
-X PUT "$RELAY_URL/media/upload" \
-H "Authorization: $SPOOF_AUTH" \
-H "Content-Type: video/mp4" \
-H "X-SHA-256: $PNG_SHA" \
--data-binary "@$PNG_FILE")
SPOOF_HTTP=$(echo "$SPOOF_RESP" | tail -1)
if [ "$SPOOF_HTTP" = "415" ] || [ "$SPOOF_HTTP" = "400" ]; then
pass "Spoofed upload rejected with $SPOOF_HTTP"
else
fail "Spoofed upload returned $SPOOF_HTTP (expected 400 or 415)"
fi
echo ""
# ── Test 7: Idempotent re-upload ─────────────────────────────────────────────
echo "Test 7: Idempotent re-upload (same file, same hash)"
REUP_AUTH="$(blossom_auth "$SHA256")"
REUP_RESP=$(curl -s -w "\n%{http_code}" \
-X PUT "$RELAY_URL/media/upload" \
-H "Authorization: $REUP_AUTH" \
-H "Content-Type: video/mp4" \
-H "X-SHA-256: $SHA256" \
--data-binary "@$TEST_MP4")
REUP_HTTP=$(echo "$REUP_RESP" | tail -1)
if [ "$REUP_HTTP" = "200" ]; then
pass "Re-upload returned 200 (idempotent)"
else
fail "Re-upload returned $REUP_HTTP (expected 200)"
fi
echo ""
# ── Test 8: Poster frame upload + imeta validation ───────────────────────────
# Upload a JPEG poster frame, then verify the server accepts an imeta tag
# that links the video and poster via the NIP-71 `image` field.
echo "Test 8: Upload poster frame (JPEG image)"
POSTER_JPG="$TMPDIR/poster.jpg"
ffmpeg -y -ss 0.5 -i "$TEST_MP4" -vframes 1 -vf "scale=640:-2" -q:v 2 \
"$POSTER_JPG" 2>/dev/null
if [ ! -s "$POSTER_JPG" ]; then
# Fallback: first frame (video may be too short for 0.5s seek)
ffmpeg -y -i "$TEST_MP4" -vframes 1 -vf "scale=640:-2" -q:v 2 \
"$POSTER_JPG" 2>/dev/null
fi
POSTER_SIZE=$(wc -c < "$POSTER_JPG" | tr -d ' ')
POSTER_SHA=$(shasum -a 256 "$POSTER_JPG" | cut -d' ' -f1)
echo " Poster: ${POSTER_SIZE} bytes, sha256=${POSTER_SHA:0:16}..."
POSTER_AUTH="$(blossom_auth "$POSTER_SHA")"
POSTER_RESP=$(curl -s -w "\n%{http_code}" \
-X PUT "$RELAY_URL/media/upload" \
-H "Authorization: $POSTER_AUTH" \
-H "Content-Type: image/jpeg" \
-H "X-SHA-256: $POSTER_SHA" \
--data-binary "@$POSTER_JPG")
POSTER_HTTP=$(echo "$POSTER_RESP" | tail -1)
POSTER_BODY=$(echo "$POSTER_RESP" | sed '$d')
if [ "$POSTER_HTTP" = "200" ]; then
pass "Poster upload returned 200"
POSTER_URL=$(echo "$POSTER_BODY" | jq -r '.url // empty')
if [ -n "$POSTER_URL" ]; then
pass "Poster has url: ${POSTER_URL:0:60}..."
else
fail "Poster response missing url"
fi
# Poster should have dim but NOT duration
POSTER_DIM=$(echo "$POSTER_BODY" | jq -r '.dim // empty')
POSTER_DUR=$(echo "$POSTER_BODY" | jq -r '.duration // empty')
if [ -n "$POSTER_DIM" ]; then
pass "Poster has dim: $POSTER_DIM"
else
fail "Poster missing dim"
fi
if [ -z "$POSTER_DUR" ]; then
pass "Poster correctly omits duration"
else
fail "Poster should not have duration, got: $POSTER_DUR"
fi
else
fail "Poster upload returned $POSTER_HTTP (expected 200)"
echo " Body: $POSTER_BODY"
fi
echo ""
# ── Test 9: GET poster frame ─────────────────────────────────────────────────
echo "Test 9: GET poster frame"
POSTER_GET_RESP=$(curl -s -o "$TMPDIR/poster_dl.jpg" -w "%{http_code}" \
"$RELAY_URL/media/${POSTER_SHA}.jpg")
if [ "$POSTER_GET_RESP" = "200" ]; then
pass "GET poster returned 200"
POSTER_DL_SIZE=$(wc -c < "$TMPDIR/poster_dl.jpg" | tr -d ' ')
if [ "$POSTER_DL_SIZE" = "$POSTER_SIZE" ]; then
pass "Poster download size matches ($POSTER_DL_SIZE bytes)"
else
fail "Poster size mismatch: expected $POSTER_SIZE, got $POSTER_DL_SIZE"
fi
else
fail "GET poster returned $POSTER_GET_RESP (expected 200)"
fi
echo ""
# ── Test 10: Video + poster blobs coexist and are independently retrievable ──
# The server links video and poster purely through the imeta tag at message
# send time. Here we verify the prerequisite: both blobs exist, have correct
# Content-Types, and are independently addressable.
echo "Test 10: Video + poster blobs coexist"
VIDEO_HEAD=$(curl -s -o /dev/null -w "%{http_code}" -I "$RELAY_URL/media/${SHA256}.mp4")
POSTER_HEAD=$(curl -s -o /dev/null -w "%{http_code}" -I "$RELAY_URL/media/${POSTER_SHA}.jpg")
if [ "$VIDEO_HEAD" = "200" ] && [ "$POSTER_HEAD" = "200" ]; then
pass "Both video and poster blobs exist (HEAD 200)"
else
fail "Blob existence check failed: video=$VIDEO_HEAD poster=$POSTER_HEAD"
fi
# Verify poster is an image (not video) by checking Content-Type
POSTER_CT=$(curl -s -I "$RELAY_URL/media/${POSTER_SHA}.jpg" | grep -i "content-type" | tr -d '\r' | awk '{print $2}')
if echo "$POSTER_CT" | grep -qi "image/jpeg"; then
pass "Poster Content-Type is image/jpeg"
else
fail "Poster Content-Type should be image/jpeg, got: $POSTER_CT"
fi
# Verify video and poster have different hashes (independent blobs)
if [ "$SHA256" != "$POSTER_SHA" ]; then
pass "Video and poster have distinct content hashes"
else
fail "Video and poster hashes should differ"
fi
echo ""
# ── Test 11: Poster sidecar has correct metadata ─────────────────────────────
# The server writes a JSON sidecar for every uploaded blob. Verify the poster
# sidecar exists and has image MIME type (the server's verify_imeta_blobs
# checks this at message send time).
echo "Test 11: Poster sidecar metadata"
# The bare-hash GET resolves via sidecar — if it returns 200 with image/jpeg
# content-type, the sidecar is correctly configured.
POSTER_BARE_RESP=$(curl -s -D "$TMPDIR/poster_bare_headers.txt" -o /dev/null -w "%{http_code}" \
"$RELAY_URL/media/${POSTER_SHA}")
if [ "$POSTER_BARE_RESP" = "200" ]; then
pass "Poster bare-hash GET resolves via sidecar (200)"
else
fail "Poster bare-hash GET returned $POSTER_BARE_RESP (expected 200)"
fi
# Verify Content-Type on the bare-hash response
POSTER_BARE_CT=$(grep -i "content-type" "$TMPDIR/poster_bare_headers.txt" | tr -d '\r' | awk '{print $2}')
if echo "$POSTER_BARE_CT" | grep -qi "image/jpeg"; then
pass "Poster bare-hash Content-Type is image/jpeg"
else
fail "Poster bare-hash Content-Type should be image/jpeg, got: $POSTER_BARE_CT"
fi
# Note: Full imeta image validation (accept/reject at message send time) is
# covered by Rust unit tests: test_imeta_image_poster_frame_accepted,
# test_imeta_image_video_url_rejected, test_imeta_image_thumbnail_url_rejected.
echo ""
# ── Summary ───────────────────────────────────────────────────────────────────
echo "════════════════════════════════════════"
echo " Results: $PASS passed, $FAIL failed"
echo "════════════════════════════════════════"
[ "$FAIL" -eq 0 ] && exit 0 || exit 1