diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index ce8882826..b30828a5c 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -17,8 +17,9 @@ use crate::validation::{ }; /// 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. +/// preserves exact non-media bytes, and `Legacy` keeps the historical upload +/// authorization while otherwise enforcing the same media-only policy as +/// `Media`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UploadRouteMode { Media, @@ -97,22 +98,7 @@ pub async fn process_streaming_ingest( 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_route_policy(mode, source_probe.as_ref())?; enforce_source_size(source_probe.as_ref(), source_size, config)?; @@ -307,6 +293,21 @@ pub async fn process_streaming_ingest( )) } +fn enforce_route_policy( + mode: UploadRouteMode, + source_probe: Option<&crate::sanitize::MediaProbe>, +) -> Result<(), MediaError> { + match (mode, source_probe) { + (UploadRouteMode::Media | UploadRouteMode::Legacy, None) => Err( + MediaError::UnsupportedMedia("non-media attachment".to_string()), + ), + (UploadRouteMode::Upload, Some(probe)) => { + Err(MediaError::UnsupportedMedia(probe.mime.clone())) + } + _ => Ok(()), + } +} + async fn stream_source( body_stream: impl futures_core::Stream> + Send + 'static, path: &std::path::Path, @@ -970,6 +971,20 @@ fn build_descriptor( mod tests { use super::*; + fn image_probe() -> crate::sanitize::MediaProbe { + crate::sanitize::MediaProbe { + class: crate::sanitize::MediaClass::Image, + mime: "image/jpeg".to_string(), + ext: "jpg".to_string(), + video_codec: None, + audio_codec: None, + width: Some(1), + height: Some(1), + duration_secs: None, + frame_count: Some(1), + } + } + fn test_config() -> MediaConfig { MediaConfig { s3_endpoint: String::new(), @@ -1123,6 +1138,28 @@ mod tests { assert_eq!(detect("connection reset"), std::io::ErrorKind::Other); } + #[test] + fn route_policy_keeps_legacy_media_only() { + let probe = image_probe(); + + assert!(enforce_route_policy(UploadRouteMode::Media, Some(&probe)).is_ok()); + assert!(enforce_route_policy(UploadRouteMode::Legacy, Some(&probe)).is_ok()); + assert!(enforce_route_policy(UploadRouteMode::Upload, None).is_ok()); + + assert!(matches!( + enforce_route_policy(UploadRouteMode::Media, None), + Err(MediaError::UnsupportedMedia(_)) + )); + assert!(matches!( + enforce_route_policy(UploadRouteMode::Legacy, None), + Err(MediaError::UnsupportedMedia(_)) + )); + assert!(matches!( + enforce_route_policy(UploadRouteMode::Upload, Some(&probe)), + Err(MediaError::UnsupportedMedia(mime)) if mime == "image/jpeg" + )); + } + #[test] fn test_build_descriptor_no_meta() { // When meta is None, all optional fields should be None. diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 2967225eb..60a9223e2 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -3,7 +3,7 @@ //! Routes: //! PUT /media — BUD-05 sanitizing media upload //! PUT /upload — BUD-02 exact-byte non-media upload -//! PUT /media/upload — temporary legacy compatibility route +//! PUT /media/upload — temporary media-only legacy auth route //! GET /media/{sha256_ext} — BUD-01 serve blob //! HEAD /media/{sha256_ext} — BUD-01 existence check @@ -282,7 +282,7 @@ async fn upload_attribution( }) } -/// PUT /media, /upload, or the temporary /media/upload compatibility route. +/// PUT /media, /upload, or the temporary media-only /media/upload route. /// /// Auth is validated via the [`AuthenticatedUpload`] extractor BEFORE the body /// is read, preventing unauthenticated clients from forcing body buffering. diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index a423d6dd7..69bcead64 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -56,6 +56,19 @@ async fn upload_file(client: &Client, keys: &Keys, body: &[u8]) -> reqwest::Resp .expect("file upload request") } +async fn upload_legacy_media(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!("{}/media/upload", relay_http_url())) + .header("Authorization", blossom_auth_header(&auth)) + .header("X-SHA-256", &sha256) + .body(body.to_vec()) + .send() + .await + .expect("legacy media upload request") +} + fn blossom_auth_header(event: &nostr::Event) -> String { format!( "Nostr {}", @@ -486,19 +499,38 @@ 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"); + let resp = upload_legacy_media(&client, &keys, &jpeg).await; assert_eq!(resp.status(), 200); } +#[tokio::test] +#[ignore] +async fn test_legacy_media_upload_alias_rejects_pdf() { + let client = http_client(); + let keys = Keys::generate(); + let pdf = b"%PDF-1.4 fake pdf content here for testing"; + let resp = upload_legacy_media(&client, &keys, pdf).await; + assert_eq!( + resp.status(), + 415, + "legacy media route must reject recognized non-media files" + ); +} + +#[tokio::test] +#[ignore] +async fn test_legacy_media_upload_alias_rejects_opaque_binary() { + let client = http_client(); + let keys = Keys::generate(); + let opaque: Vec = (0..1000).map(|i| (i * 37 % 256) as u8).collect(); + let resp = upload_legacy_media(&client, &keys, &opaque).await; + assert_eq!( + resp.status(), + 415, + "legacy media route must reject unknown non-media files" + ); +} + #[tokio::test] #[ignore] async fn test_concurrent_upload_same_file() {