send: stream media downloads with hard size caps

download_to_temp now uses download_media_limited: non-photos abort the
moment the 10 MiB upload cap is crossed mid-stream (no more full-body
buffering before the size check), photos cap at the 512 MiB decode
budget, and the ugoira frame zip gets a 512 MiB cap. MediaTooLarge
routes to the existing smaller-URL fallback.
This commit is contained in:
2026-08-08 20:03:14 +08:00
parent f6df3e28cb
commit d61dba5096
3 changed files with 16 additions and 9 deletions
+1 -1
View File
@@ -222,7 +222,7 @@ impl PixivAPI {
let Some(zip_url) = zip_url else { let Some(zip_url) = zip_url else {
return Ok(None); return Ok(None);
}; };
let zip_bytes = crate::site::download_media(&zip_url) let zip_bytes = crate::site::download_media_limited(&zip_url, 512 * 1024 * 1024)
.await .await
.map_err(|e| match e { .map_err(|e| match e {
FetchError::Http(e) => PixivError::Http(e), FetchError::Http(e) => PixivError::Http(e),
+3 -2
View File
@@ -26,8 +26,9 @@ pub const PHOTO_TARGET_DIMENSION_SUM: u32 = 9900;
/// to a smaller media URL instead. /// to a smaller media URL instead.
pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024; pub const MAX_UPLOAD_BYTES: u64 = 10 * 1024 * 1024;
/// Decode budget (bytes): a larger intermediate buffer is not worth the peak /// Decode budget (bytes): a larger intermediate buffer is not worth the peak
/// memory; the photo degrades to the smaller URL instead. /// memory; the photo degrades to the smaller URL instead. Also the cap for
const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024; /// downloading photos in the send fallback (they must be downloaded whole).
pub(crate) const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024;
/// JPEG output quality (1-100). /// JPEG output quality (1-100).
const JPEG_QUALITY: u8 = 90; const JPEG_QUALITY: u8 = 90;
+12 -6
View File
@@ -472,24 +472,30 @@ async fn download_to_temp(item: &MediaItemPayload) -> Result<NamedTempFile, Fall
| MediaItemPayload::Video { media, .. } | MediaItemPayload::Video { media, .. }
| MediaItemPayload::Animation { media, .. } => media, | MediaItemPayload::Animation { media, .. } => media,
}; };
let bytes = match x_media::site::download_media(media_url).await { // Photos are downloaded even over the upload cap so `prepare_photo` can
// downscale / transcode them (cap = decode budget); videos/animations
// abort as soon as the upload cap is crossed mid-stream.
let limit = if matches!(item, MediaItemPayload::Photo { .. }) {
photo::MAX_DECODE_BYTES
} else {
MAX_UPLOAD_BYTES + 1
};
let bytes = match x_media::site::download_media_limited(media_url, limit).await {
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(FetchError::Http(_)) => { Err(FetchError::Http(_)) => {
return Err(FallbackError::Retryable { return Err(FallbackError::Retryable {
delay_seconds: retry_delay_seconds(0), delay_seconds: retry_delay_seconds(0),
}); });
} }
Err(FetchError::TooLarge) => {
return Err(FallbackError::MediaTooLarge);
}
Err(e) => { Err(e) => {
return Err(FallbackError::Permanent { return Err(FallbackError::Permanent {
message: format!("download failed: {e}"), message: format!("download failed: {e}"),
}); });
} }
}; };
// Photos are downloaded even over the cap so `prepare_photo` can
// downscale / transcode them; only videos/animations short-circuit.
if !matches!(item, MediaItemPayload::Photo { .. }) && bytes.len() as u64 > MAX_UPLOAD_BYTES {
return Err(FallbackError::MediaTooLarge);
}
let ext = sniff_ext(&bytes); let ext = sniff_ext(&bytes);
let mut file = tempfile::Builder::new() let mut file = tempfile::Builder::new()
.suffix(&format!(".{ext}")) .suffix(&format!(".{ext}"))