Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4697d35
fix: image hash cross-check, moov scanner hardening, upload descripto…
tlongwell-block Apr 9, 2026
88b5fcd
fix: reject zero-duration in imeta validation (defense-in-depth)
tlongwell-block Apr 9, 2026
2dae0ff
feat: add get_range() and put_file() to MediaStorage for video support
tlongwell-block Apr 9, 2026
8ce6dd0
feat: wire max_video_bytes into relay config and body limit
tlongwell-block Apr 9, 2026
57cec4d
fix(media): use S3-native range GET for 206 responses
tlongwell-block Apr 9, 2026
c367679
fix: moov scanner fails closed on atom budget exceeded
tlongwell-block Apr 9, 2026
757030b
fix: remove semantically wrong image hash cross-check against x
tlongwell-block Apr 9, 2026
9f7e165
fix: reject video/mp4 in image upload path (Content-Type spoofing)
tlongwell-block Apr 9, 2026
6edb522
fix: reject zero-duration videos in validate_video_file()
tlongwell-block Apr 9, 2026
d1662b3
feat: add get_stream() for streaming S3 downloads
tlongwell-block Apr 9, 2026
07f4c37
fix: map body-limit stream errors to FileTooLarge (413) not Io (500)
tlongwell-block Apr 9, 2026
179e26e
test: add 5 video E2E tests (upload, spoofing, range, 416, auth)
tlongwell-block Apr 9, 2026
d5cf7ff
feat(media): streaming upload + streaming download
tlongwell-block Apr 9, 2026
7d9f874
fix: verify poster frame blob exists in verify_imeta_blobs
tlongwell-block Apr 9, 2026
04e0071
fix(media): support suffix byte ranges (bytes=-N) per RFC 9110
tlongwell-block Apr 9, 2026
b25144d
fix: buffer leading bytes for MIME sniffing instead of using first chunk
tlongwell-block Apr 9, 2026
377ea04
fix: bump MIME sniff buffer from 64 to 4096 bytes
tlongwell-block Apr 9, 2026
4ccafb8
test: add live video upload validation script
tlongwell-block Apr 9, 2026
403fdb5
feat: video support infrastructure (deps, error variants, config, types)
tlongwell-block Apr 9, 2026
0b354bf
fix: use std::io::Error::other() to satisfy clippy io_other_error lint
tlongwell-block Apr 9, 2026
3ff104e
fix: remove needless Ok wrapper to satisfy clippy
tlongwell-block Apr 9, 2026
0edea05
fix: poster frame sidecar verification + E2E test X-SHA-256 headers
tlongwell-block Apr 9, 2026
a5b8901
fix: add Authorization header and remove stray tokens in E2E video tests
tlongwell-block Apr 9, 2026
82cf29d
fix: poster extension mismatch + duration cross-check in verify_imeta…
tlongwell-block Apr 9, 2026
0d0274a
fix: reject thumbnail URLs in imeta image field (poster frames must b…
tlongwell-block Apr 9, 2026
aefeb5c
style: rustfmt formatting fixes
tlongwell-block Apr 9, 2026
d606881
fix: crossfire follow-ups — imeta field gating, multi-range fallback,…
tlongwell-block Apr 9, 2026
f2fdec4
feat(desktop): video upload support — ffmpeg transcode, drag-drop, in…
tlongwell-block Apr 10, 2026
12a0557
fix(desktop): show video thumbnail before play (preload=auto)
tlongwell-block Apr 10, 2026
86e2208
feat(desktop): video poster frame extraction + inline playback
tlongwell-block Apr 10, 2026
c06e4f2
fix: crossfire follow-ups — scoped auth, TOCTOU fd-pinning, proxy OOM…
tlongwell-block Apr 10, 2026
34a6f7b
fix(desktop): add ffmpeg wall-clock timeout to prevent indefinite hangs
tlongwell-block Apr 10, 2026
3745637
fix(desktop): prevent stderr pipe deadlock in ffmpeg timeout wrapper
tlongwell-block Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/sprout-media/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
29 changes: 16 additions & 13 deletions crates/sprout-media/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -156,15 +157,15 @@ 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]
fn test_verify_auth_event_valid() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
assert!(verify_blossom_auth_event(&event, None).is_ok());
assert!(verify_blossom_auth_event(&event, None, 600).is_ok());
}

#[test]
Expand All @@ -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)
));
}
Expand All @@ -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)
));
}
Expand All @@ -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]
Expand All @@ -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)
));
}
Expand All @@ -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]
Expand All @@ -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)
));
}
Expand Down
10 changes: 10 additions & 0 deletions crates/sprout-media/src/config.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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(())
}
}
29 changes: 28 additions & 1 deletion crates/sprout-media/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
}
Expand Down
5 changes: 3 additions & 2 deletions crates/sprout-media/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Loading
Loading