mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
## Summary - apply the existing 2160×3840 video resolution envelope independent of orientation - accept portrait recordings whose short/long edges fit that envelope - retain rejection coverage for either edge exceeding its limit - make the relay error describe the orientation-independent limits ## Why The iOS simulator produces portrait H.264 recordings such as 1206×2622. The relay previously checked `width <= 3840 && height <= 2160`, so the equivalent portrait dimensions were rejected while landscape dimensions were accepted. ## Validation - `bin/just ci` - `cargo test -p buzz-media` (120 passed; one MinIO integration test ignored by its existing live-service guard) - `cargo clippy -p buzz-media --all-targets -- -D warnings` - exact blocked artifact `/Users/judeedwards/.buzz/.scratch/pr5874-mongo-evidence/native/head-20260816T050317Z/video-bitexact.mp4` validated through `validate_video_file`: 1206×2622 accepted Signed-off-by: Carl <5f365698229751c0461f57bb03a4e93134e6e936bd7039ebe7b737282a43c754@buzz.block.builderlab.xyz> Co-authored-by: Carl <5f365698229751c0461f57bb03a4e93134e6e936bd7039ebe7b737282a43c754@buzz.block.builderlab.xyz>
223 lines
7.5 KiB
Rust
223 lines
7.5 KiB
Rust
//! Media error types.
|
|
|
|
use axum::http::StatusCode;
|
|
use axum::response::{IntoResponse, Response};
|
|
|
|
/// Errors from media operations.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum MediaError {
|
|
#[error("unknown content type")]
|
|
UnknownContentType,
|
|
#[error("disallowed content type: {0}")]
|
|
DisallowedContentType(String),
|
|
#[error("file too large: {size} bytes (max {max})")]
|
|
FileTooLarge { size: u64, max: u64 },
|
|
#[error("image dimensions too large")]
|
|
ImageTooLarge,
|
|
#[error("invalid image data")]
|
|
InvalidImage,
|
|
#[error("media contains metadata or a non-canonical metadata channel")]
|
|
MetadataForbidden,
|
|
#[error("invalid signature")]
|
|
InvalidSignature,
|
|
#[error("invalid auth event kind")]
|
|
InvalidAuthKind,
|
|
#[error("invalid auth verb")]
|
|
InvalidAuthVerb,
|
|
#[error("missing required tag: {0}")]
|
|
MissingTag(&'static str),
|
|
#[error("hash mismatch")]
|
|
HashMismatch,
|
|
#[error("server mismatch")]
|
|
ServerMismatch,
|
|
#[error("token expired")]
|
|
TokenExpired,
|
|
#[error("timestamp out of window")]
|
|
TimestampOutOfWindow,
|
|
#[error("storage error: {0}")]
|
|
StorageError(String),
|
|
#[error("internal error")]
|
|
Internal,
|
|
#[error("not found")]
|
|
NotFound,
|
|
#[error("missing authorization header")]
|
|
MissingAuth,
|
|
#[error("invalid authorization scheme")]
|
|
InvalidAuthScheme,
|
|
#[error("invalid base64 encoding")]
|
|
InvalidBase64,
|
|
#[error("invalid auth event")]
|
|
InvalidAuthEvent,
|
|
#[error("unauthorized")]
|
|
Unauthorized,
|
|
#[error("insufficient scope")]
|
|
InsufficientScope,
|
|
#[error("relay membership required")]
|
|
RelayMembershipRequired,
|
|
#[error("community writes are fenced")]
|
|
CommunityWriteFenced,
|
|
#[error("media service temporarily unavailable")]
|
|
ServiceUnavailable,
|
|
#[error("token revoked")]
|
|
TokenRevoked,
|
|
#[error("pubkey mismatch")]
|
|
PubkeyMismatch,
|
|
#[error("upload rate limit exceeded")]
|
|
UploadRateLimitExceeded,
|
|
#[error("upload concurrency limit reached")]
|
|
UploadConcurrencyLimitReached,
|
|
/// A video/audio track does not use the canonical H.264/AAC codecs.
|
|
#[error("unsupported media codec: only H.264 video and AAC audio are accepted")]
|
|
WrongCodec,
|
|
/// Video duration exceeds the 600-second limit.
|
|
#[error("video too long: duration exceeds 600 seconds")]
|
|
DurationTooLong,
|
|
/// Video resolution exceeds the 2160 short-edge / 3840 long-edge envelope.
|
|
#[error(
|
|
"video resolution too high: maximum is 2160 on the short edge and 3840 on the long edge"
|
|
)]
|
|
ResolutionTooHigh,
|
|
/// MP4 moov atom appears after mdat — not fast-start.
|
|
#[error("moov atom not at front of file (not fast-start)")]
|
|
MoovNotAtFront,
|
|
/// Container is not MP4 (e.g. MOV, MKV).
|
|
#[error("unsupported container: only MP4 is accepted")]
|
|
UnsupportedContainer,
|
|
/// MP4 metadata could not be parsed.
|
|
#[error("invalid video data")]
|
|
InvalidVideo,
|
|
/// I/O error during streaming upload.
|
|
#[error("io error: {0}")]
|
|
Io(String),
|
|
}
|
|
|
|
impl From<image::ImageError> for MediaError {
|
|
fn from(_: image::ImageError) -> Self {
|
|
Self::InvalidImage
|
|
}
|
|
}
|
|
|
|
impl From<s3::error::S3Error> for MediaError {
|
|
fn from(e: s3::error::S3Error) -> Self {
|
|
Self::StorageError(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<serde_json::Error> for MediaError {
|
|
fn from(e: serde_json::Error) -> Self {
|
|
Self::StorageError(e.to_string())
|
|
}
|
|
}
|
|
|
|
impl IntoResponse for MediaError {
|
|
fn into_response(self) -> Response {
|
|
let (status, msg) = match &self {
|
|
Self::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
|
Self::DisallowedContentType(_) => {
|
|
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
|
|
}
|
|
Self::FileTooLarge { .. } | Self::ImageTooLarge => {
|
|
(StatusCode::PAYLOAD_TOO_LARGE, self.to_string())
|
|
}
|
|
// All authentication failures return the same generic 401 to prevent oracle enumeration.
|
|
// InsufficientScope is intentionally 403 — it's an authorization (not authentication)
|
|
// failure and is safe to distinguish since it requires a valid identity first.
|
|
Self::MissingAuth
|
|
| Self::InvalidAuthScheme
|
|
| Self::InvalidBase64
|
|
| Self::InvalidAuthEvent
|
|
| Self::InvalidSignature
|
|
| Self::InvalidAuthKind
|
|
| Self::InvalidAuthVerb
|
|
| Self::TokenExpired
|
|
| Self::TimestampOutOfWindow
|
|
| Self::Unauthorized
|
|
| Self::TokenRevoked
|
|
| Self::PubkeyMismatch
|
|
| Self::HashMismatch
|
|
| Self::ServerMismatch
|
|
| Self::MissingTag(_) => {
|
|
tracing::warn!(error = %self, "authentication failed");
|
|
(
|
|
StatusCode::UNAUTHORIZED,
|
|
"authentication failed".to_string(),
|
|
)
|
|
}
|
|
Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()),
|
|
Self::RelayMembershipRequired | Self::CommunityWriteFenced => {
|
|
(StatusCode::FORBIDDEN, self.to_string())
|
|
}
|
|
Self::ServiceUnavailable => (StatusCode::SERVICE_UNAVAILABLE, self.to_string()),
|
|
Self::UploadRateLimitExceeded | Self::UploadConcurrencyLimitReached => {
|
|
(StatusCode::TOO_MANY_REQUESTS, self.to_string())
|
|
}
|
|
Self::UnknownContentType | Self::UnsupportedContainer | Self::WrongCodec => {
|
|
(StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string())
|
|
}
|
|
Self::DurationTooLong
|
|
| Self::ResolutionTooHigh
|
|
| Self::MoovNotAtFront
|
|
| Self::InvalidVideo
|
|
| Self::InvalidImage
|
|
| Self::MetadataForbidden => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
|
Self::Io(_) | Self::StorageError(_) | Self::Internal => {
|
|
tracing::error!(error = %self, "media storage error");
|
|
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".into())
|
|
}
|
|
};
|
|
(status, axum::Json(serde_json::json!({"error": msg}))).into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn serving_backend_failures_map_to_5xx_but_fences_remain_403() {
|
|
for error in [
|
|
MediaError::ServiceUnavailable,
|
|
MediaError::Internal,
|
|
MediaError::StorageError("backend".to_string()),
|
|
] {
|
|
assert!(error.into_response().status().is_server_error());
|
|
}
|
|
assert_eq!(
|
|
MediaError::CommunityWriteFenced.into_response().status(),
|
|
StatusCode::FORBIDDEN
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_media_maps_to_415() {
|
|
for error in [
|
|
MediaError::UnknownContentType,
|
|
MediaError::DisallowedContentType("audio/mpeg".to_string()),
|
|
MediaError::UnsupportedContainer,
|
|
MediaError::WrongCodec,
|
|
] {
|
|
assert_eq!(
|
|
error.into_response().status(),
|
|
StatusCode::UNSUPPORTED_MEDIA_TYPE
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_or_noncanonical_media_maps_to_422() {
|
|
for error in [
|
|
MediaError::InvalidImage,
|
|
MediaError::InvalidVideo,
|
|
MediaError::MetadataForbidden,
|
|
MediaError::MoovNotAtFront,
|
|
MediaError::DurationTooLong,
|
|
MediaError::ResolutionTooHigh,
|
|
] {
|
|
assert_eq!(
|
|
error.into_response().status(),
|
|
StatusCode::UNPROCESSABLE_ENTITY
|
|
);
|
|
}
|
|
}
|
|
}
|