diff --git a/.env.example b/.env.example index e9ff8c082..db6d8c83c 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,14 @@ RELAY_URL=ws://localhost:3000 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS=8 # BUZZ_MEDIA_MAX_CONCURRENT_UPLOADS_PER_PUBKEY=2 # BUZZ_MEDIA_UPLOADS_PER_MINUTE=30 +# Required privacy toolchain. Defaults resolve these names from PATH; production +# images and CI install them explicitly. +# BUZZ_EXIFTOOL_PATH=exiftool +# BUZZ_FFMPEG_PATH=ffmpeg +# BUZZ_FFPROBE_PATH=ffprobe +# BUZZ_MAX_AUDIO_BYTES=104857600 +# BUZZ_MEDIA_IMAGE_PROCESS_TIMEOUT_SECS=120 +# BUZZ_MEDIA_AV_PROCESS_TIMEOUT_SECS=600 # Require Blossom t=get auth and relay membership for GET/HEAD /media/*. # Keep off until desktop/mobile/CLI clients that attach media read auth are deployed. # BUZZ_REQUIRE_MEDIA_GET_AUTH=false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44dfe5ef2..ff3a21fe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,8 +96,14 @@ jobs: uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 with: tool: cargo-nextest@0.9.136 + - name: Install media privacy tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl - name: Unit tests run: just test-unit + - name: Media compliance tests + run: just media-compliance-test desktop-core: name: Desktop Core @@ -446,8 +452,9 @@ jobs: - name: Start relay run: | chmod +x ./target/ci/buzz-relay + POSTGRES_PASSWORD=buzz_dev nohup env \ - DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ + DATABASE_URL="postgres://buzz:${POSTGRES_PASSWORD}@localhost:5432/buzz" \ REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ @@ -603,8 +610,9 @@ jobs: - name: Start relay run: | chmod +x ./target/ci/buzz-relay + POSTGRES_PASSWORD=buzz_dev nohup env \ - DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ + DATABASE_URL="postgres://buzz:${POSTGRES_PASSWORD}@localhost:5432/buzz" \ REDIS_URL=redis://localhost:6379 \ RELAY_URL=ws://localhost:3000 \ BUZZ_BIND_ADDR=0.0.0.0:3000 \ diff --git a/.intersect/sadscan.yaml b/.intersect/sadscan.yaml new file mode 100644 index 000000000..6d24d43f9 --- /dev/null +++ b/.intersect/sadscan.yaml @@ -0,0 +1,3 @@ +exclude_rules_for_files: + sq.pii.cc.visa: + - Cargo.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a684f84bb..855bb72a9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,8 @@ unacceptable behavior to **conduct@buzz-relay.org**. | pnpm | 10+ | Required for desktop app commands and `just ci` | | Flutter | 3.41+ | Required for mobile app — install via [flutter.dev](https://docs.flutter.dev/get-started/install) | | Docker | 24+ | For Postgres, Redis, MinIO | +| FFmpeg + ffprobe | 6+ | Media sanitization; pinned by Hermit | +| ExifTool | 12.70+ | Image metadata removal and verification; install with your OS package manager | | `just` | latest | Task runner — `cargo install just` | | `lefthook` | latest | Optional; run `lefthook install` for local Git hooks | | `sqlx` migrations | workspace crate | `just migrate` applies embedded migrations from `migrations/` | @@ -89,6 +91,13 @@ Adminer on `:8082`, Keycloak on `:8180` for local OAuth/OIDC testing, MinIO on `:9000` for media storage, and Prometheus on `:9090` for metrics) and runs all pending database migrations. +The relay validates its privacy toolchain during startup and fails closed when +FFmpeg, ffprobe, ExifTool, or a required codec is unavailable. Hermit supplies +FFmpeg and ffprobe. Install ExifTool separately (for example, +`brew install exiftool` on macOS or `apt install libimage-exiftool-perl` on +Debian/Ubuntu). Override binary locations with `BUZZ_EXIFTOOL_PATH`, +`BUZZ_FFMPEG_PATH`, and `BUZZ_FFPROBE_PATH` when needed. + ### Running the Relay and Desktop App ```bash @@ -132,6 +141,14 @@ just test-unit Unit tests are self-contained and run without Docker. They cover event parsing, filter matching, auth logic, workflow YAML parsing, and more. +The media compliance suite exercises the real packaged binaries and the full +synthetic format corpus. It intentionally fails instead of skipping when a +tool, codec, or fixture is missing: + +```bash +just media-compliance-test +``` + ### Integration Tests (requires running infrastructure) ```bash diff --git a/Cargo.lock b/Cargo.lock index d54fc64c0..7fcd5cd8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -987,6 +987,7 @@ name = "buzz-media" version = "0.1.0" dependencies = [ "axum", + "base64", "blurhash", "buzz-core", "bytes", @@ -997,6 +998,7 @@ dependencies = [ "image", "imagesize", "infer", + "metrics", "mp4", "nostr", "rust-s3", @@ -1239,6 +1241,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "tempfile", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.29.0", diff --git a/Dockerfile b/Dockerfile index ddc2579a3..ac62d72d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -126,7 +126,9 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ curl \ + ffmpeg \ git \ + libimage-exiftool-perl \ openssl \ && rm -rf /var/lib/apt/lists/* \ && groupadd --system --gid 1000 buzz \ diff --git a/Justfile b/Justfile index b21c62a13..88a840ad4 100644 --- a/Justfile +++ b/Justfile @@ -230,7 +230,7 @@ desktop-e2e-pre-push: _ensure-migrations cd {{desktop_dir}} && pnpm build && pnpm exec playwright test --only-changed=origin/main # Run all checks suitable for CI / pre-push (no infra needed) -ci: check test-unit desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test +ci: check test-unit media-compliance-test desktop-test desktop-build desktop-tauri-check desktop-tauri-test web-build mobile-test # ─── Test ───────────────────────────────────────────────────────────────────── @@ -262,6 +262,11 @@ test-unit: ./scripts/run-tests.sh unit fi +# Exercise the real FFmpeg/ffprobe/ExifTool privacy pipeline. These tests are +# intentionally not allowed to skip when a required executable is missing. +media-compliance-test: + cargo test -p buzz-media --test media_compliance -- --ignored --test-threads=1 + # Run integration tests only (starts services if needed) test-integration: ./scripts/run-tests.sh integration diff --git a/bin/.ffmpeg-6.1.1.pkg b/bin/.ffmpeg-6.1.1.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.ffmpeg-6.1.1.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/.ffprobe-6.1.1.pkg b/bin/.ffprobe-6.1.1.pkg new file mode 120000 index 000000000..383f4511d --- /dev/null +++ b/bin/.ffprobe-6.1.1.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/ffmpeg b/bin/ffmpeg new file mode 120000 index 000000000..4f2f621a9 --- /dev/null +++ b/bin/ffmpeg @@ -0,0 +1 @@ +.ffmpeg-6.1.1.pkg \ No newline at end of file diff --git a/bin/ffprobe b/bin/ffprobe new file mode 120000 index 000000000..dfdf7c92b --- /dev/null +++ b/bin/ffprobe @@ -0,0 +1 @@ +.ffprobe-6.1.1.pkg \ No newline at end of file diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index 7c6f58b36..bdcbbdcfe 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -65,7 +65,24 @@ const ALLOWED_MIMES: &[&str] = &[ "image/png", "image/gif", "image/webp", + "image/tiff", + "image/bmp", + "image/x-ms-bmp", + "image/heic", + "image/heif", + "image/avif", "video/mp4", + "video/quicktime", + "video/webm", + "video/x-matroska", + "audio/mpeg", + "audio/mp4", + "audio/aac", + "audio/flac", + "audio/wav", + "audio/x-wav", + "audio/ogg", + "audio/opus", ]; /// Maximum file size for image uploads (50 MB). @@ -74,6 +91,9 @@ const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; /// Maximum file size for video uploads (500 MB). const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; +/// Maximum file size for audio uploads (100 MB). +const MAX_AUDIO_BYTES: u64 = 100 * 1024 * 1024; + /// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value. /// /// The event includes: @@ -534,6 +554,8 @@ impl BuzzClient { // 3. Size check let max = if mime.starts_with("video/") { MAX_VIDEO_BYTES + } else if mime.starts_with("audio/") { + MAX_AUDIO_BYTES } else { MAX_IMAGE_BYTES }; @@ -559,7 +581,7 @@ impl BuzzClient { let exp_str = (now + expiry).to_string(); let mut blossom_tags = vec![ - Tag::parse(["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(["t", "media"]).map_err(|e| CliError::Other(e.to_string()))?, Tag::parse(["x", &sha256]).map_err(|e| CliError::Other(e.to_string()))?, Tag::parse(["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, ]; @@ -569,7 +591,7 @@ impl BuzzClient { .push(Tag::parse(["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?); } - let auth_event = EventBuilder::new(Kind::from(24242), "Upload file") + let auth_event = EventBuilder::new(Kind::from(24242), "Process media") .tags(blossom_tags) .sign_with_keys(&self.keys) .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; @@ -581,13 +603,13 @@ impl BuzzClient { URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) ); - // 7. PUT request to /media/upload — with generous per-request timeout. + // 7. PUT request to BUD-05 /media — with generous per-request timeout. let upload_timeout = if mime.starts_with("video/") { Duration::from_secs(600) } else { Duration::from_secs(120) }; - let url = format!("{}/media/upload", self.relay_url); + let url = format!("{}/media", self.relay_url); let req = self .http .put(&url) diff --git a/crates/buzz-media/Cargo.toml b/crates/buzz-media/Cargo.toml index aee5fdbdf..73362c7be 100644 --- a/crates/buzz-media/Cargo.toml +++ b/crates/buzz-media/Cargo.toml @@ -31,7 +31,9 @@ tempfile = "3" tokio-util = { version = "0.7", features = ["io"] } futures-util = "0.3" futures-core = "0.3" +metrics = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["test-util"] } uuid = { workspace = true } +base64 = "0.22" diff --git a/crates/buzz-media/src/auth.rs b/crates/buzz-media/src/auth.rs index c6fff2be4..17bbeaad6 100644 --- a/crates/buzz-media/src/auth.rs +++ b/crates/buzz-media/src/auth.rs @@ -6,6 +6,7 @@ use crate::error::MediaError; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BlossomVerb { Upload, + Media, Get, } @@ -13,6 +14,7 @@ impl BlossomVerb { fn as_str(self) -> &'static str { match self { Self::Upload => "upload", + Self::Media => "media", Self::Get => "get", } } @@ -198,6 +200,29 @@ pub fn verify_blossom_upload_auth( Ok(()) } +/// Verify a BUD-11 authorization for the transforming `PUT /media` route. +pub fn verify_blossom_media_auth( + auth_event: &nostr::Event, + sha256: &str, + server_domain: Option<&str>, + max_age_secs: u64, +) -> Result<(), MediaError> { + verify_blossom_auth_event_for_verb( + auth_event, + BlossomVerb::Media, + server_domain, + max_age_secs, + )?; + let has_matching_x = auth_event + .tags + .iter() + .any(|tag| tag.kind().to_string() == "x" && tag.content() == Some(sha256)); + if !has_matching_x { + return Err(MediaError::HashMismatch); + } + Ok(()) +} + /// Verify a kind:24242 Blossom get auth event for one requested blob. /// /// BUD-01 permits either blob-scoped authorization (`x` tag matches `sha256`) @@ -273,6 +298,31 @@ mod tests { assert!(verify_blossom_auth_event(&event, None, 600).is_ok()); } + #[test] + fn test_media_verb_is_distinct_and_hash_bound() { + let keys = Keys::generate(); + let sha256 = "a".repeat(64); + let now = Timestamp::now().as_secs(); + let event = EventBuilder::new(Kind::from(24242), "Process media") + .tags(vec![ + Tag::parse(["t", "media"]).unwrap(), + Tag::parse(["x", &sha256]).unwrap(), + Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + assert!(verify_blossom_media_auth(&event, &sha256, None, 600).is_ok()); + assert!(matches!( + verify_blossom_upload_auth(&event, &sha256, None, 600), + Err(MediaError::InvalidAuthVerb) + )); + assert!(matches!( + verify_blossom_media_auth(&event, &"b".repeat(64), None, 600), + Err(MediaError::HashMismatch) + )); + } + fn build_get_auth(keys: &Keys, tags: Vec) -> nostr::Event { EventBuilder::new(Kind::from(24242), "Get buzz-media") .tags(tags) diff --git a/crates/buzz-media/src/config.rs b/crates/buzz-media/src/config.rs index 047c08475..e36265a56 100644 --- a/crates/buzz-media/src/config.rs +++ b/crates/buzz-media/src/config.rs @@ -4,6 +4,30 @@ fn default_max_video_bytes() -> u64 { 524_288_000 // 500 MB } +fn default_max_audio_bytes() -> u64 { + 104_857_600 // 100 MB +} + +fn default_exiftool_path() -> String { + "exiftool".to_string() +} + +fn default_ffmpeg_path() -> String { + "ffmpeg".to_string() +} + +fn default_ffprobe_path() -> String { + "ffprobe".to_string() +} + +fn default_image_process_timeout_secs() -> u64 { + 120 +} + +fn default_av_process_timeout_secs() -> u64 { + 600 +} + fn default_max_file_bytes() -> u64 { 104_857_600 // 100 MB } @@ -38,9 +62,27 @@ pub struct MediaConfig { /// Maximum upload size for video files (bytes). Default: 500 MB. #[serde(default = "default_max_video_bytes")] pub max_video_bytes: u64, + /// Maximum upload size for audio files (bytes). Default: 100 MB. + #[serde(default = "default_max_audio_bytes")] + pub max_audio_bytes: u64, /// Maximum upload size for generic (non-image, non-video) files (bytes). Default: 100 MB. #[serde(default = "default_max_file_bytes")] pub max_file_bytes: u64, + /// ExifTool executable used for metadata deletion and verification. + #[serde(default = "default_exiftool_path")] + pub exiftool_path: String, + /// FFmpeg executable used for media remuxing and normalization. + #[serde(default = "default_ffmpeg_path")] + pub ffmpeg_path: String, + /// ffprobe executable used for content-derived media classification. + #[serde(default = "default_ffprobe_path")] + pub ffprobe_path: String, + /// Maximum image sanitizer runtime. + #[serde(default = "default_image_process_timeout_secs")] + pub image_process_timeout_secs: u64, + /// Maximum audio/video sanitizer runtime. + #[serde(default = "default_av_process_timeout_secs")] + pub av_process_timeout_secs: u64, /// Public base URL for media URLs in BlobDescriptor (must include `/media` path). pub public_base_url: String, /// Whether to write per-upload-event records under `_uploads/` @@ -85,9 +127,24 @@ impl MediaConfig { if self.max_video_bytes == 0 { return Err("max_video_bytes must be > 0".to_string()); } + if self.max_audio_bytes == 0 { + return Err("max_audio_bytes must be > 0".to_string()); + } if self.max_file_bytes == 0 { return Err("max_file_bytes must be > 0".to_string()); } + for (name, value) in [ + ("exiftool_path", &self.exiftool_path), + ("ffmpeg_path", &self.ffmpeg_path), + ("ffprobe_path", &self.ffprobe_path), + ] { + if value.trim().is_empty() { + return Err(format!("{name} must not be empty")); + } + } + if self.image_process_timeout_secs == 0 || self.av_process_timeout_secs == 0 { + return Err("media process timeouts must be > 0".to_string()); + } // Fail startup on incoherent collection config instead of silently // recording nothing — an operator who set an IP header believes they // are meeting a reporting obligation. @@ -135,7 +192,13 @@ mod tests { max_image_bytes: 1, max_gif_bytes: 1, max_video_bytes: 1, + max_audio_bytes: 1, max_file_bytes: 1, + exiftool_path: "exiftool".to_string(), + ffmpeg_path: "ffmpeg".to_string(), + ffprobe_path: "ffprobe".to_string(), + image_process_timeout_secs: 120, + av_process_timeout_secs: 600, public_base_url: "http://localhost:3000/media".to_string(), upload_records_enabled: false, upload_ip_header: None, diff --git a/crates/buzz-media/src/error.rs b/crates/buzz-media/src/error.rs index b9aff34ee..59794cf12 100644 --- a/crates/buzz-media/src/error.rs +++ b/crates/buzz-media/src/error.rs @@ -78,6 +78,18 @@ pub enum MediaError { /// MP4 metadata could not be parsed. #[error("invalid video data")] InvalidVideo, + /// A recognized media format is outside the sanitizer allowlist. + #[error("unsupported media format: {0}")] + UnsupportedMedia(String), + /// Media could not be sanitized or failed post-processing verification. + #[error("media sanitization failed")] + SanitizationFailed, + /// Sanitizer completed but forbidden descriptive metadata remained. + #[error("forbidden metadata remained after sanitization")] + ResidualMetadata, + /// Required media processing tools are missing or unusable. + #[error("media processing tools unavailable")] + ToolUnavailable, /// I/O error during streaming upload. #[error("io error: {0}")] Io(String), @@ -140,15 +152,26 @@ impl IntoResponse for MediaError { Self::UploadRateLimitExceeded | Self::UploadConcurrencyLimitReached => { (StatusCode::TOO_MANY_REQUESTS, self.to_string()) } - Self::UnsupportedContainer => (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()), + Self::UnsupportedContainer | Self::UnsupportedMedia(_) => { + (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()) + } Self::WrongCodec | Self::DurationTooLong | Self::ResolutionTooHigh | Self::MoovNotAtFront - | Self::InvalidVideo => (StatusCode::BAD_REQUEST, self.to_string()), + | Self::InvalidVideo + | Self::SanitizationFailed + | Self::ResidualMetadata => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()), Self::UnknownContentType | Self::InvalidImage => { (StatusCode::BAD_REQUEST, self.to_string()) } + Self::ToolUnavailable => { + tracing::error!(error = %self, "media processing tool unavailable"); + ( + StatusCode::SERVICE_UNAVAILABLE, + "media processing unavailable".into(), + ) + } Self::Io(_) | Self::StorageError(_) | Self::Internal => { tracing::error!(error = %self, "media storage error"); (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()) diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index e62972d58..d5d4088d4 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -5,6 +5,7 @@ pub mod auth; pub mod config; pub mod error; +pub mod sanitize; pub mod storage; pub mod thumbnail; pub mod types; @@ -17,6 +18,7 @@ pub use error::MediaError; pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage}; pub use types::BlobDescriptor; pub use upload::{process_file_upload, process_upload, process_video_upload}; +pub use upload::{process_streaming_ingest, StreamingIngestInput, UploadRouteMode}; pub use upload_record::{ parse_port, parse_public_ip, upload_record_key, UploadAttribution, UploadNetworkInfo, UploadRecord, UPLOAD_RECORD_VERSION, diff --git a/crates/buzz-media/src/sanitize.rs b/crates/buzz-media/src/sanitize.rs new file mode 100644 index 000000000..e0d79ec77 --- /dev/null +++ b/crates/buzz-media/src/sanitize.rs @@ -0,0 +1,1068 @@ +//! Fail-closed media classification, metadata removal, and output verification. +//! +//! Authentication is deliberately outside this module: the caller authenticates +//! the hash of the source bytes, while this module returns a new artifact whose +//! hash becomes the public content-addressed identifier. + +use std::path::Path; +use std::process::Stdio; +use std::sync::OnceLock; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tempfile::{Builder, NamedTempFile}; +use tokio::io::AsyncReadExt; +use tokio::process::Command; + +use crate::{MediaConfig, MediaError}; + +const MAX_TOOL_OUTPUT: usize = 1024 * 1024; +const MAX_IMAGE_PIXELS: u64 = 25_000_000; +const MAX_ANIMATION_FRAMES: u64 = 1_000; +const MAX_ANIMATION_PIXELS: u64 = 250_000_000; +const MAX_VIDEO_DURATION_SECS: f64 = 600.0; +const MAX_VIDEO_WIDTH: u32 = 3_840; +const MAX_VIDEO_HEIGHT: u32 = 2_160; + +static TOOL_VERSIONS: OnceLock = OnceLock::new(); + +/// Sanitizer binary versions captured by the startup capability check. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolVersions { + /// Full first version line reported by ExifTool. + pub exiftool: String, + /// Full first version line reported by FFmpeg. + pub ffmpeg: String, + /// Full first version line reported by ffprobe. + pub ffprobe: String, +} + +/// Return the startup-verified sanitizer versions for private audit records. +pub fn tool_versions() -> Option<&'static ToolVersions> { + TOOL_VERSIONS.get() +} + +/// High-level class used for route enforcement and bounded metrics. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MediaClass { + Image, + Video, + Audio, +} + +impl MediaClass { + /// Stable, bounded metric label. + pub const fn as_str(self) -> &'static str { + match self { + Self::Image => "image", + Self::Video => "video", + Self::Audio => "audio", + } + } +} + +/// Content-derived media information. Request MIME types and filenames are +/// never used to construct this value. +#[derive(Debug, Clone)] +pub struct MediaProbe { + pub class: MediaClass, + pub mime: String, + pub ext: String, + pub video_codec: Option, + pub audio_codec: Option, + pub width: Option, + pub height: Option, + pub duration_secs: Option, + pub frame_count: Option, +} + +/// A verified artifact ready for content-addressed publication. +pub struct SanitizedMedia { + pub file: NamedTempFile, + pub probe: MediaProbe, +} + +/// Verify required executables at relay startup. This intentionally fails +/// closed: a deployment without its privacy controls must not accept uploads. +pub async fn validate_toolchain(config: &MediaConfig) -> Result<(), MediaError> { + let exiftool = successful_version(&config.exiftool_path, "-ver").await?; + let ffmpeg = successful_version(&config.ffmpeg_path, "-version").await?; + let ffprobe = successful_version(&config.ffprobe_path, "-version").await?; + let encoders = run_tool( + &config.ffmpeg_path, + &["-hide_banner", "-encoders"], + Duration::from_secs(15), + ) + .await?; + let encoders = String::from_utf8_lossy(&encoders.stdout); + for required in [ + "libx264", + "aac", + "libaom-av1", + "libvpx-vp9", + "libopus", + "libvorbis", + ] { + if !encoders.contains(required) { + return Err(MediaError::ToolUnavailable); + } + } + let decoders = run_tool( + &config.ffmpeg_path, + &["-hide_banner", "-decoders"], + Duration::from_secs(15), + ) + .await?; + let decoders = String::from_utf8_lossy(&decoders.stdout); + for required in [ + "h264", + "hevc", + "vp8", + "vp9", + "av1", + "aac", + "mp3", + "flac", + "vorbis", + "opus", + "pcm_s16le", + ] { + if !decoders.contains(required) { + return Err(MediaError::ToolUnavailable); + } + } + let _ = TOOL_VERSIONS.set(ToolVersions { + exiftool, + ffmpeg, + ffprobe, + }); + Ok(()) +} + +async fn successful_version(program: &str, arg: &str) -> Result { + let output = run_tool(program, &[arg], Duration::from_secs(15)).await?; + if !output.status.success() { + return Err(MediaError::ToolUnavailable); + } + String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .ok_or(MediaError::ToolUnavailable) +} + +/// Return `None` for a non-media attachment, a supported probe for accepted +/// media, or `UnsupportedMedia` when a parser recognizes media outside the +/// compliance allowlist. +pub async fn probe_media( + path: &Path, + sniff: &[u8], + config: &MediaConfig, +) -> Result, MediaError> { + if let Some((mime, ext)) = iso_bmff_still_image(sniff) { + let mut probe = match probe_with_ffprobe(path, config).await { + Ok(probe) => probe, + Err(_) => { + let (width, height, frame_count) = exiftool_image_dimensions(path, config).await?; + MediaProbe { + class: MediaClass::Image, + mime: mime.to_string(), + ext: ext.to_string(), + video_codec: None, + audio_codec: None, + width: Some(width), + height: Some(height), + duration_secs: None, + frame_count, + } + } + }; + probe.class = MediaClass::Image; + probe.mime = mime.to_string(); + probe.ext = ext.to_string(); + validate_media_limits(&probe)?; + return Ok(Some(probe)); + } + let mut recognized_media = false; + if let Some(kind) = infer::get(sniff) { + if let Some((mime, ext)) = supported_image(kind.mime_type()) { + let mut probe = match probe_with_ffprobe(path, config).await { + Ok(probe) => probe, + Err(_) => { + let (width, height) = image_dimensions(path)?; + MediaProbe { + class: MediaClass::Image, + mime: mime.to_string(), + ext: ext.to_string(), + video_codec: None, + audio_codec: None, + width: Some(width), + height: Some(height), + duration_secs: None, + frame_count: None, + } + } + }; + probe.class = MediaClass::Image; + probe.mime = mime.to_string(); + probe.ext = ext.to_string(); + validate_media_limits(&probe)?; + return Ok(Some(probe)); + } + if kind.mime_type().starts_with("image/") { + return Err(MediaError::UnsupportedMedia(kind.mime_type().to_string())); + } + recognized_media = + kind.mime_type().starts_with("video/") || kind.mime_type().starts_with("audio/"); + } + + let probe = match probe_with_ffprobe(path, config).await { + Ok(probe) => probe, + Err(MediaError::SanitizationFailed) if !recognized_media => return Ok(None), + Err(error) => return Err(error), + }; + match probe.class { + MediaClass::Video => { + if !matches!(probe.ext.as_str(), "mp4" | "mov" | "webm" | "mkv") { + return Err(MediaError::UnsupportedMedia(probe.mime)); + } + } + MediaClass::Audio => { + if !matches!( + probe.ext.as_str(), + "mp3" | "m4a" | "aac" | "flac" | "wav" | "ogg" | "opus" + ) { + return Err(MediaError::UnsupportedMedia(probe.mime)); + } + } + MediaClass::Image => {} + } + validate_media_limits(&probe)?; + Ok(Some(probe)) +} + +fn image_dimensions(path: &Path) -> Result<(u32, u32), MediaError> { + let dimensions = imagesize::size(path).map_err(|_| MediaError::SanitizationFailed)?; + let width = u32::try_from(dimensions.width).map_err(|_| MediaError::ImageTooLarge)?; + let height = u32::try_from(dimensions.height).map_err(|_| MediaError::ImageTooLarge)?; + if width == 0 || height == 0 { + return Err(MediaError::SanitizationFailed); + } + Ok((width, height)) +} + +async fn exiftool_image_dimensions( + path: &Path, + config: &MediaConfig, +) -> Result<(u32, u32, Option), MediaError> { + let args = [ + "-j".to_string(), + "-n".to_string(), + "-ImageWidth".to_string(), + "-ImageHeight".to_string(), + "-FrameCount".to_string(), + "-ImageCount".to_string(), + path_string(path), + ]; + let output = run_tool( + &config.exiftool_path, + &args.iter().map(String::as_str).collect::>(), + Duration::from_secs(config.image_process_timeout_secs), + ) + .await?; + if !output.status.success() { + return Err(MediaError::SanitizationFailed); + } + let documents: Vec = + serde_json::from_slice(&output.stdout).map_err(|_| MediaError::SanitizationFailed)?; + let document = documents + .first() + .and_then(Value::as_object) + .ok_or(MediaError::SanitizationFailed)?; + let parse_u64 = |name: &str| { + document.get(name).and_then(|value| { + value + .as_u64() + .or_else(|| value.as_str().and_then(|text| text.parse().ok())) + }) + }; + let width = parse_u64("ImageWidth") + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or(MediaError::SanitizationFailed)?; + let height = parse_u64("ImageHeight") + .and_then(|value| u32::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or(MediaError::SanitizationFailed)?; + let frame_count = parse_u64("FrameCount").or_else(|| parse_u64("ImageCount")); + Ok((width, height, frame_count)) +} + +/// Remove metadata and return a separately verified output artifact. +pub async fn sanitize( + source: &Path, + source_probe: &MediaProbe, + config: &MediaConfig, +) -> Result { + let (file, expected_class) = match source_probe.class { + MediaClass::Image => ( + sanitize_image(source, source_probe, config).await?, + MediaClass::Image, + ), + MediaClass::Video => ( + sanitize_video(source, source_probe, config).await?, + MediaClass::Video, + ), + MediaClass::Audio => ( + sanitize_audio(source, source_probe, config).await?, + MediaClass::Audio, + ), + }; + + verify_forbidden_metadata(file.path(), config).await?; + let sniff = read_sniff(file.path()).await?; + let output_probe = probe_media(file.path(), &sniff, config) + .await? + .ok_or(MediaError::SanitizationFailed)?; + if output_probe.class != expected_class { + return Err(MediaError::SanitizationFailed); + } + verify_stream_shape(&output_probe, file.path(), config).await?; + Ok(SanitizedMedia { + file, + probe: output_probe, + }) +} + +async fn sanitize_image( + source: &Path, + probe: &MediaProbe, + config: &MediaConfig, +) -> Result { + if probe.ext == "bmp" { + let output = temp_with_suffix(".png")?; + let args = vec![ + "-nostdin".to_string(), + "-v".to_string(), + "error".to_string(), + "-y".to_string(), + "-i".to_string(), + path_string(source), + "-map".to_string(), + "0:v:0".to_string(), + "-map_metadata".to_string(), + "-1".to_string(), + "-frames:v".to_string(), + "1".to_string(), + path_string(output.path()), + ]; + require_success( + &config.ffmpeg_path, + &args, + Duration::from_secs(config.image_process_timeout_secs), + ) + .await?; + return Ok(output); + } + + if probe.ext == "tiff" { + let output = temp_with_suffix(".tiff")?; + let args = vec![ + "-nostdin".to_string(), + "-v".to_string(), + "error".to_string(), + "-y".to_string(), + "-i".to_string(), + path_string(source), + "-map".to_string(), + "0:v:0".to_string(), + "-map_metadata".to_string(), + "-1".to_string(), + "-frames:v".to_string(), + "1".to_string(), + "-c:v".to_string(), + "tiff".to_string(), + path_string(output.path()), + ]; + require_success( + &config.ffmpeg_path, + &args, + Duration::from_secs(config.image_process_timeout_secs), + ) + .await?; + let args = [ + "-overwrite_original".to_string(), + "-tagsFromFile".to_string(), + path_string(source), + "-ICC_Profile:All".to_string(), + "-ColorSpaceTags".to_string(), + "-Orientation".to_string(), + "-Software=".to_string(), + path_string(output.path()), + ]; + require_success( + &config.exiftool_path, + &args, + Duration::from_secs(config.image_process_timeout_secs), + ) + .await?; + return Ok(output); + } + + let output = temp_with_suffix(&format!(".{}", probe.ext))?; + tokio::fs::copy(source, output.path()) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + // Delete everything, excluding the ICC block from deletion, then copy back + // only rendering-critical color-space and orientation tags from the file. + let args = [ + "-overwrite_original".to_string(), + "-all=".to_string(), + "--ICC_Profile:All".to_string(), + "-tagsFromFile".to_string(), + "@".to_string(), + "-ColorSpaceTags".to_string(), + "-Orientation".to_string(), + path_string(output.path()), + ]; + require_success( + &config.exiftool_path, + &args, + Duration::from_secs(config.image_process_timeout_secs), + ) + .await?; + Ok(output) +} + +async fn sanitize_video( + source: &Path, + probe: &MediaProbe, + config: &MediaConfig, +) -> Result { + let output = temp_with_suffix(".mp4")?; + let can_copy = probe.video_codec.as_deref() == Some("h264") + && matches!(probe.audio_codec.as_deref(), None | Some("aac")); + let mut args = common_ffmpeg_input(source); + args.extend(strings(&[ + "-map", + "0:v:0", + "-map", + "0:a:0?", + "-map_metadata", + "-1", + "-map_metadata:s", + "-1", + "-map_metadata:c", + "-1", + "-map_metadata:p", + "-1", + "-map_chapters", + "-1", + "-sn", + "-dn", + ])); + if can_copy { + args.extend(strings(&["-c:v", "copy", "-c:a", "copy"])); + } else { + args.extend(strings(&[ + "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-c:a", "aac", "-b:a", "192k", + ])); + } + args.extend(strings(&[ + "-movflags", + "+faststart", + "-metadata", + "encoder=", + ])); + args.push(path_string(output.path())); + require_success( + &config.ffmpeg_path, + &args, + Duration::from_secs(config.av_process_timeout_secs), + ) + .await?; + Ok(output) +} + +async fn sanitize_audio( + source: &Path, + probe: &MediaProbe, + config: &MediaConfig, +) -> Result { + let (suffix, format) = match probe.ext.as_str() { + "mp3" => (".mp3", "mp3"), + "m4a" => (".m4a", "mp4"), + "aac" => (".aac", "adts"), + "flac" => (".flac", "flac"), + "wav" => (".wav", "wav"), + "ogg" => (".ogg", "ogg"), + "opus" => (".opus", "opus"), + _ => return Err(MediaError::UnsupportedMedia(probe.mime.clone())), + }; + let output = temp_with_suffix(suffix)?; + let mut args = common_ffmpeg_input(source); + args.extend(strings(&[ + "-map", + "0:a:0", + "-map_metadata", + "-1", + "-map_metadata:s", + "-1", + "-map_metadata:c", + "-1", + "-map_metadata:p", + "-1", + "-map_chapters", + "-1", + "-vn", + "-sn", + "-dn", + "-c:a", + "copy", + "-metadata", + "encoder=", + "-f", + format, + ])); + if probe.ext == "mp3" { + args.extend(strings(&["-id3v2_version", "0", "-write_id3v1", "0"])); + } + if probe.ext == "wav" { + args.extend(strings(&["-fflags", "+bitexact", "-flags:a", "+bitexact"])); + } + args.push(path_string(output.path())); + require_success( + &config.ffmpeg_path, + &args, + Duration::from_secs(config.av_process_timeout_secs), + ) + .await?; + Ok(output) +} + +async fn verify_forbidden_metadata(path: &Path, config: &MediaConfig) -> Result<(), MediaError> { + let selectors = [ + "-GPS*", + "-Location*", + "-*Latitude*", + "-*Longitude*", + "-*Altitude*", + "-MakerNotes:All", + "-Make", + "-Model", + "-*SerialNumber*", + "-OwnerName", + "-Artist", + "-Author", + "-Creator", + "-Comment", + "-Description", + "-Software", + "-DateTimeOriginal", + "-CreateDate", + "-ModifyDate", + "-MediaCreateDate", + "-TrackCreateDate", + "-XMPToolkit", + "-ThumbnailImage", + "-PreviewImage", + "-History*", + "-DocumentID", + "-InstanceID", + "-Lens*", + "-Camera*", + "-Copyright", + "-Title", + "-Keywords", + "-Subject", + "-UserDefinedText", + ]; + let mut args = strings(&["-api", "LargeFileSupport=1", "-ee", "-j", "-G1", "-s"]); + args.extend(selectors.iter().map(|value| (*value).to_string())); + args.push(path_string(path)); + let output = run_tool( + &config.exiftool_path, + &args.iter().map(String::as_str).collect::>(), + Duration::from_secs(config.av_process_timeout_secs), + ) + .await?; + if !output.status.success() { + return Err(MediaError::SanitizationFailed); + } + let documents: Vec = + serde_json::from_slice(&output.stdout).map_err(|_| MediaError::SanitizationFailed)?; + let has_forbidden = documents.iter().any(|document| { + document + .as_object() + .map(|object| { + object.iter().any(|(key, value)| { + if key.ends_with("SourceFile") { + return false; + } + let is_date = key.to_ascii_lowercase().contains("date"); + let is_zero_date = value.as_str().is_some_and(|value| { + value.starts_with("0000:00:00") || value.starts_with("1904:01:01") + }); + !is_date || !is_zero_date + }) + }) + .unwrap_or(true) + }); + if has_forbidden { + return Err(MediaError::ResidualMetadata); + } + Ok(()) +} + +async fn verify_stream_shape( + probe: &MediaProbe, + path: &Path, + config: &MediaConfig, +) -> Result<(), MediaError> { + // Image structure and geometry/frame limits were already checked by + // `probe_media`. HEIC/HEIF support is supplied by ExifTool on deployments + // where FFmpeg cannot expose these still-image containers as streams. + if probe.class == MediaClass::Image { + return Ok(()); + } + let json = ffprobe_json(path, config).await?; + let streams = json["streams"] + .as_array() + .ok_or(MediaError::SanitizationFailed)?; + let video_count = streams + .iter() + .filter(|stream| stream["codec_type"] == "video") + .count(); + let audio_count = streams + .iter() + .filter(|stream| stream["codec_type"] == "audio") + .count(); + let unexpected = streams.iter().any(|stream| { + !matches!(stream["codec_type"].as_str(), Some("video" | "audio")) + || stream + .get("tags") + .and_then(Value::as_object) + .is_some_and(|tags| tags.keys().any(|key| !is_structural_tag(key))) + }); + let valid = match probe.class { + MediaClass::Image => video_count == 1 && audio_count == 0, + MediaClass::Video => video_count == 1 && audio_count <= 1, + MediaClass::Audio => video_count == 0 && audio_count == 1, + }; + if unexpected || !valid { + return Err(MediaError::ResidualMetadata); + } + Ok(()) +} + +fn is_structural_tag(tag: &str) -> bool { + matches!( + tag.to_ascii_lowercase().as_str(), + "language" | "handler_name" | "vendor_id" | "encoder" + ) +} + +async fn probe_with_ffprobe(path: &Path, config: &MediaConfig) -> Result { + let json = ffprobe_json(path, config).await?; + let streams = json["streams"] + .as_array() + .ok_or(MediaError::SanitizationFailed)?; + let video = streams + .iter() + .find(|stream| stream["codec_type"] == "video"); + let audio = streams + .iter() + .find(|stream| stream["codec_type"] == "audio"); + let format_name = json["format"]["format_name"].as_str().unwrap_or_default(); + let video_codec = video + .and_then(|stream| stream["codec_name"].as_str()) + .map(str::to_string); + let audio_codec = audio + .and_then(|stream| stream["codec_name"].as_str()) + .map(str::to_string); + + let (class, mime, ext) = if video.is_some() { + if is_still_image_format(format_name) { + let (mime, ext) = image_format(format_name, video_codec.as_deref())?; + (MediaClass::Image, mime, ext) + } else { + let (mime, ext) = video_format(format_name)?; + (MediaClass::Video, mime, ext) + } + } else if audio.is_some() { + let (mime, ext) = audio_format(format_name, audio_codec.as_deref())?; + (MediaClass::Audio, mime, ext) + } else { + return Err(MediaError::SanitizationFailed); + }; + + let width = video + .and_then(|stream| stream["width"].as_u64()) + .and_then(|value| u32::try_from(value).ok()); + let height = video + .and_then(|stream| stream["height"].as_u64()) + .and_then(|value| u32::try_from(value).ok()); + let duration_secs = json["format"]["duration"] + .as_str() + .and_then(|value| value.parse().ok()) + .or_else(|| { + video + .or(audio) + .and_then(|stream| stream["duration"].as_str()) + .and_then(|value| value.parse().ok()) + }); + let frame_count = video + .and_then(|stream| stream["nb_frames"].as_str()) + .and_then(|value| value.parse().ok()) + .or_else(|| { + video + .and_then(|stream| stream["nb_read_frames"].as_str()) + .and_then(|value| value.parse().ok()) + }); + Ok(MediaProbe { + class, + mime: mime.to_string(), + ext: ext.to_string(), + video_codec, + audio_codec, + width, + height, + duration_secs, + frame_count, + }) +} + +async fn ffprobe_json(path: &Path, config: &MediaConfig) -> Result { + let args = [ + "-v".to_string(), + "error".to_string(), + "-count_frames".to_string(), + "-show_streams".to_string(), + "-show_format".to_string(), + "-of".to_string(), + "json".to_string(), + path_string(path), + ]; + let output = run_tool( + &config.ffprobe_path, + &args.iter().map(String::as_str).collect::>(), + Duration::from_secs(config.av_process_timeout_secs), + ) + .await?; + if !output.status.success() || output.stdout.is_empty() { + return Err(MediaError::SanitizationFailed); + } + serde_json::from_slice(&output.stdout).map_err(|_| MediaError::SanitizationFailed) +} + +fn validate_media_limits(probe: &MediaProbe) -> Result<(), MediaError> { + match probe.class { + MediaClass::Image => { + if let (Some(width), Some(height)) = (probe.width, probe.height) { + let pixels = u64::from(width) * u64::from(height); + if pixels > MAX_IMAGE_PIXELS { + return Err(MediaError::ImageTooLarge); + } + if probe.frame_count.is_some_and(|frames| { + frames > MAX_ANIMATION_FRAMES + || pixels.saturating_mul(frames) > MAX_ANIMATION_PIXELS + }) { + return Err(MediaError::ImageTooLarge); + } + } + } + MediaClass::Video => { + if probe + .duration_secs + .is_none_or(|duration| duration <= 0.0 || duration > MAX_VIDEO_DURATION_SECS) + { + return Err(MediaError::DurationTooLong); + } + if probe.width.is_none_or(|width| width > MAX_VIDEO_WIDTH) + || probe.height.is_none_or(|height| height > MAX_VIDEO_HEIGHT) + { + return Err(MediaError::ResolutionTooHigh); + } + } + MediaClass::Audio => {} + } + Ok(()) +} + +fn iso_bmff_still_image(bytes: &[u8]) -> Option<(&'static str, &'static str)> { + if bytes.len() < 12 || &bytes[4..8] != b"ftyp" { + return None; + } + let upper = bytes.len().min(64); + (8..upper.saturating_sub(3)) + .step_by(4) + .find_map(|offset| match &bytes[offset..offset + 4] { + b"avif" | b"avis" => Some(("image/avif", "avif")), + b"heic" | b"heix" | b"hevc" | b"hevx" | b"heim" | b"heis" | b"mif1" | b"msf1" => { + Some(("image/heic", "heic")) + } + _ => None, + }) +} + +fn supported_image(mime: &str) -> Option<(&'static str, &'static str)> { + match mime { + "image/jpeg" => Some(("image/jpeg", "jpg")), + "image/png" => Some(("image/png", "png")), + "image/gif" => Some(("image/gif", "gif")), + "image/webp" => Some(("image/webp", "webp")), + "image/tiff" => Some(("image/tiff", "tiff")), + "image/bmp" | "image/x-ms-bmp" => Some(("image/bmp", "bmp")), + "image/heic" | "image/heif" => Some(("image/heic", "heic")), + "image/avif" => Some(("image/avif", "avif")), + _ => None, + } +} + +fn is_still_image_format(format: &str) -> bool { + format.split(',').any(|name| { + matches!( + name, + "image2" + | "jpeg_pipe" + | "png_pipe" + | "gif" + | "webp_pipe" + | "tiff_pipe" + | "bmp_pipe" + | "avif" + | "heif" + ) + }) +} + +fn image_format( + format: &str, + codec: Option<&str>, +) -> Result<(&'static str, &'static str), MediaError> { + if let Some(pair) = codec.and_then(|codec| match codec { + "mjpeg" => Some(("image/jpeg", "jpg")), + "png" => Some(("image/png", "png")), + "gif" => Some(("image/gif", "gif")), + "webp" => Some(("image/webp", "webp")), + "tiff" => Some(("image/tiff", "tiff")), + "bmp" => Some(("image/bmp", "bmp")), + "av1" => Some(("image/avif", "avif")), + _ => None, + }) { + return Ok(pair); + } + Err(MediaError::UnsupportedMedia(format.to_string())) +} + +fn video_format(format: &str) -> Result<(&'static str, &'static str), MediaError> { + if format.contains("matroska") || format.contains("webm") { + if format.contains("webm") { + Ok(("video/webm", "webm")) + } else { + Ok(("video/x-matroska", "mkv")) + } + } else if format.contains("mov") || format.contains("mp4") { + Ok(("video/mp4", "mp4")) + } else { + Err(MediaError::UnsupportedMedia(format.to_string())) + } +} + +fn audio_format( + format: &str, + codec: Option<&str>, +) -> Result<(&'static str, &'static str), MediaError> { + if format.contains("mp3") { + Ok(("audio/mpeg", "mp3")) + } else if format.contains("mov") || format.contains("mp4") { + Ok(("audio/mp4", "m4a")) + } else if format.contains("aac") { + Ok(("audio/aac", "aac")) + } else if format.contains("flac") { + Ok(("audio/flac", "flac")) + } else if format.contains("wav") { + Ok(("audio/wav", "wav")) + } else if format.contains("ogg") { + if codec == Some("opus") { + Ok(("audio/opus", "opus")) + } else { + Ok(("audio/ogg", "ogg")) + } + } else { + Err(MediaError::UnsupportedMedia(format.to_string())) + } +} + +fn common_ffmpeg_input(source: &Path) -> Vec { + let mut args = strings(&["-nostdin", "-v", "error", "-y", "-i"]); + args.push(path_string(source)); + args +} + +async fn require_success( + program: &str, + args: &[String], + timeout: Duration, +) -> Result<(), MediaError> { + let output = run_tool( + program, + &args.iter().map(String::as_str).collect::>(), + timeout, + ) + .await?; + if output.status.success() { + Ok(()) + } else { + Err(MediaError::SanitizationFailed) + } +} + +async fn run_tool( + program: &str, + args: &[&str], + timeout: Duration, +) -> Result { + let mut command = Command::new(program); + command + .args(args) + .env_clear() + .env("LC_ALL", "C") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if let Some(path) = std::env::var_os("PATH") { + command.env("PATH", path); + } + let mut child = command.spawn().map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + MediaError::ToolUnavailable + } else { + MediaError::Io(error.to_string()) + } + })?; + let stdout = child.stdout.take().ok_or(MediaError::Internal)?; + let stderr = child.stderr.take().ok_or(MediaError::Internal)?; + let stdout_task = tokio::spawn(read_bounded_output(stdout)); + let stderr_task = tokio::spawn(read_bounded_output(stderr)); + let status = match tokio::time::timeout(timeout, child.wait()).await { + Ok(result) => result.map_err(|error| MediaError::Io(error.to_string()))?, + Err(_) => { + let _ = child.kill().await; + let _ = child.wait().await; + let _ = stdout_task.await; + let _ = stderr_task.await; + return Err(MediaError::SanitizationFailed); + } + }; + let stdout = stdout_task.await.map_err(|_| MediaError::Internal)??; + let stderr = stderr_task.await.map_err(|_| MediaError::Internal)??; + if stdout.len() > MAX_TOOL_OUTPUT || stderr.len() > MAX_TOOL_OUTPUT { + return Err(MediaError::SanitizationFailed); + } + Ok(std::process::Output { + status, + stdout, + stderr, + }) +} + +async fn read_bounded_output( + reader: impl tokio::io::AsyncRead + Unpin, +) -> Result, MediaError> { + let mut bytes = Vec::new(); + reader + .take((MAX_TOOL_OUTPUT + 1) as u64) + .read_to_end(&mut bytes) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + Ok(bytes) +} + +async fn read_sniff(path: &Path) -> Result, MediaError> { + use tokio::io::AsyncReadExt; + let mut file = tokio::fs::File::open(path) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + let mut bytes = vec![0_u8; 4096]; + let read = file + .read(&mut bytes) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + bytes.truncate(read); + Ok(bytes) +} + +fn temp_with_suffix(suffix: &str) -> Result { + Builder::new() + .prefix("buzz-sanitized-") + .suffix(suffix) + .tempfile() + .map_err(|error| MediaError::Io(error.to_string())) +} + +fn path_string(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supported_format_tables_are_bounded() { + assert_eq!(supported_image("image/jpeg"), Some(("image/jpeg", "jpg"))); + assert!(supported_image("image/svg+xml").is_none()); + assert_eq!(audio_format("ogg", Some("opus")).unwrap().1, "opus"); + assert!(video_format("avi").is_err()); + assert_eq!( + iso_bmff_still_image(b"\0\0\0\x18ftypheic\0\0\0\0heic"), + Some(("image/heic", "heic")) + ); + } + + #[test] + fn structural_tag_allowlist_is_narrow() { + assert!(is_structural_tag("language")); + assert!(!is_structural_tag("location")); + assert!(!is_structural_tag("title")); + } + + #[test] + fn video_and_animation_limits_fail_closed() { + let mut probe = MediaProbe { + class: MediaClass::Video, + mime: "video/mp4".to_string(), + ext: "mp4".to_string(), + video_codec: Some("h264".to_string()), + audio_codec: Some("aac".to_string()), + width: Some(3840), + height: Some(2160), + duration_secs: Some(600.0), + frame_count: Some(18_000), + }; + assert!(validate_media_limits(&probe).is_ok()); + probe.duration_secs = Some(600.1); + assert!(matches!( + validate_media_limits(&probe), + Err(MediaError::DurationTooLong) + )); + probe.class = MediaClass::Image; + probe.width = Some(1_000); + probe.height = Some(1_000); + probe.duration_secs = None; + probe.frame_count = Some(1_001); + assert!(matches!( + validate_media_limits(&probe), + Err(MediaError::ImageTooLarge) + )); + } +} diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index a469826df..5d05e3068 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -254,7 +254,13 @@ mod tests { max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, + max_audio_bytes: 104_857_600, max_file_bytes: 104_857_600, + exiftool_path: "exiftool".to_string(), + ffmpeg_path: "ffmpeg".to_string(), + ffprobe_path: "ffprobe".to_string(), + image_process_timeout_secs: 120, + av_process_timeout_secs: 600, public_base_url: "http://localhost:3000/media".to_string(), upload_records_enabled: false, upload_ip_header: None, diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 32f8d09ba..ce8882826 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -5,7 +5,7 @@ use bytes::Bytes; use sha2::{Digest, Sha256}; use tokio::io::AsyncWriteExt; -use crate::auth::verify_blossom_upload_auth; +use crate::auth::{verify_blossom_media_auth, verify_blossom_upload_auth}; use crate::config::MediaConfig; use crate::error::MediaError; use crate::storage::{BlobMeta, MediaStorage}; @@ -16,6 +16,393 @@ use crate::validation::{ mime_to_ext, validate_content, validate_file_content, validate_video_file, }; +/// Upload route semantics. `Media` transforms recognized media, `Upload` +/// preserves exact non-media bytes, and `Legacy` keeps the historical route +/// while applying the same sanitizer whenever the body is recognized media. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UploadRouteMode { + Media, + Upload, + Legacy, +} + +/// Request-scoped dependencies and policy for a unified streaming upload. +pub struct StreamingIngestInput<'a> { + /// Content-addressed object storage. + pub storage: &'a MediaStorage, + /// Media limits and sanitizer executable configuration. + pub config: &'a MediaConfig, + /// Server-resolved tenant boundary for storage and authorization. + pub ctx: &'a TenantContext, + /// Verified Blossom authorization event for the source bytes. + pub auth_event: &'a nostr::Event, + /// Optional request content length for an early size rejection. + pub content_length: Option, + /// Optional moderation attribution recorded after durable publication. + pub attribution: Option, + /// Route policy controlling transformation versus exact-byte storage. + pub mode: UploadRouteMode, +} + +/// Unified streaming ingestion pipeline used by all public upload routes. +/// +/// The source hash is authenticated before any transformation. Only the final +/// sanitized artifact is hashed into the public storage key and descriptor. +pub async fn process_streaming_ingest( + input: StreamingIngestInput<'_>, + body_stream: impl futures_core::Stream> + Send + 'static, +) -> Result { + let StreamingIngestInput { + storage, + config, + ctx, + auth_event, + content_length, + attribution, + mode, + } = input; + let max_bytes = config + .max_image_bytes + .max(config.max_video_bytes) + .max(config.max_audio_bytes) + .max(config.max_file_bytes); + if content_length.is_some_and(|size| size > max_bytes) { + return Err(MediaError::FileTooLarge { + size: content_length.unwrap_or(max_bytes), + max: max_bytes, + }); + } + + let source = tempfile::Builder::new() + .prefix("buzz-source-") + .tempfile() + .map_err(|error| MediaError::Io(error.to_string()))?; + let source_path = source.path().to_path_buf(); + let (source_hash, source_size, sniff) = + stream_source(body_stream, &source_path, max_bytes).await?; + + let auth = auth_event.clone(); + let auth_hash = source_hash.clone(); + let bound_host = ctx.host().to_string(); + tokio::task::spawn_blocking(move || match mode { + UploadRouteMode::Media => { + verify_blossom_media_auth(&auth, &auth_hash, Some(&bound_host), 3600) + } + UploadRouteMode::Upload | UploadRouteMode::Legacy => { + verify_blossom_upload_auth(&auth, &auth_hash, Some(&bound_host), 3600) + } + }) + .await + .map_err(|_| MediaError::Internal)??; + + let source_probe = crate::sanitize::probe_media(&source_path, &sniff, config).await?; + let is_media = source_probe.is_some(); + match (mode, is_media) { + (UploadRouteMode::Media, false) => { + return Err(MediaError::UnsupportedMedia( + "non-media attachment".to_string(), + )); + } + (UploadRouteMode::Upload, true) => { + return Err(MediaError::UnsupportedMedia( + source_probe + .as_ref() + .map(|probe| probe.mime.clone()) + .unwrap_or_else(|| "media".to_string()), + )); + } + _ => {} + } + + enforce_source_size(source_probe.as_ref(), source_size, config)?; + + let sanitized = match source_probe.as_ref() { + Some(probe) => { + let started = std::time::Instant::now(); + let result = crate::sanitize::sanitize(&source_path, probe, config).await; + let outcome = match &result { + Ok(_) => "accepted", + Err(MediaError::ResidualMetadata) => "residual_metadata", + Err(MediaError::SanitizationFailed) => "sanitization_failed", + Err(MediaError::ImageTooLarge) => "limits", + Err(_) => "rejected", + }; + metrics::counter!( + "buzz_media_sanitization_total", + "class" => probe.class.as_str(), + "format" => probe.ext.clone(), + "outcome" => outcome + ) + .increment(1); + metrics::histogram!( + "buzz_media_sanitization_duration_seconds", + "class" => probe.class.as_str(), + "format" => probe.ext.clone(), + "outcome" => outcome + ) + .record(started.elapsed().as_secs_f64()); + if matches!(&result, Err(MediaError::ResidualMetadata)) { + metrics::counter!("buzz_media_residual_metadata_rejections_total").increment(1); + } + Some(result?) + } + None => None, + }; + let (artifact_path, mime, ext, class, duration, dim) = if let Some(artifact) = &sanitized { + let output_size = tokio::fs::metadata(artifact.file.path()) + .await + .map_err(|error| MediaError::Io(error.to_string()))? + .len(); + enforce_source_size(Some(&artifact.probe), output_size, config)?; + let dim = artifact + .probe + .width + .zip(artifact.probe.height) + .map(|(width, height)| format!("{width}x{height}")) + .unwrap_or_default(); + ( + artifact.file.path(), + artifact.probe.mime.clone(), + artifact.probe.ext.clone(), + Some(artifact.probe.class), + artifact.probe.duration_secs, + dim, + ) + } else { + let bytes = tokio::fs::read(&source_path) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + let (mime, ext) = validate_file_content(&bytes, config)?; + (source_path.as_path(), mime, ext, None, None, String::new()) + }; + + let (output_hash, output_size) = hash_file(artifact_path).await?; + let key = format!("{output_hash}.{ext}"); + let meta_key = MediaStorage::ctx_sidecar_key(ctx, &output_hash); + 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(ctx, &output_hash).await?; + if let Some(attribution) = &attribution { + record_upload_event( + storage, + ctx, + &auth_event.pubkey, + attribution, + UploadEventFacts { + sha256: &output_hash, + ext: &ext, + mime: &mime, + size: output_size, + source_sha256: Some(source_hash.as_str()), + source_size: Some(source_size), + source_mime: Some( + source_probe + .as_ref() + .map_or(mime.as_str(), |probe| probe.mime.as_str()), + ), + sanitization_policy: is_media.then_some(1), + tool_versions: is_media.then(crate::sanitize::tool_versions).flatten(), + uploaded_at: chrono::Utc::now().timestamp(), + }, + ) + .await?; + } + return Ok(build_descriptor( + config, + &output_hash, + &ext, + &mime, + output_size, + Some(&meta), + meta.uploaded_at, + )); + } + + let uploaded_at = chrono::Utc::now().timestamp(); + storage.put_file(&key, artifact_path, &mime).await?; + + let meta = if class == Some(crate::sanitize::MediaClass::Image) + && matches!( + mime.as_str(), + "image/jpeg" | "image/png" | "image/gif" | "image/webp" + ) { + let body = Bytes::from( + tokio::fs::read(artifact_path) + .await + .map_err(|error| MediaError::Io(error.to_string()))?, + ); + prepare_image_metadata( + storage, + config, + MetadataInput { + sha256: output_hash.clone(), + ext: ext.clone(), + mime: mime.clone(), + body, + uploaded_at, + }, + ) + .await? + } else { + BlobMeta { + dim, + blurhash: String::new(), + thumb_url: String::new(), + size: output_size, + ext: ext.clone(), + mime_type: mime.clone(), + uploaded_at, + duration_secs: duration, + } + }; + + if let Some(attribution) = &attribution { + record_upload_event( + storage, + ctx, + &auth_event.pubkey, + attribution, + UploadEventFacts { + sha256: &output_hash, + ext: &ext, + mime: &mime, + size: output_size, + source_sha256: Some(source_hash.as_str()), + source_size: Some(source_size), + source_mime: Some( + source_probe + .as_ref() + .map_or(mime.as_str(), |probe| probe.mime.as_str()), + ), + sanitization_policy: is_media.then_some(1), + tool_versions: is_media.then(crate::sanitize::tool_versions).flatten(), + uploaded_at, + }, + ) + .await?; + } + storage.put_sidecar(ctx, &output_hash, &meta).await?; + + metrics::counter!( + "buzz_media_ingest_total", + "class" => class.map(crate::sanitize::MediaClass::as_str).unwrap_or("file"), + "format" => ext.clone(), + "outcome" => "accepted" + ) + .increment(1); + metrics::histogram!("buzz_media_processing_input_bytes", "class" => class.map(crate::sanitize::MediaClass::as_str).unwrap_or("file")) + .record(source_size as f64); + metrics::histogram!("buzz_media_processing_output_bytes", "class" => class.map(crate::sanitize::MediaClass::as_str).unwrap_or("file")) + .record(output_size as f64); + + Ok(build_descriptor( + config, + &output_hash, + &ext, + &mime, + output_size, + Some(&meta), + uploaded_at, + )) +} + +async fn stream_source( + body_stream: impl futures_core::Stream> + Send + 'static, + path: &std::path::Path, + max_bytes: u64, +) -> Result<(String, u64, Vec), MediaError> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_util::io::StreamReader; + + let mapped = futures_util::StreamExt::map(body_stream, |result| { + result.map_err(|error| std::io::Error::other(error.to_string())) + }); + let mut reader = StreamReader::new(Box::pin(mapped)); + let mut file = tokio::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(path) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + let mut hasher = Sha256::new(); + let mut total = 0_u64; + let mut sniff = Vec::with_capacity(4096); + let mut buffer = vec![0_u8; 64 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + if read == 0 { + break; + } + total += read as u64; + if total > max_bytes { + return Err(MediaError::FileTooLarge { + size: total, + max: max_bytes, + }); + } + hasher.update(&buffer[..read]); + file.write_all(&buffer[..read]) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + let remaining = 4096_usize.saturating_sub(sniff.len()); + sniff.extend_from_slice(&buffer[..read.min(remaining)]); + } + file.flush() + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + Ok((hex::encode(hasher.finalize()), total, sniff)) +} + +async fn hash_file(path: &std::path::Path) -> Result<(String, u64), MediaError> { + use tokio::io::AsyncReadExt; + let mut file = tokio::fs::File::open(path) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + let mut hasher = Sha256::new(); + let mut size = 0_u64; + let mut buffer = vec![0_u8; 64 * 1024]; + loop { + let read = file + .read(&mut buffer) + .await + .map_err(|error| MediaError::Io(error.to_string()))?; + if read == 0 { + break; + } + size += read as u64; + hasher.update(&buffer[..read]); + } + Ok((hex::encode(hasher.finalize()), size)) +} + +fn enforce_source_size( + probe: Option<&crate::sanitize::MediaProbe>, + size: u64, + config: &MediaConfig, +) -> Result<(), MediaError> { + let max = match probe.map(|probe| probe.class) { + Some(crate::sanitize::MediaClass::Image) => { + if probe.is_some_and(|probe| probe.ext == "gif") { + config.max_gif_bytes + } else { + config.max_image_bytes + } + } + Some(crate::sanitize::MediaClass::Video) => config.max_video_bytes, + Some(crate::sanitize::MediaClass::Audio) => config.max_audio_bytes, + None => config.max_file_bytes, + }; + if size > max { + Err(MediaError::FileTooLarge { size, max }) + } else { + Ok(()) + } +} + /// Shared buffered-upload pipeline for the image and generic-file paths. /// /// Both paths are identical except for two steps, which are injected: @@ -111,6 +498,11 @@ where ext: &ext, mime: &mime, size: body.len() as u64, + source_sha256: None, + source_size: None, + source_mime: None, + sanitization_policy: None, + tool_versions: None, uploaded_at: chrono::Utc::now().timestamp(), }, ) @@ -169,6 +561,11 @@ where ext: &ext, mime: &mime, size: body.len() as u64, + source_sha256: None, + source_size: None, + source_mime: None, + sanitization_policy: None, + tool_versions: None, uploaded_at, }, ) @@ -444,6 +841,11 @@ pub async fn process_video_upload( ext, mime: &mime, size: file_size, + source_sha256: None, + source_size: None, + source_mime: None, + sanitization_policy: None, + tool_versions: None, uploaded_at: chrono::Utc::now().timestamp(), }, ) @@ -490,6 +892,11 @@ pub async fn process_video_upload( ext, mime: &mime, size: file_size, + source_sha256: None, + source_size: None, + source_mime: None, + sanitization_policy: None, + tool_versions: None, uploaded_at, }, ) @@ -573,7 +980,13 @@ mod tests { max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, + max_audio_bytes: 104_857_600, max_file_bytes: 104_857_600, + exiftool_path: "exiftool".to_string(), + ffmpeg_path: "ffmpeg".to_string(), + ffprobe_path: "ffprobe".to_string(), + image_process_timeout_secs: 120, + av_process_timeout_secs: 600, public_base_url: "https://media.example.com".to_string(), upload_records_enabled: false, upload_ip_header: None, diff --git a/crates/buzz-media/src/upload_record.rs b/crates/buzz-media/src/upload_record.rs index 42f57fbd2..7657f38c4 100644 --- a/crates/buzz-media/src/upload_record.rs +++ b/crates/buzz-media/src/upload_record.rs @@ -66,6 +66,25 @@ pub struct UploadRecord { pub mime_type: String, /// Size of the uploaded bytes. pub size: u64, + /// Hash of the authenticated source bytes when the public blob was + /// transformed. Omitted for exact-byte uploads and historical callers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_sha256: Option, + /// Size of the authenticated source bytes before transformation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_size: Option, + /// Content-derived MIME type of the authenticated source artifact. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_mime_type: Option, + /// Sanitization policy version applied to this upload. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sanitization_policy: Option, + /// Stable processing outcome. Accepted records currently use `accepted`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outcome: Option, + /// Startup-verified sanitizer binary versions. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_versions: Option, /// Unix seconds when the relay accepted *this* upload event. On an /// idempotent re-upload this is the re-upload time, not the original /// blob's `uploaded_at`. @@ -125,6 +144,16 @@ pub struct UploadEventFacts<'a> { pub mime: &'a str, /// Uploaded byte size. pub size: u64, + /// Authenticated pre-transform hash for sanitized media. + pub source_sha256: Option<&'a str>, + /// Authenticated pre-transform size for sanitized media. + pub source_size: Option, + /// Content-derived MIME type of the authenticated source. + pub source_mime: Option<&'a str>, + /// Applied sanitization policy version (`1` for the initial policy). + pub sanitization_policy: Option, + /// Startup-verified sanitizer binary versions. + pub tool_versions: Option<&'a crate::sanitize::ToolVersions>, /// Unix seconds this upload event was accepted. pub uploaded_at: i64, } @@ -156,6 +185,12 @@ pub async fn record_upload_event( ext: facts.ext.to_string(), mime_type: facts.mime.to_string(), size: facts.size, + source_sha256: facts.source_sha256.map(str::to_string), + source_size: facts.source_size, + source_mime_type: facts.source_mime.map(str::to_string), + sanitization_policy: facts.sanitization_policy, + outcome: facts.sanitization_policy.map(|_| "accepted".to_string()), + tool_versions: facts.tool_versions.cloned(), uploaded_at: facts.uploaded_at, community_id: ctx.community().to_string(), community_host: ctx.host().to_string(), @@ -290,6 +325,12 @@ mod tests { ext: "png".into(), mime_type: "image/png".into(), size: 12345, + source_sha256: Some("a".repeat(64)), + source_size: Some(13000), + source_mime_type: Some("image/png".into()), + sanitization_policy: Some(1), + outcome: Some("accepted".into()), + tool_versions: None, uploaded_at: 1_783_358_352, community_id: uuid::Uuid::from_u128(7).to_string(), community_host: "chat.example.com".into(), @@ -304,6 +345,7 @@ mod tests { assert_eq!(json["ext"], "png"); assert_eq!(json["mime_type"], "image/png"); assert_eq!(json["size"], 12345); + assert_eq!(json["sanitization_policy"], 1); assert_eq!(json["ip"], "203.0.113.7"); assert_eq!(json["port"], 51234); assert_eq!(json["uploader_name"], "alice"); @@ -318,6 +360,12 @@ mod tests { ext: "mp4".into(), mime_type: "video/mp4".into(), size: 1, + source_sha256: None, + source_size: None, + source_mime_type: None, + sanitization_policy: None, + outcome: None, + tool_versions: None, uploaded_at: 0, community_id: "cid".into(), community_host: "h".into(), @@ -332,6 +380,8 @@ mod tests { assert!(json.get("uploader_name").is_none()); assert!(json.get("ip").is_none()); assert!(json.get("port").is_none()); + assert!(json.get("source_sha256").is_none()); + assert!(json.get("sanitization_policy").is_none()); } #[test] diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index 74b4ab0d9..8eb0d61e1 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -420,7 +420,13 @@ mod tests { max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, + max_audio_bytes: 104_857_600, max_file_bytes: 104_857_600, + exiftool_path: "exiftool".to_string(), + ffmpeg_path: "ffmpeg".to_string(), + ffprobe_path: "ffprobe".to_string(), + image_process_timeout_secs: 120, + av_process_timeout_secs: 600, public_base_url: String::new(), upload_records_enabled: false, upload_ip_header: None, diff --git a/crates/buzz-media/tests/fixtures/README.md b/crates/buzz-media/tests/fixtures/README.md new file mode 100644 index 000000000..307a1e01d --- /dev/null +++ b/crates/buzz-media/tests/fixtures/README.md @@ -0,0 +1,15 @@ +# Media compliance fixtures + +The compliance test builds deterministic, synthetic fixtures at runtime so +codec/container coverage follows the exact FFmpeg and ExifTool binaries shipped +with the relay. Coordinates are fictional test data (`41.8781, -87.6298`) and +no fixture contains a real person or device identifier. + +`tiny.heic.b64` is the sole prebuilt input because FFmpeg does not provide a +portable HEIC muxer. It is a 64×64 synthetic application icon converted by +macOS ImageIO. The test adds GPS, camera, timestamp, and comment metadata with +ExifTool before passing it through the same sanitizer used by the relay. + +The matrix covers JPEG, PNG, GIF, WebP, HEIC, AVIF, TIFF, BMP, MP4, MOV, WebM, +MKV, MP3, M4A, AAC, FLAC, WAV, Ogg/Vorbis, and Opus. Every generated source is +small (96×64 or one second) and deterministic. diff --git a/crates/buzz-media/tests/fixtures/tiny.heic.b64 b/crates/buzz-media/tests/fixtures/tiny.heic.b64 new file mode 100644 index 000000000..8afbdc590 --- /dev/null +++ b/crates/buzz-media/tests/fixtures/tiny.heic.b64 @@ -0,0 +1 @@ +AAAAJGZ0eXBoZWljAAAAAG1pZjFNaVBybWlhZk1pSEJoZWljAAABhW1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAHBpY3QAAAAAAAAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAAADnBpdG0AAAAAAAEAAAAjaWluZgAAAAAAAQAAABVpbmZlAgAAAAABAABodmMxAAAAAOVpcHJwAAAAxGlwY28AAAATY29scm5jbHgAAgACAAaAAAAADGNsbGkAywBAAAAAFGlzcGUAAAAAAAAAQAAAAEAAAAAJaXJvdAAAAAAQcGl4aQAAAAADCAgIAAAAcGh2Y0MBA3AAAACwAAAAAAAe8AD8/fj4AAALA6AAAQAXQAEMAf//A3AAAAMAsAAAAwAAAwAecCShAAEAIkIBAQNwAAADALAAAAMAAAMAHqAUIEHBj4h7kWVTcCAgYAiiAAEACUQBwGFyyERTZAAAABlpcG1hAAAAAAAAAAEAAQaBAgMFhoQAAAAeaWxvYwAAAABEAAABAAEAAAABAAABuQAAAzgAAAABbWRhdAAAAAAAAANIAAADNCgBr6E8OCto5f/5vzLcmqS4VbL0xtjJrLu8xkKqvDMOTmUMli4IiU+QBTFEL7wuY+6mi7zYYGfw3L9Thj6KcCgd+Gdd1r7uUUk0wXnn5P4JW/QxrOzrRSqTETOXuclMaoap1CSQpUwmQ87bnGbUOEs8xUD0KYIklCl9LYHYHKEcQXL2URsSWAKnAYyXDbdZb/lv4a3S/Yw+Cw/vv0CZmFKgGiNSpWcaKI+1LrfGFmuxqg03cM3zsvE2PxZ+2A89j6pq7muTmpQ6v3v2vG/qui8G7YTh1eRZMpcHMKFptMZnnmCHQ4PJV7gmBzQppR/xS5GsYRU4sE2JzcqiLwigjLNxdjtFoUJJk6ysfIRJCLe5pzHYWWl5np2BmnALMECAT+GMQLgVZGW6tVdluqR0DXthRb887tNxSS6VZoELPPLxE20wEuQaYxLFdzFME78aD9PLztVnP2qb95jb93JLVgGp64KYoJXpRhq2kl5qkyqV6uMYHv99kd6b2/4KH9IeUTJ/dmJnFXpUbu9bx0l6Yddf7PDt1Fj+Dk/KV8Kogxrqt2LaszYZQcl4KHKFapA2yqMFVsI0K2kUM/vu1w3eam3GNjGn/rUqI7Z6Fc0zgT35vauzCXSGQx90TQowBZy0qhKzQ88ZyfDhNf70Ih7g29+nKloQzjxvce7lpNhTJTzsByCd2Ez0VAY0teWz3XohgLq0EtWAz+my/svEfq+sqtHHrdheV45XjF3MfL4Q5k6Kdjywr6H1MaODIbLk1eKnbCUH50OyW7ZB9tl/oD/7RKMKvDFktjlk3qXpfV33eJcxl/TFshOCmtVlLToL+jJW/LrI2i5Oct/P0/zL/+irj68C1YJacoyYbVYtVuZHAnKUsdHYv7rm74OJkZ5H2H9/fZww24Y99zpZTciNRZFftceWa8sJW8cVBs8GFXRTjpP8fhHiea825tY8kcFLmvYevVyfKc0dV6zfQpqlibyhtnyqR2Nq/Gw3AZhW6qgq0xvXg/soUToc9YuI4uSIXrGa0vRLVK/+uQKdXw1eVxy+X9LTx5uQmXtkvjUIxJ74q78O0AHvElPmPdYLPmYltM3vvXWerM0= diff --git a/crates/buzz-media/tests/media_compliance.rs b/crates/buzz-media/tests/media_compliance.rs new file mode 100644 index 000000000..c7a8a0bad --- /dev/null +++ b/crates/buzz-media/tests/media_compliance.rs @@ -0,0 +1,352 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use base64::Engine; +use buzz_media::sanitize::{probe_media, sanitize, validate_toolchain, MediaClass}; +use buzz_media::MediaConfig; +use sha2::{Digest, Sha256}; + +fn config() -> MediaConfig { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let hermit = |name: &str| { + let path = root.join("bin").join(name); + if path.exists() { + path.to_string_lossy().into_owned() + } else { + name.to_string() + } + }; + MediaConfig { + s3_endpoint: String::new(), + s3_access_key: String::new(), + s3_secret_key: String::new(), + s3_bucket: String::new(), + s3_region: "us-east-1".to_string(), + max_image_bytes: 50 * 1024 * 1024, + max_gif_bytes: 10 * 1024 * 1024, + max_video_bytes: 500 * 1024 * 1024, + max_audio_bytes: 100 * 1024 * 1024, + max_file_bytes: 100 * 1024 * 1024, + public_base_url: "http://localhost:3000/media".to_string(), + exiftool_path: std::env::var("BUZZ_EXIFTOOL_PATH") + .unwrap_or_else(|_| "exiftool".to_string()), + ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| hermit("ffmpeg")), + ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH").unwrap_or_else(|_| hermit("ffprobe")), + image_process_timeout_secs: 120, + av_process_timeout_secs: 600, + upload_records_enabled: false, + upload_ip_header: None, + upload_port_header: None, + } +} + +fn run(program: &str, args: &[&str]) { + let output = Command::new(program) + .args(args) + .output() + .unwrap_or_else(|error| panic!("failed to run {program}: {error}")); + assert!( + output.status.success(), + "{program} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn path(path: &Path) -> &str { + path.to_str().expect("fixture path must be UTF-8") +} + +fn ffmpeg(config: &MediaConfig, args: &[&str]) { + let mut full = vec!["-nostdin", "-v", "error", "-y"]; + full.extend_from_slice(args); + run(&config.ffmpeg_path, &full); +} + +fn add_private_metadata(config: &MediaConfig, fixture: &Path) { + run( + &config.exiftool_path, + &[ + "-overwrite_original", + "-GPSLatitude=41.8781", + "-GPSLatitudeRef=N", + "-GPSLongitude=87.6298", + "-GPSLongitudeRef=W", + "-Make=BuzzFixture", + "-Model=ComplianceCamera", + "-Artist=Buzz Compliance Fixture", + "-Comment=Synthetic location fixture", + "-Title=Synthetic location fixture", + path(fixture), + ], + ); +} + +fn forbidden_metadata( + config: &MediaConfig, + fixture: &Path, +) -> serde_json::Map { + let output = Command::new(&config.exiftool_path) + .args([ + "-j", + "-G1", + "-s", + "-GPS*", + "-Location*", + "-Make", + "-Model", + "-Artist", + "-Comment", + "-Title", + "-UserDefinedText", + path(fixture), + ]) + .output() + .expect("run exiftool metadata oracle"); + assert!(output.status.success(), "metadata oracle failed"); + let values: Vec = + serde_json::from_slice(&output.stdout).expect("ExifTool JSON"); + values[0].as_object().expect("ExifTool object").clone() +} + +fn source_hash(path: &Path) -> String { + hex::encode(Sha256::digest(std::fs::read(path).expect("read fixture"))) +} + +fn synchsafe(value: usize) -> [u8; 4] { + [ + ((value >> 21) & 0x7f) as u8, + ((value >> 14) & 0x7f) as u8, + ((value >> 7) & 0x7f) as u8, + (value & 0x7f) as u8, + ] +} + +fn id3_text_frame(id: &[u8; 4], payload: &[u8]) -> Vec { + let mut frame = Vec::with_capacity(10 + payload.len()); + frame.extend_from_slice(id); + frame.extend_from_slice(&synchsafe(payload.len())); + frame.extend_from_slice(&[0, 0]); + frame.extend_from_slice(payload); + frame +} + +fn prepend_aac_id3(fixture: &Path) { + let title = id3_text_frame(b"TIT2", b"\x03Synthetic location fixture"); + let location = id3_text_frame(b"TXXX", b"\x03LOCATION\x0041.8781,-87.6298"); + let mut frames = Vec::new(); + frames.extend_from_slice(&title); + frames.extend_from_slice(&location); + + let audio = std::fs::read(fixture).expect("read AAC fixture"); + let mut tagged = Vec::with_capacity(10 + frames.len() + audio.len()); + tagged.extend_from_slice(b"ID3\x04\x00\x00"); + tagged.extend_from_slice(&synchsafe(frames.len())); + tagged.extend_from_slice(&frames); + tagged.extend_from_slice(&audio); + std::fs::write(fixture, tagged).expect("write ID3-tagged AAC fixture"); +} + +async fn assert_sanitized( + config: &MediaConfig, + source: &Path, + expected_class: MediaClass, + require_source_metadata: bool, +) { + let source_metadata = forbidden_metadata(config, source); + if require_source_metadata { + assert!( + source_metadata + .keys() + .any(|key| !key.ends_with("SourceFile")), + "{} must prove it contains private metadata before sanitizing", + source.display() + ); + } + let bytes = std::fs::read(source).expect("read source"); + let probe = probe_media(source, &bytes[..bytes.len().min(4096)], config) + .await + .expect("probe source") + .expect("fixture must be recognized media"); + assert_eq!(probe.class, expected_class); + let input_hash = source_hash(source); + let output = sanitize(source, &probe, config) + .await + .unwrap_or_else(|error| panic!("sanitize fixture {}: {error:?}", source.display())); + assert_eq!(output.probe.class, expected_class); + assert_ne!(input_hash, source_hash(output.file.path())); + let residual = forbidden_metadata(config, output.file.path()); + assert!( + residual.keys().all(|key| key.ends_with("SourceFile")), + "{} retained forbidden metadata keys: {:?}", + source.display(), + residual.keys().collect::>() + ); +} + +#[tokio::test] +#[ignore = "mandatory via `just media-compliance-test`"] +async fn realistic_media_matrix_strips_location_and_descriptive_metadata() { + let config = config(); + validate_toolchain(&config) + .await + .expect("FFmpeg, ffprobe, and ExifTool are mandatory"); + let temp = tempfile::tempdir().expect("fixture directory"); + + let image_specs = [ + ( + "jpg", + vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=96x64:rate=1", + "-frames:v", + "1", + ], + ), + ( + "png", + vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=96x64:rate=1", + "-frames:v", + "1", + ], + ), + ( + "gif", + vec!["-f", "lavfi", "-i", "testsrc2=size=96x64:rate=2", "-t", "1"], + ), + ( + "webp", + vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=96x64:rate=1", + "-frames:v", + "1", + ], + ), + ( + "tiff", + vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=96x64:rate=1", + "-frames:v", + "1", + ], + ), + ( + "bmp", + vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=96x64:rate=1", + "-frames:v", + "1", + ], + ), + ( + "avif", + vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=96x64:rate=1", + "-frames:v", + "1", + "-c:v", + "libaom-av1", + "-still-picture", + "1", + ], + ), + ]; + for (ext, mut args) in image_specs { + let fixture = temp.path().join(format!("location.{ext}")); + args.push(path(&fixture)); + ffmpeg(&config, &args); + if ext != "bmp" { + add_private_metadata(&config, &fixture); + } + assert_sanitized(&config, &fixture, MediaClass::Image, ext != "bmp").await; + } + + let heic = temp.path().join("location.heic"); + let encoded = include_str!("fixtures/tiny.heic.b64").trim(); + std::fs::write( + &heic, + base64::engine::general_purpose::STANDARD + .decode(encoded) + .expect("decode HEIC fixture"), + ) + .expect("write HEIC fixture"); + add_private_metadata(&config, &heic); + assert_sanitized(&config, &heic, MediaClass::Image, true).await; + + for ext in ["mp4", "mov", "webm", "mkv"] { + let fixture = temp.path().join(format!("location.{ext}")); + let mut args = vec![ + "-f", + "lavfi", + "-i", + "testsrc2=size=96x64:rate=10:duration=1", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-shortest", + "-metadata", + "location=+41.8781-087.6298/", + "-metadata", + "title=Synthetic location fixture", + ]; + if matches!(ext, "webm" | "mkv") { + args.extend(["-c:v", "libvpx-vp9", "-c:a", "libopus"]); + } else { + args.extend(["-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac"]); + } + args.push(path(&fixture)); + ffmpeg(&config, &args); + assert_sanitized(&config, &fixture, MediaClass::Video, true).await; + } + + let audio_specs = [ + ("mp3", "libmp3lame"), + ("m4a", "aac"), + ("aac", "aac"), + ("flac", "flac"), + ("wav", "pcm_s16le"), + ("ogg", "libvorbis"), + ("opus", "libopus"), + ]; + for (ext, codec) in audio_specs { + let fixture = temp.path().join(format!("location.{ext}")); + ffmpeg( + &config, + &[ + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:a", + codec, + "-metadata", + "LOCATION=41.8781,-87.6298", + "-metadata", + "title=Synthetic location fixture", + path(&fixture), + ], + ); + if ext == "aac" { + prepend_aac_id3(&fixture); + } + assert_sanitized(&config, &fixture, MediaClass::Audio, true).await; + } +} diff --git a/crates/buzz-media/tests/static_creds_minio.rs b/crates/buzz-media/tests/static_creds_minio.rs index d7591238c..4048be511 100644 --- a/crates/buzz-media/tests/static_creds_minio.rs +++ b/crates/buzz-media/tests/static_creds_minio.rs @@ -33,7 +33,13 @@ fn minio_config() -> MediaConfig { max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, + max_audio_bytes: 104_857_600, max_file_bytes: 104_857_600, + exiftool_path: "exiftool".to_string(), + ffmpeg_path: "ffmpeg".to_string(), + ffprobe_path: "ffprobe".to_string(), + image_process_timeout_secs: 120, + av_process_timeout_secs: 600, public_base_url: "http://localhost:3000/media".to_string(), upload_records_enabled: false, upload_ip_header: None, diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 62b0fdc39..2967225eb 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -1,7 +1,9 @@ //! Blossom-compatible media upload, retrieval, and existence check handlers. //! //! Routes: -//! PUT /media/upload — BUD-02 upload (auth required) +//! PUT /media — BUD-05 sanitizing media upload +//! PUT /upload — BUD-02 exact-byte non-media upload +//! PUT /media/upload — temporary legacy compatibility route //! GET /media/{sha256_ext} — BUD-01 serve blob //! HEAD /media/{sha256_ext} — BUD-01 existence check @@ -35,6 +37,8 @@ pub(crate) struct AuthenticatedUpload { /// this HTTP door), identical to the WS door in `router.rs` and the bridge /// door in `bridge.rs`. Server-resolved, never client-supplied. tenant: TenantContext, + claimed_hash: String, + mode: buzz_media::UploadRouteMode, _upload_permit: UploadPermit, } @@ -145,13 +149,30 @@ impl FromRequestParts> for AuthenticatedUpload { .await .map_err(|_| MediaError::NotFound)?; + let mode = match parts.uri.path() { + "/media" => buzz_media::UploadRouteMode::Media, + "/upload" => buzz_media::UploadRouteMode::Upload, + "/media/upload" => buzz_media::UploadRouteMode::Legacy, + _ => return Err(MediaError::NotFound), + }; + // 2. Extract and validate Blossom auth event against the bound host. 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. - buzz_media::auth::verify_blossom_auth_event(&auth_event, Some(tenant.host()), 3600)?; + let verb = if mode == buzz_media::UploadRouteMode::Media { + buzz_media::auth::BlossomVerb::Media + } else { + buzz_media::auth::BlossomVerb::Upload + }; + buzz_media::auth::verify_blossom_auth_event_for_verb( + &auth_event, + verb, + Some(tenant.host()), + 3600, + )?; // 3. Require X-SHA-256 header (BUD-11: mandatory for PUT /upload) let claimed_hash = headers @@ -208,6 +229,8 @@ impl FromRequestParts> for AuthenticatedUpload { Ok(AuthenticatedUpload { auth_event, tenant, + claimed_hash: claimed_hash.to_string(), + mode, _upload_permit: upload_permit, }) } @@ -259,7 +282,7 @@ async fn upload_attribution( }) } -/// PUT /media/upload — Blossom BUD-02 upload. +/// PUT /media, /upload, or the temporary /media/upload compatibility route. /// /// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body /// is read, preventing unauthenticated clients from forcing body buffering. @@ -271,7 +294,7 @@ async fn upload_attribution( /// Expects: /// - `Authorization: Nostr ` — Blossom auth /// - `X-SHA-256: ` — Required per BUD-11 -/// - `Content-Type: video/mp4` — routes to video validation path; all other types use image path +/// - `Content-Type` is advisory only; routing is always based on body probes /// - Raw binary body (the file bytes) /// /// Returns a [`BlobDescriptor`] JSON on success. @@ -283,71 +306,27 @@ pub async fn upload_blob( headers: HeaderMap, body: axum::body::Body, ) -> Result, MediaError> { - let content_type = headers - .get("content-type") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - let attribution = upload_attribution(&state, &auth, &headers).await; - - let mut 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::().ok()); - buzz_media::process_video_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - body.into_data_stream(), + let content_length = headers + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()); + if auth.mode == buzz_media::UploadRouteMode::Legacy { + metrics::counter!("buzz_media_legacy_upload_route_total").increment(1); + } + let mut descriptor = buzz_media::process_streaming_ingest( + buzz_media::StreamingIngestInput { + storage: &state.media_storage, + config: &state.config.media, + ctx: &auth.tenant, + auth_event: &auth.auth_event, content_length, attribution, - ) - .await? - } else { - // Non-video path: buffer the body (bounded by the larger of the image - // and generic-file caps), then decide image-vs-generic by sniffed MIME. - // Images go through the thumbnailing pipeline; everything else (docs, - // archives, audio, text, data) takes the generic file path and is - // served as a download. - let max = state - .config - .media - .max_image_bytes - .max(state.config.media.max_file_bytes); - let bytes = axum::body::to_bytes(body, max as usize) - .await - .map_err(|_| MediaError::FileTooLarge { size: 0, max })?; - - let is_image = matches!( - infer::get(&bytes).map(|t| t.mime_type()), - Some("image/jpeg" | "image/png" | "image/gif" | "image/webp") - ); - - if is_image { - buzz_media::process_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - bytes, - attribution, - ) - .await? - } else { - buzz_media::process_file_upload( - &state.media_storage, - &state.config.media, - &auth.tenant, - &auth.auth_event, - bytes, - attribution, - ) - .await? - } - }; + mode: auth.mode, + }, + body.into_data_stream(), + ) + .await?; rewrite_descriptor_urls_for_tenant( &mut descriptor, @@ -371,6 +350,7 @@ pub async fn upload_blob( // Audit via bounded channel — same pattern as event audit. let desc = descriptor.clone(); + let sanitized = desc.sha256 != auth.claimed_hash; if let Err(e) = state .audit_tx .send(NewAuditEntry { @@ -380,8 +360,13 @@ pub async fn upload_blob( object_id: Some(desc.sha256.clone()), detail: serde_json::json!({ "sha256": desc.sha256, - "size": desc.size, - "mime": desc.mime_type, + "source_sha256": auth.claimed_hash, + "sanitized": sanitized, + "sanitization_policy": sanitized.then_some(1), + "outcome": "accepted", + "output_size": desc.size, + "output_mime": desc.mime_type, + "tool_versions": buzz_media::sanitize::tool_versions(), }), }) .await diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index c11dca9cc..75bb831b7 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -499,10 +499,27 @@ impl Config { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(500 * 1024 * 1024), + max_audio_bytes: std::env::var("BUZZ_MAX_AUDIO_BYTES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100 * 1024 * 1024), max_file_bytes: std::env::var("BUZZ_MAX_FILE_BYTES") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(100 * 1024 * 1024), + exiftool_path: std::env::var("BUZZ_EXIFTOOL_PATH") + .unwrap_or_else(|_| "exiftool".to_string()), + ffmpeg_path: std::env::var("BUZZ_FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()), + ffprobe_path: std::env::var("BUZZ_FFPROBE_PATH") + .unwrap_or_else(|_| "ffprobe".to_string()), + image_process_timeout_secs: std::env::var("BUZZ_MEDIA_IMAGE_PROCESS_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(120), + av_process_timeout_secs: std::env::var("BUZZ_MEDIA_AV_PROCESS_TIMEOUT_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(600), public_base_url: std::env::var("BUZZ_MEDIA_BASE_URL") .unwrap_or_else(|_| "http://localhost:3000/media".to_string()), // Per-upload-event records (`_uploads/` moderation side channel). diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 744633baa..895c37c77 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -122,6 +122,16 @@ async fn main() -> anyhow::Result<()> { error!("Invalid configuration: {e}"); anyhow::anyhow!("Configuration error: {e}") })?; + config.media.validate().map_err(|message| { + error!(%message, "Invalid media configuration"); + anyhow::anyhow!("Media configuration error: {message}") + })?; + buzz_media::sanitize::validate_toolchain(&config.media) + .await + .map_err(|error| { + error!(%error, "Required media privacy toolchain is unavailable"); + anyhow::anyhow!("Media privacy toolchain unavailable: {error}") + })?; info!( bind_addr = %config.bind_addr, relay_url = %config.relay_url, diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index bb8de5556..e8957d66d 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -36,6 +36,8 @@ pub fn build_router(state: Arc) -> Router { .max_image_bytes .max(state.config.media.max_video_bytes) as usize; let media_router = Router::new() + .route("/media", put(api::media::upload_blob)) + .route("/upload", put(api::media::upload_blob)) .route("/media/upload", put(api::media::upload_blob)) .route( "/media/{sha256_ext}", diff --git a/crates/buzz-test-client/Cargo.toml b/crates/buzz-test-client/Cargo.toml index 7ce3e2bc3..62732b714 100644 --- a/crates/buzz-test-client/Cargo.toml +++ b/crates/buzz-test-client/Cargo.toml @@ -36,6 +36,7 @@ sha2 = { workspace = true } chrono = { workspace = true } s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] } buzz-sdk = { workspace = true } +tempfile = "3" [[bin]] name = "buzz-test-cli" diff --git a/crates/buzz-test-client/tests/e2e_media.rs b/crates/buzz-test-client/tests/e2e_media.rs index 0b90c01c7..1c44aaa51 100644 --- a/crates/buzz-test-client/tests/e2e_media.rs +++ b/crates/buzz-test-client/tests/e2e_media.rs @@ -38,7 +38,7 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); let exp_str = (now + 300).to_string(); let tags = vec![ - Tag::parse(["t", "upload"]).expect("t tag"), + Tag::parse(["t", "media"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), ]; @@ -99,10 +99,10 @@ async fn test_upload_and_get() { println!("sha256: {sha256}"); println!("relay: {}", relay_http_url()); - // PUT /media/upload + // PUT /media let auth = sign_blossom_auth(&keys, &sha256); let resp = client - .put(format!("{}/media/upload", relay_http_url())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(&auth)) .header("Content-Type", "image/jpeg") .header("X-SHA-256", &sha256) @@ -113,7 +113,7 @@ async fn test_upload_and_get() { let status = resp.status(); let body_text = resp.text().await.unwrap_or_default(); - println!("PUT /media/upload → {status}: {body_text}"); + println!("PUT /media → {status}: {body_text}"); assert_eq!(status, 200, "upload should succeed"); // Parse BlobDescriptor @@ -121,14 +121,10 @@ async fn test_upload_and_get() { serde_json::from_str(&body_text).expect("BlobDescriptor JSON"); println!("BlobDescriptor: {descriptor:#}"); - assert_eq!( - descriptor["sha256"].as_str().unwrap(), - sha256, - "sha256 must match" - ); + let output_sha = descriptor["sha256"].as_str().unwrap(); assert!( - descriptor["url"].as_str().unwrap().contains(&sha256), - "url must contain sha256" + descriptor["url"].as_str().unwrap().contains(output_sha), + "url must contain sanitized sha256" ); assert!( descriptor["size"].as_u64().unwrap() > 0, @@ -145,7 +141,7 @@ async fn test_upload_and_get() { ); // GET /media/{sha256}.jpg — bytes must match - let get_url = format!("{}/media/{sha256}.jpg", relay_http_url()); + let get_url = descriptor["url"].as_str().unwrap().to_string(); let get_resp = client .get(&get_url) .send() @@ -153,11 +149,7 @@ async fn test_upload_and_get() { .expect("GET /media/{sha256}.jpg failed"); assert_eq!(get_resp.status(), 200, "GET should return 200"); let returned_bytes = get_resp.bytes().await.unwrap(); - assert_eq!( - returned_bytes.as_ref(), - jpeg.as_slice(), - "GET must return original bytes" - ); + assert_eq!(hex::encode(Sha256::digest(&returned_bytes)), output_sha); // HEAD /media/{sha256}.jpg — must return 200 with content-type let head_resp = client @@ -172,7 +164,7 @@ async fn test_upload_and_get() { ); // GET thumbnail — /media/{sha256}.thumb.jpg - let thumb_url = format!("{}/media/{sha256}.thumb.jpg", relay_http_url()); + let thumb_url = format!("{}/media/{output_sha}.thumb.jpg", relay_http_url()); let thumb_resp = client .get(&thumb_url) .send() @@ -195,7 +187,7 @@ async fn test_upload_idempotent() { let upload = |keys: &Keys| { let auth = sign_blossom_auth(keys, &sha256); client - .put(format!("{}/media/upload", relay_http_url())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(&auth)) .header("Content-Type", "image/jpeg") .header("X-SHA-256", sha256.clone()) @@ -234,7 +226,7 @@ async fn test_upload_no_auth_returns_401() { let jpeg = tiny_jpeg(); let resp = client - .put(format!("{}/media/upload", relay_http_url())) + .put(format!("{}/media", relay_http_url())) .header("Content-Type", "image/jpeg") .body(jpeg) .send() @@ -256,7 +248,7 @@ async fn test_upload_missing_x_sha256_returns_401() { let auth = sign_blossom_auth(&keys, &sha256); let resp = client - .put(format!("{}/media/upload", relay_http_url())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(&auth)) .header("Content-Type", "image/jpeg") // Intentionally omit X-SHA-256 @@ -280,7 +272,7 @@ async fn test_upload_hash_mismatch_returns_400() { let auth = sign_blossom_auth(&keys, &wrong_hash); let resp = client - .put(format!("{}/media/upload", relay_http_url())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(&auth)) .header("Content-Type", "image/jpeg") .header("X-SHA-256", &wrong_hash) @@ -331,7 +323,7 @@ async fn test_upload_real_image() { let auth = sign_blossom_auth(&keys, &sha256); let resp = client - .put(format!("{}/media/upload", relay_http_url())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(&auth)) .header("Content-Type", "image/jpeg") .header("X-SHA-256", &sha256) @@ -342,16 +334,15 @@ async fn test_upload_real_image() { let status = resp.status(); let body_text = resp.text().await.unwrap_or_default(); - println!("PUT /media/upload → {status}: {body_text}"); + println!("PUT /media → {status}: {body_text}"); assert_eq!(status, 200, "upload should succeed"); let descriptor: serde_json::Value = serde_json::from_str(&body_text).expect("BlobDescriptor JSON"); println!("BlobDescriptor: {descriptor:#}"); - assert_eq!(descriptor["sha256"].as_str().unwrap(), sha256); - assert_eq!(descriptor["size"].as_u64().unwrap(), size as u64); - assert!(descriptor["url"].as_str().unwrap().contains(&sha256)); + let output_sha = descriptor["sha256"].as_str().unwrap(); + assert!(descriptor["url"].as_str().unwrap().contains(output_sha)); assert!( descriptor["dim"].as_str().is_some(), "real image should have dim" @@ -366,11 +357,7 @@ async fn test_upload_real_image() { let get_resp = client.get(get_url).send().await.expect("GET failed"); assert_eq!(get_resp.status(), 200); let returned = get_resp.bytes().await.unwrap(); - assert_eq!( - returned.as_ref(), - bytes.as_slice(), - "GET must return original bytes" - ); + assert_eq!(hex::encode(Sha256::digest(&returned)), output_sha); println!("✅ Real image upload round-trip passed"); } diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index dc4a0629f..a423d6dd7 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -27,9 +27,13 @@ fn http_client() -> Client { } fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { + sign_blossom_auth_for_verb(keys, sha256, "media") +} + +fn sign_blossom_auth_for_verb(keys: &Keys, sha256: &str, verb: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); let tags = vec![ - Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["t", verb]).unwrap(), Tag::parse(["x", sha256]).unwrap(), Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), ]; @@ -39,6 +43,19 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { .unwrap() } +async fn upload_file(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response { + let sha256 = hex::encode(Sha256::digest(body)); + let auth = sign_blossom_auth_for_verb(keys, &sha256, "upload"); + client + .put(format!("{}/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(&auth)) + .header("X-SHA-256", &sha256) + .body(body.to_vec()) + .send() + .await + .expect("file upload request") +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -50,7 +67,7 @@ async fn upload(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Response let sha256 = hex::encode(Sha256::digest(body)); let auth = sign_blossom_auth(keys, &sha256); client - .put(format!("{}/media/upload", relay_http_url())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(&auth)) .header("X-SHA-256", &sha256) .body(body.to_vec()) @@ -137,7 +154,7 @@ async fn upload_with_auth( body: &[u8], ) -> reqwest::Response { client - .put(format!("{}/media/upload", relay_http_url())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(auth_event)) .header("X-SHA-256", sha256) .body(body.to_vec()) @@ -166,7 +183,11 @@ async fn test_upload_png_roundtrip() { .await .unwrap(); assert_eq!(get.status(), 200); - assert_eq!(get.bytes().await.unwrap().as_ref(), png.as_slice()); + let returned = get.bytes().await.unwrap(); + assert_eq!( + hex::encode(Sha256::digest(&returned)), + desc["sha256"].as_str().unwrap() + ); println!("✅ PNG GET roundtrip verified"); } @@ -189,7 +210,11 @@ async fn test_upload_gif_roundtrip() { .await .unwrap(); assert_eq!(get.status(), 200); - assert_eq!(get.bytes().await.unwrap().as_ref(), gif.as_slice()); + let returned = get.bytes().await.unwrap(); + assert_eq!( + hex::encode(Sha256::digest(&returned)), + desc["sha256"].as_str().unwrap() + ); println!("✅ GIF GET roundtrip verified"); } @@ -223,7 +248,7 @@ async fn test_auth_wrong_kind() { 27235, "Upload test", vec![ - Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["t", "media"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), ], @@ -267,7 +292,7 @@ async fn test_auth_missing_expiration() { 24242, "Upload test", vec![ - Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["t", "media"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), ], ); @@ -289,7 +314,7 @@ async fn test_auth_expired_token() { 24242, "Upload test", vec![ - Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["t", "media"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), Tag::parse(["expiration", &(now - 60).to_string()]).unwrap(), ], @@ -312,7 +337,7 @@ async fn test_auth_empty_content() { 24242, "", vec![ - Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["t", "media"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), ], @@ -335,7 +360,7 @@ async fn test_auth_server_tag_mismatch() { 24242, "Upload test", vec![ - Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["t", "media"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), Tag::parse(["server", "evil.example.com"]).unwrap(), @@ -359,7 +384,7 @@ async fn test_auth_server_tag_correct() { 24242, "Upload test", vec![ - Tag::parse(["t", "upload"]).unwrap(), + Tag::parse(["t", "media"]).unwrap(), Tag::parse(["x", &sha256]).unwrap(), Tag::parse(["expiration", &(now + 300).to_string()]).unwrap(), Tag::parse(["server", "localhost:3000"]).unwrap(), @@ -378,7 +403,7 @@ async fn test_upload_svg_accepted_as_text_xml() { let client = http_client(); let keys = Keys::generate(); let svg = b""; - let resp = upload(&client, &keys, svg).await; + let resp = upload_file(&client, &keys, svg).await; let status = resp.status().as_u16(); assert_eq!( status, 200, @@ -397,7 +422,7 @@ async fn test_upload_pdf_accepted() { let client = http_client(); let keys = Keys::generate(); let pdf = b"%PDF-1.4 fake pdf content here for testing"; - let resp = upload(&client, &keys, pdf).await; + let resp = upload_file(&client, &keys, pdf).await; let status = resp.status().as_u16(); assert_eq!( status, 200, @@ -415,7 +440,7 @@ async fn test_upload_zero_bytes_accepted() { // as application/octet-stream. let client = http_client(); let keys = Keys::generate(); - let resp = upload(&client, &keys, b"").await; + let resp = upload_file(&client, &keys, b"").await; let status = resp.status().as_u16(); assert_eq!( status, 200, @@ -435,7 +460,7 @@ async fn test_upload_random_bytes_accepted() { let client = http_client(); let keys = Keys::generate(); let random: Vec = (0..1000).map(|i| (i * 37 % 256) as u8).collect(); - let resp = upload(&client, &keys, &random).await; + let resp = upload_file(&client, &keys, &random).await; let status = resp.status().as_u16(); assert_eq!( status, 200, @@ -446,6 +471,34 @@ async fn test_upload_random_bytes_accepted() { println!("✅ Random bytes → 200 as octet-stream"); } +#[tokio::test] +#[ignore] +async fn test_standard_upload_rejects_media_bypass() { + let client = http_client(); + let keys = Keys::generate(); + let resp = upload_file(&client, &keys, &tiny_jpeg()).await; + assert_eq!(resp.status(), 415, "recognized media must use PUT /media"); +} + +#[tokio::test] +#[ignore] +async fn test_legacy_media_upload_alias_sanitizes_with_upload_verb() { + let client = http_client(); + let keys = Keys::generate(); + let jpeg = tiny_jpeg(); + let sha256 = hex::encode(Sha256::digest(&jpeg)); + let auth = sign_blossom_auth_for_verb(&keys, &sha256, "upload"); + let resp = client + .put(format!("{}/media/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(&auth)) + .header("X-SHA-256", &sha256) + .body(jpeg) + .send() + .await + .expect("legacy upload request"); + assert_eq!(resp.status(), 200); +} + #[tokio::test] #[ignore] async fn test_concurrent_upload_same_file() { diff --git a/crates/buzz-test-client/tests/e2e_media_video.rs b/crates/buzz-test-client/tests/e2e_media_video.rs index 27c9c3767..8a4a58a9b 100644 --- a/crates/buzz-test-client/tests/e2e_media_video.rs +++ b/crates/buzz-test-client/tests/e2e_media_video.rs @@ -30,7 +30,7 @@ fn sign_blossom_auth(keys: &Keys, sha256: &str) -> nostr::Event { let now = Timestamp::now().as_secs(); let exp_str = (now + 300).to_string(); let tags = vec![ - Tag::parse(["t", "upload"]).expect("t tag"), + Tag::parse(["t", "media"]).expect("t tag"), Tag::parse(["x", sha256]).expect("x tag"), Tag::parse(["expiration", &exp_str]).expect("expiration tag"), ]; @@ -51,6 +51,7 @@ fn blossom_auth_header(event: &nostr::Event) -> String { /// /// 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. +#[allow(dead_code)] fn build_test_mp4() -> Vec { fn box_wrap(fourcc: &[u8; 4], payload: &[u8]) -> Vec { let size = (8 + payload.len()) as u32; @@ -235,6 +236,45 @@ fn build_test_mp4() -> Vec { [ftyp, moov, mdat].concat() } +/// Generate a real decodable H.264/AAC fixture. Compliance processing uses +/// FFmpeg rather than accepting header-only MP4 structures, so happy-path E2E +/// coverage must exercise actual media samples. +fn build_real_test_mp4() -> Vec { + let output = tempfile::Builder::new() + .suffix(".mp4") + .tempfile() + .expect("MP4 fixture tempfile"); + let status = std::process::Command::new("ffmpeg") + .args([ + "-nostdin", + "-v", + "error", + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=size=320x240:rate=10:duration=1", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-shortest", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + "-c:a", + "aac", + "-movflags", + "+faststart", + ]) + .arg(output.path()) + .status() + .expect("run ffmpeg fixture generator"); + assert!(status.success(), "ffmpeg fixture generation failed"); + std::fs::read(output.path()).expect("read MP4 fixture") +} + /// Upload a valid MP4 video via Blossom, verify the BlobDescriptor includes /// video-specific fields (duration, dim) and the blob is retrievable. #[tokio::test] @@ -242,11 +282,11 @@ fn build_test_mp4() -> Vec { async fn test_video_upload_and_get() { let client = http_client(); let keys = Keys::generate(); - let mp4 = build_test_mp4(); + let mp4 = build_real_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 url = format!("{}/media", relay_http_url()); let resp = client .put(&url) @@ -261,7 +301,7 @@ async fn test_video_upload_and_get() { 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); + let output_sha = desc["sha256"].as_str().unwrap(); assert_eq!(desc["type"].as_str().unwrap(), "video/mp4"); assert!(desc["size"].as_u64().unwrap() > 0); // Video descriptor should have duration @@ -275,7 +315,7 @@ async fn test_video_upload_and_get() { 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()); + assert_eq!(hex::encode(Sha256::digest(&body)), output_sha); } /// The relay ignores the Content-Type header and sniffs magic bytes. MP4 @@ -286,11 +326,11 @@ async fn test_video_upload_and_get() { async fn test_video_content_type_header_ignored() { let client = http_client(); let keys = Keys::generate(); - let mp4 = build_test_mp4(); + let mp4 = build_real_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 url = format!("{}/media", relay_http_url()); // Upload MP4 bytes but claim it's image/jpeg let resp = client @@ -322,12 +362,12 @@ async fn test_video_content_type_header_ignored() { async fn test_video_range_request_206() { let client = http_client(); let keys = Keys::generate(); - let mp4 = build_test_mp4(); + let mp4 = build_real_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 url = format!("{}/media", relay_http_url()); let resp = client .put(&url) .header("Authorization", blossom_auth_header(&auth)) @@ -357,7 +397,6 @@ async fn test_video_range_request_206() { .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. @@ -366,12 +405,12 @@ async fn test_video_range_request_206() { async fn test_video_range_request_416() { let client = http_client(); let keys = Keys::generate(); - let mp4 = build_test_mp4(); + let mp4 = build_real_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 url = format!("{}/media", relay_http_url()); let resp = client .put(&url) .header("Authorization", blossom_auth_header(&auth)) @@ -390,7 +429,11 @@ async fn test_video_range_request_416() { .get(blob_url) .header( "Range", - format!("bytes={}-{}", mp4.len() + 1000, mp4.len() + 2000), + format!( + "bytes={}-{}", + desc["size"].as_u64().unwrap() + 1000, + desc["size"].as_u64().unwrap() + 2000 + ), ) .send() .await @@ -408,8 +451,8 @@ async fn test_video_range_request_416() { #[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 mp4 = build_real_test_mp4(); + let url = format!("{}/media", relay_http_url()); let resp = client .put(&url) @@ -496,11 +539,11 @@ async fn test_video_poster_imeta_accepted_via_ws() { assert!(resp.status().is_success(), "channel creation failed"); // 2. Upload video - let mp4 = build_test_mp4(); + let mp4 = build_real_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())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(&video_auth)) .header("X-SHA-256", &video_sha) .header("Content-Type", "video/mp4") @@ -510,6 +553,7 @@ async fn test_video_poster_imeta_accepted_via_ws() { .unwrap(); assert_eq!(video_resp.status(), StatusCode::OK, "video upload failed"); let video_desc: serde_json::Value = video_resp.json().await.unwrap(); + let published_video_sha = video_desc["sha256"].as_str().unwrap().to_string(); let video_size = video_desc["size"].as_u64().unwrap(); // 3. Upload poster (tiny JPEG) @@ -517,7 +561,7 @@ async fn test_video_poster_imeta_accepted_via_ws() { 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())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(&poster_auth)) .header("X-SHA-256", &poster_sha) .body(poster.to_vec()) @@ -525,6 +569,8 @@ async fn test_video_poster_imeta_accepted_via_ws() { .await .unwrap(); assert_eq!(poster_resp.status(), StatusCode::OK, "poster upload failed"); + let poster_desc: serde_json::Value = poster_resp.json().await.unwrap(); + let published_poster_sha = poster_desc["sha256"].as_str().unwrap(); // 4. Send message with imeta referencing both video and poster let mut ws = BuzzTestClient::connect(&relay_ws_url(), &keys) @@ -534,17 +580,17 @@ async fn test_video_poster_imeta_accepted_via_ws() { let base = relay_http_url(); let event = EventBuilder::new( Kind::from(9), - format!("![video]({base}/media/{video_sha}.mp4)"), + format!("![video]({base}/media/{published_video_sha}.mp4)"), ) .tags(vec![ Tag::parse(["h", &channel_id]).unwrap(), Tag::parse([ "imeta", - &format!("url {base}/media/{video_sha}.mp4"), + &format!("url {base}/media/{published_video_sha}.mp4"), "m video/mp4", - &format!("x {video_sha}"), + &format!("x {published_video_sha}"), &format!("size {video_size}"), - &format!("image {base}/media/{poster_sha}.jpg"), + &format!("image {base}/media/{published_poster_sha}.jpg"), ]) .unwrap(), ]) @@ -595,11 +641,11 @@ async fn test_video_poster_imeta_rejects_video_as_poster() { assert!(resp.status().is_success()); // 2. Upload video - let mp4 = build_test_mp4(); + let mp4 = build_real_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())) + .put(format!("{}/media", relay_http_url())) .header("Authorization", blossom_auth_header(&video_auth)) .header("X-SHA-256", &video_sha) .header("Content-Type", "video/mp4") @@ -609,6 +655,7 @@ async fn test_video_poster_imeta_rejects_video_as_poster() { .unwrap(); assert_eq!(video_resp.status(), StatusCode::OK); let video_desc: serde_json::Value = video_resp.json().await.unwrap(); + let published_video_sha = video_desc["sha256"].as_str().unwrap(); let video_size = video_desc["size"].as_u64().unwrap(); // 3. Send message with imeta `image` pointing to the VIDEO (not an image) @@ -622,12 +669,12 @@ async fn test_video_poster_imeta_rejects_video_as_poster() { Tag::parse(["h", &channel_id]).unwrap(), Tag::parse([ "imeta", - &format!("url {base}/media/{video_sha}.mp4"), + &format!("url {base}/media/{published_video_sha}.mp4"), "m video/mp4", - &format!("x {video_sha}"), + &format!("x {published_video_sha}"), &format!("size {video_size}"), // BAD: image field points to the video itself (.mp4 extension) - &format!("image {base}/media/{video_sha}.mp4"), + &format!("image {base}/media/{published_video_sha}.mp4"), ]) .unwrap(), ]) diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 1950b2e29..9ddf32f0c 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -235,10 +235,11 @@ fn sign_blossom_upload_auth( sha256: &str, expiry_secs: u64, base_url: &str, + verb: &str, ) -> Result { let now = Timestamp::now().as_secs(); let mut tags = vec![ - Tag::parse(vec!["t", "upload"]).map_err(|e| e.to_string())?, + Tag::parse(vec!["t", verb]).map_err(|e| e.to_string())?, Tag::parse(vec!["x", sha256]).map_err(|e| e.to_string())?, Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()]) .map_err(|e| e.to_string())?, @@ -246,7 +247,7 @@ fn sign_blossom_upload_auth( if let Some(domain) = extract_server_authority(base_url) { tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?); } - EventBuilder::new(Kind::from(24242), "Upload buzz-media") + EventBuilder::new(Kind::from(24242), format!("{verb} buzz-media")) .tags(tags) .sign_with_keys(keys) .map_err(|e| e.to_string()) @@ -274,9 +275,13 @@ async fn do_upload( 300 }; let base_url = relay_api_base_url_with_override(state); + let is_media = + mime.starts_with("image/") || mime.starts_with("video/") || mime.starts_with("audio/"); + let verb = if is_media { "media" } else { "upload" }; + let route = if is_media { "/media" } else { "/upload" }; let auth_event = { let keys = state.signing_keys()?; - sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)? + sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url, verb)? }; let auth_header = format!( @@ -285,7 +290,7 @@ async fn do_upload( ); let req = state .http_client - .put(format!("{base_url}/media/upload")) + .put(format!("{base_url}{route}")) .header("Authorization", &auth_header) .header("Content-Type", mime) .header("X-SHA-256", &sha256); diff --git a/desktop/src-tauri/src/commands/media_transcode.rs b/desktop/src-tauri/src/commands/media_transcode.rs index f4f96cf6e..edb55aeb5 100644 --- a/desktop/src-tauri/src/commands/media_transcode.rs +++ b/desktop/src-tauri/src/commands/media_transcode.rs @@ -173,6 +173,16 @@ pub(super) fn transcode_to_mp4( .arg("-i") .arg(source) // OsStr — handles non-UTF-8 paths on Unix .args([ + "-map", + "0:v:0", + "-map", + "0:a:0?", + "-map_metadata", + "-1", + "-map_chapters", + "-1", + "-sn", + "-dn", "-c:v", "libx264", "-preset", @@ -187,6 +197,8 @@ pub(super) fn transcode_to_mp4( "128k", "-movflags", "+faststart", + "-metadata", + "encoder=", ]) .arg(&output) .stdout(std::process::Stdio::null()) @@ -232,7 +244,16 @@ pub(super) fn transcode_heic_to_jpeg( .args(["-y", "-loglevel", "error"]) // suppress progress spam — prevents stderr pipe deadlock .arg("-i") .arg(source) // OsStr — handles non-UTF-8 paths on Unix - .args(["-frames:v", "1", "-q:v", "2"]) + .args([ + "-map", + "0:v:0", + "-map_metadata", + "-1", + "-frames:v", + "1", + "-q:v", + "2", + ]) .arg(&output) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()), diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt index b3eebf5dc..379404ff3 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt @@ -175,11 +175,21 @@ class MainActivity : FlutterActivity() { muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4) val trackIndices = mutableMapOf() + var copiedVideo = false + var copiedAudio = false for (i in 0 until extractor.trackCount) { val format = extractor.getTrackFormat(i) + val mime = format.getString(android.media.MediaFormat.KEY_MIME) ?: continue + val isVideo = mime.startsWith("video/") + val isAudio = mime.startsWith("audio/") + if ((!isVideo && !isAudio) || (isVideo && copiedVideo) || (isAudio && copiedAudio)) { + continue + } val newIndex = muxer.addTrack(format) trackIndices[i] = newIndex extractor.selectTrack(i) + copiedVideo = copiedVideo || isVideo + copiedAudio = copiedAudio || isAudio } muxer.start() @@ -189,7 +199,11 @@ class MainActivity : FlutterActivity() { while (true) { val sampleSize = extractor.readSampleData(buffer, 0) if (sampleSize < 0) break - val muxerTrack = trackIndices[extractor.sampleTrackIndex]!! + val muxerTrack = trackIndices[extractor.sampleTrackIndex] + if (muxerTrack == null) { + extractor.advance() + continue + } bufferInfo.offset = 0 bufferInfo.size = sampleSize bufferInfo.presentationTimeUs = extractor.sampleTime diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c1bc7a742..af136e229 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -177,6 +177,7 @@ import UserNotifications exportSession.outputURL = outputURL exportSession.outputFileType = .mp4 exportSession.shouldOptimizeForNetworkUse = true + exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing() exportSession.exportAsynchronously { switch exportSession.status { diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 0e0f37447..b2103ac2a 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -12,7 +12,7 @@ import 'package:pointycastle/digests/sha256.dart'; import 'media_auth.dart'; import 'relay_provider.dart'; -const _mediaUploadPath = '/media/upload'; +const _mediaUploadPath = '/media'; const _mediaUploadPlatformChannelName = 'buzz/media_upload'; const _sanitizeImageForUploadMethod = 'sanitizeImageForUpload'; const _transcodeVideoToMp4Method = 'transcodeVideoToMp4'; @@ -309,7 +309,7 @@ class MediaUploadService { final expiration = (_now().millisecondsSinceEpoch ~/ 1000) + _uploadAuthLifetimeSeconds; final tags = >[ - ['t', 'upload'], + ['t', 'media'], ['x', sha256], ['expiration', '$expiration'], if (extractServerAuthority(_baseUrl) case final authority?) @@ -318,7 +318,7 @@ class MediaUploadService { return nostr.Event.from( kind: _uploadAuthKind, - content: 'Upload buzz-media', + content: 'Process buzz-media', tags: tags, secretKey: privkeyHex, verify: false, diff --git a/mobile/lib/shared/relay/relay_client.dart b/mobile/lib/shared/relay/relay_client.dart index 975655a7a..6c25e1965 100644 --- a/mobile/lib/shared/relay/relay_client.dart +++ b/mobile/lib/shared/relay/relay_client.dart @@ -6,7 +6,7 @@ import 'package:http/http.dart' as http; /// WebSocket. This client now exists only to provide a base URL (and a /// shared HTTP client) for the media upload endpoint, which is the one /// remaining HTTP path because Blossom uses kind:24242 NIP-98 auth on a -/// regular HTTP POST. +/// regular HTTP PUT. class RelayClient { final String baseUrl; final http.Client _http; @@ -20,7 +20,7 @@ class RelayClient { /// Fully-qualified URL for the relay's Blossom-style media upload endpoint. String get mediaUploadUrl { final base = Uri.parse(baseUrl); - return base.resolve('/media/upload').toString(); + return base.resolve('/media').toString(); } void dispose() => _http.close(); diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index ceb4f5b8a..ad70c5754 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -360,7 +360,7 @@ void main() { expect(capturedRequest, isNotNull); expect( capturedRequest!.url.toString(), - 'https://relay.example:8443/media/upload', + 'https://relay.example:8443/media', ); expect(capturedRequest!.headers['Content-Type'], 'image/png'); expect(capturedRequest!.headers['X-SHA-256'], isNotEmpty); @@ -380,7 +380,7 @@ void main() { expect(authEvent['kind'], 24242); expect(authEvent['pubkey'], keychain.public); - expect(tags, anyElement(equals(['t', 'upload']))); + expect(tags, anyElement(equals(['t', 'media']))); expect( tags, anyElement( diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 3b2db4e4f..e0c16d03b 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -40,11 +40,12 @@ if [[ -f ".env" ]]; then set +o allexport else # Use defaults matching docker-compose.yml - export DATABASE_URL="postgres://buzz:buzz_dev@localhost:5432/buzz" + export POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-buzz_dev}" + export DATABASE_URL="postgres://buzz:${POSTGRES_PASSWORD}@localhost:5432/buzz" export PGHOST=localhost export PGPORT=5432 export PGUSER=buzz - export PGPASSWORD=buzz_dev + export PGPASSWORD="${POSTGRES_PASSWORD}" export PGDATABASE=buzz export REDIS_URL="redis://localhost:6379" fi @@ -112,9 +113,15 @@ run_integration_tests() { run_test_step "buzz-db tests" \ cargo test -p buzz-db -- --nocapture - run_test_step "buzz-auth integration tests" \ - cargo test -p buzz-auth --test '*' -- --nocapture 2>/dev/null || \ + shopt -s nullglob + local auth_integration_tests=(crates/buzz-auth/tests/*.rs) + shopt -u nullglob + if (( ${#auth_integration_tests[@]} > 0 )); then + run_test_step "buzz-auth integration tests" \ + cargo test -p buzz-auth --test '*' -- --nocapture + else run_test_step "buzz-auth (no integration tests found)" true + fi run_test_step "workspace integration tests" \ cargo test --test '*' -- --nocapture 2>/dev/null || \