mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat: Phase 1 video upload support (Blossom-compliant-ish) (#285)
This commit is contained in:
63
Cargo.lock
generated
63
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
658
crates/sprout-test-client/tests/e2e_media_video.rs
Normal file
658
crates/sprout-test-client/tests/e2e_media_video.rs
Normal 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!(""),
|
||||
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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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\n`;
|
||||
const isVideo = descriptor.type === "video/mp4";
|
||||
const markdown = isVideo
|
||||
? `\n\n`
|
||||
: `\n\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" });
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -723,6 +723,8 @@ export type BlobDescriptor = {
|
||||
dim?: string;
|
||||
blurhash?: string;
|
||||
thumb?: string;
|
||||
duration?: number;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
export async function uploadMedia(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
378
scripts/test-video-upload.sh
Executable 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
|
||||
Reference in New Issue
Block a user