use std::net::{IpAddr, SocketAddr}; use std::time::{SystemTime, UNIX_EPOCH}; use aether_contracts::ExecutionResult; use aether_data_contracts::repository::video_tasks::{ StoredVideoTask, VideoTaskQueryFilter, VideoTaskStatus, }; use axum::body::Body; use axum::extract::{Path, Query, State}; use axum::response::IntoResponse; use axum::response::Redirect; use axum::Json; use serde::Deserialize; use serde_json::{json, Value}; mod cancel; use super::query::VideoTaskVideoSource; use super::{ read_video_task_detail, read_video_task_page, read_video_task_stats, read_video_task_video_source, }; use crate::{AppState, GatewayError}; pub(crate) use self::cancel::{ cancel_video_task_record, cancel_video_task_record_for_user, CancelVideoTaskError, }; #[derive(Debug, Deserialize)] pub(crate) struct ListVideoTasksQuery { pub(crate) status: Option, pub(crate) user_id: Option, pub(crate) model: Option, pub(crate) client_api_format: Option, pub(crate) page: Option, pub(crate) page_size: Option, } pub(crate) async fn list_video_tasks( State(state): State, Query(query): Query, ) -> Result, axum::response::Response> { let filter = parse_filter(&query)?; let response = read_video_task_page( &state, &filter, query.page.unwrap_or(1), query.page_size.unwrap_or(20), ) .await .map_err(IntoResponse::into_response)?; Ok(Json(response)) } pub(crate) async fn get_video_task_stats( State(state): State, Query(query): Query, ) -> Result, axum::response::Response> { let filter = parse_filter(&query)?; let response = read_video_task_stats(&state, &filter, current_unix_secs()) .await .map_err(IntoResponse::into_response)?; Ok(Json(response)) } pub(crate) async fn get_video_task_detail( State(state): State, Path(task_id): Path, ) -> Result, axum::response::Response> { let task = read_video_task_detail(&state, &task_id) .await .map_err(IntoResponse::into_response)?; match task { Some(task) => Ok(Json(task)), None => Err(( axum::http::StatusCode::NOT_FOUND, Json(json!({ "error": { "message": "Video task not found", } })), ) .into_response()), } } pub(crate) async fn cancel_video_task( State(state): State, Path(task_id): Path, ) -> Result, axum::response::Response> { let stored = cancel_video_task_record(&state, &task_id) .await .map_err(|err| match err { CancelVideoTaskError::NotFound => ( axum::http::StatusCode::NOT_FOUND, Json(json!({ "error": { "message": "Video task not found", } })), ) .into_response(), CancelVideoTaskError::InvalidStatus(status) => ( axum::http::StatusCode::BAD_REQUEST, Json(json!({ "error": { "message": format!( "Cannot cancel task with status: {}", video_task_status_name(status), ), } })), ) .into_response(), CancelVideoTaskError::Response(response) => response, CancelVideoTaskError::Gateway(err) => err.into_response(), })?; Ok(Json(json!({ "id": stored.id, "status": "cancelled", "message": "Task cancelled successfully", }))) } pub(crate) async fn get_video_task_video( State(state): State, Path(task_id): Path, ) -> Result { let Some(source) = read_video_task_video_source(&state, &task_id) .await .map_err(IntoResponse::into_response)? else { return Err(( axum::http::StatusCode::NOT_FOUND, Json(json!({ "error": { "message": "Video task or video not found", } })), ) .into_response()); }; build_video_task_video_response(&state, &task_id, source) .await .map_err(IntoResponse::into_response) } pub(crate) async fn build_video_task_video_response( _state: &AppState, task_id: &str, source: VideoTaskVideoSource, ) -> Result { match source { VideoTaskVideoSource::Redirect { url } => { resolve_public_video_target(&url).await?; Ok(Redirect::temporary(url.as_str()).into_response()) } VideoTaskVideoSource::Proxy { url, header_name, header_value, filename, } => proxy_video_stream(task_id, &url, &header_name, &header_value, &filename).await, } } fn parse_filter( query: &ListVideoTasksQuery, ) -> Result { let status = match query.status.as_deref() { Some(value) => Some(VideoTaskStatus::from_database(value).map_err(|err| { ( axum::http::StatusCode::BAD_REQUEST, Json(json!({ "error": { "message": err.to_string(), } })), ) .into_response() })?), None => None, }; Ok(VideoTaskQueryFilter { user_id: query.user_id.clone(), status, model_substring: query.model.clone(), client_api_format: query.client_api_format.clone(), }) } fn current_unix_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } fn video_task_status_name(status: VideoTaskStatus) -> &'static str { match status { VideoTaskStatus::Pending => "pending", VideoTaskStatus::Submitted => "submitted", VideoTaskStatus::Queued => "queued", VideoTaskStatus::Processing => "processing", VideoTaskStatus::Completed => "completed", VideoTaskStatus::Failed => "failed", VideoTaskStatus::Cancelled => "cancelled", VideoTaskStatus::Expired => "expired", VideoTaskStatus::Deleted => "deleted", } } async fn proxy_video_stream( task_id: &str, url: &url::Url, header_name: &str, header_value: &str, filename: &str, ) -> Result { let target = resolve_public_video_target(url).await?; let client = build_pinned_video_client(&target)?; let response = client .get(url.clone()) .header(header_name, header_value) .send() .await .map_err(|err| GatewayError::UpstreamUnavailable { trace_id: task_id.to_string(), message: video_request_failure_message(&err).to_string(), })?; if response.status().is_redirection() { return Err(GatewayError::UpstreamUnavailable { trace_id: task_id.to_string(), message: format!( "video upstream redirect was rejected with HTTP {}", response.status() ), }); } if !response.status().is_success() { return Err(GatewayError::UpstreamUnavailable { trace_id: task_id.to_string(), message: format!("video upstream returned HTTP {}", response.status()), }); } let status = response.status(); // Do not copy the provider's Content-Length onto a newly wrapped stream. // Reqwest may decode transfer/content encodings and the provider controls // the declaration; forwarding a stale value would make the client-facing // HTTP framing disagree with the bytes produced by this Body. Axum/Hyper // will select safe framing for the actual stream. let upstream_headers = response.headers().clone(); let body = Body::from_stream(response.bytes_stream()); let mut outbound = axum::http::Response::builder() .status(status) .body(body) .map_err(|err| GatewayError::Internal(err.to_string()))?; apply_safe_video_response_metadata(outbound.headers_mut(), &upstream_headers, filename)?; Ok(outbound) } fn apply_safe_video_response_metadata( outbound: &mut axum::http::HeaderMap, upstream: &axum::http::HeaderMap, filename: &str, ) -> Result<(), GatewayError> { let content_type = upstream .get(axum::http::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .and_then(safe_video_content_type) .unwrap_or_else(|| axum::http::HeaderValue::from_static("application/octet-stream")); outbound.insert(axum::http::header::CONTENT_TYPE, content_type); outbound.insert( axum::http::header::CONTENT_DISPOSITION, axum::http::HeaderValue::from_str(&format!( "inline; filename=\"{}\"", safe_video_filename(filename) )) .map_err(|err| GatewayError::Internal(err.to_string()))?, ); outbound.remove(axum::http::header::CONTENT_LENGTH); outbound.insert( axum::http::header::CACHE_CONTROL, axum::http::HeaderValue::from_static("private, no-store"), ); outbound.insert( axum::http::header::X_CONTENT_TYPE_OPTIONS, axum::http::HeaderValue::from_static("nosniff"), ); Ok(()) } fn safe_video_content_type(raw_value: &str) -> Option { let media_type = raw_value.split(';').next()?.trim().to_ascii_lowercase(); let subtype = media_type.strip_prefix("video/")?; if subtype.is_empty() || !subtype.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!( byte, b'!' | b'#' | b'$' | b'&' | b'-' | b'^' | b'_' | b'.' | b'+' ) }) { return None; } axum::http::HeaderValue::from_str(raw_value).ok() } fn safe_video_filename(filename: &str) -> String { let filename = filename .chars() .take(255) .map(|character| { if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { character } else { '_' } }) .collect::(); if filename.is_empty() { "video.mp4".to_string() } else { filename } } struct ResolvedVideoTarget { host: String, addrs: Vec, } async fn resolve_public_video_target(url: &url::Url) -> Result { if !matches!(url.scheme(), "http" | "https") || !url.username().is_empty() || url.password().is_some() { return Err(video_target_rejected( "video URL must be an absolute HTTP(S) URL without credentials", )); } let port = url .port_or_known_default() .ok_or_else(|| video_target_rejected("video URL is missing a port"))?; let (host, addrs) = match url.host() { Some(url::Host::Ipv4(ip)) => (ip.to_string(), vec![SocketAddr::new(IpAddr::V4(ip), port)]), Some(url::Host::Ipv6(ip)) => (ip.to_string(), vec![SocketAddr::new(IpAddr::V6(ip), port)]), Some(url::Host::Domain(host)) if !host.is_empty() => { let addrs = aether_http::lookup_host_with_limits( host, port, aether_http::DEFAULT_DNS_LOOKUP_TIMEOUT, ) .await .map_err(|_| video_target_rejected("video URL DNS resolution failed"))?; (host.to_string(), addrs) } _ => return Err(video_target_rejected("video URL is missing a host")), }; if addrs.is_empty() || addrs .iter() .any(|addr| aether_http::is_private_or_reserved_ip(addr.ip())) { return Err(video_target_rejected( "video URL resolves to a private or reserved address", )); } Ok(ResolvedVideoTarget { host, addrs }) } fn build_pinned_video_client( target: &ResolvedVideoTarget, ) -> Result { let mut builder = aether_http::apply_http_client_config( reqwest::Client::builder() .no_proxy() .redirect(reqwest::redirect::Policy::none()), &aether_http::HttpClientConfig { connect_timeout_ms: Some(10_000), request_timeout_ms: Some(300_000), http2_adaptive_window: true, ..aether_http::HttpClientConfig::default() }, ); if target.host.parse::().is_err() { builder = builder.resolve_to_addrs(&target.host, &target.addrs); } builder .build() .map_err(|_| GatewayError::Internal("video HTTP client initialization failed".to_string())) } fn video_target_rejected(message: &str) -> GatewayError { GatewayError::Client { status: axum::http::StatusCode::BAD_GATEWAY, message: message.to_string(), } } fn video_request_failure_message(error: &reqwest::Error) -> &'static str { if error.is_timeout() { "video upstream request timed out" } else if error.is_connect() { "video upstream connection failed" } else if error.is_body() || error.is_decode() { "video upstream response failed" } else { "video upstream request failed" } } #[cfg(test)] mod tests { use axum::response::IntoResponse; use super::{ apply_safe_video_response_metadata, build_video_task_video_response, resolve_public_video_target, safe_video_content_type, safe_video_filename, VideoTaskVideoSource, }; use crate::AppState; #[tokio::test] async fn video_redirect_response_accepts_public_target() { let state = AppState::new().expect("gateway state should build"); let target = "https://8.8.8.8/video.mp4"; let response = build_video_task_video_response( &state, "task-public-redirect", VideoTaskVideoSource::Redirect { url: url::Url::parse(target).expect("public target should parse"), }, ) .await .expect("public redirect should build"); assert_eq!( response.status(), axum::http::StatusCode::TEMPORARY_REDIRECT ); assert_eq!( response .headers() .get(axum::http::header::LOCATION) .and_then(|value| value.to_str().ok()), Some(target) ); } #[tokio::test] async fn video_redirect_response_rejects_private_and_reserved_targets() { let state = AppState::new().expect("gateway state should build"); for raw_url in [ "http://127.0.0.1/video.mp4", "http://169.254.169.254/latest/meta-data", "http://10.0.0.1/video.mp4", "http://[::1]/video.mp4", ] { let error = build_video_task_video_response( &state, "task-rejected-redirect", VideoTaskVideoSource::Redirect { url: url::Url::parse(raw_url).expect("target should parse"), }, ) .await .expect_err("private or reserved redirect target should be rejected"); assert_eq!( error.into_response().status(), axum::http::StatusCode::BAD_GATEWAY, "unexpected status for {raw_url}" ); } } #[tokio::test] async fn video_target_resolution_rejects_private_and_reserved_ip_literals() { for raw_url in [ "http://127.0.0.1/video.mp4", "http://169.254.169.254/latest/meta-data", "http://10.0.0.1/video.mp4", "http://[::1]/video.mp4", "http://[::ffff:127.0.0.1]/video.mp4", ] { let url = url::Url::parse(raw_url).unwrap(); assert!( resolve_public_video_target(&url).await.is_err(), "target should be rejected: {raw_url}" ); } } #[tokio::test] async fn video_target_resolution_accepts_public_ip_literals() { for raw_url in [ "https://8.8.8.8/video.mp4", "https://[2606:4700:4700::1111]/video.mp4", ] { let url = url::Url::parse(raw_url).unwrap(); assert!( resolve_public_video_target(&url).await.is_ok(), "target should be accepted: {raw_url}" ); } } #[test] fn video_response_metadata_rejects_active_content_and_sanitizes_filename() { assert!(safe_video_content_type("video/mp4").is_some()); assert!(safe_video_content_type("video/webm; charset=binary").is_some()); assert!(safe_video_content_type("video/").is_none()); assert!(safe_video_content_type("video/; charset=binary").is_none()); assert!(safe_video_content_type("text/html").is_none()); assert!(safe_video_content_type("video/mp4\r\nx-test: injected").is_none()); assert_eq!( safe_video_filename("video_123.mp4\"; filename=\"attack.html"), "video_123.mp4___filename__attack.html" ); assert_eq!(safe_video_filename(&"x".repeat(1024)).len(), 255); let mut upstream = axum::http::HeaderMap::new(); upstream.insert( axum::http::header::CONTENT_TYPE, axum::http::HeaderValue::from_static("text/html"), ); upstream.insert( axum::http::header::CONTENT_LENGTH, axum::http::HeaderValue::from_static("999999"), ); let mut outbound = upstream.clone(); apply_safe_video_response_metadata( &mut outbound, &upstream, "video.mp4\"; filename=\"attack.html", ) .expect("video metadata should build"); assert_eq!( outbound .get(axum::http::header::CONTENT_TYPE) .and_then(|value| value.to_str().ok()), Some("application/octet-stream") ); assert!(outbound.get(axum::http::header::CONTENT_LENGTH).is_none()); assert_eq!( outbound .get(axum::http::header::X_CONTENT_TYPE_OPTIONS) .and_then(|value| value.to_str().ok()), Some("nosniff") ); assert_eq!( outbound .get(axum::http::header::CONTENT_DISPOSITION) .and_then(|value| value.to_str().ok()), Some("inline; filename=\"video.mp4___filename__attack.html\"") ); } }