fix(media): keep legacy route media-only

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Atish Patel
2026-07-16 15:39:47 -05:00
co-authored by Codex
parent 39416b4eef
commit 65fc6c7fb9
3 changed files with 99 additions and 30 deletions
+55 -18
View File
@@ -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<Item = Result<Bytes, axum::Error>> + 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.
+2 -2
View File
@@ -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.
@@ -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<u8> = (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() {