mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 17:07:46 +08:00
Consolidate subscription usage policy enforcement, privacy-safe persistence, and gateway security hardening into one reviewable change. Includes bounded HTTP and execution envelopes, header and protocol guards, DNS and relay validation, authentication and secret projection hardening, secure backup/install paths, and regression coverage.
487 lines
16 KiB
Rust
487 lines
16 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
use aether_data_contracts::repository::video_tasks::{
|
|
StoredVideoTask, VideoTaskModelCount, VideoTaskQueryFilter, VideoTaskStatus,
|
|
VideoTaskStatusCount,
|
|
};
|
|
use serde::Serialize;
|
|
|
|
use crate::{AppState, GatewayError};
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub(crate) struct VideoTaskPageResponse {
|
|
pub(crate) items: Vec<StoredVideoTask>,
|
|
pub(crate) total: u64,
|
|
pub(crate) page: usize,
|
|
pub(crate) page_size: usize,
|
|
pub(crate) pages: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub(crate) struct VideoTaskStatsResponse {
|
|
pub(crate) total: u64,
|
|
pub(crate) by_status: BTreeMap<String, u64>,
|
|
pub(crate) by_model: BTreeMap<String, u64>,
|
|
pub(crate) today_count: u64,
|
|
pub(crate) processing_count: u64,
|
|
}
|
|
|
|
pub(crate) enum VideoTaskVideoSource {
|
|
Redirect {
|
|
url: url::Url,
|
|
},
|
|
Proxy {
|
|
url: url::Url,
|
|
header_name: String,
|
|
header_value: String,
|
|
filename: String,
|
|
},
|
|
}
|
|
|
|
pub(crate) async fn read_video_task_page(
|
|
state: &AppState,
|
|
filter: &VideoTaskQueryFilter,
|
|
page: usize,
|
|
page_size: usize,
|
|
) -> Result<VideoTaskPageResponse, GatewayError> {
|
|
let page = page.max(1);
|
|
let page_size = page_size.clamp(1, 100);
|
|
let total = state.count_video_tasks(filter).await?;
|
|
let offset = page_size.saturating_mul(page.saturating_sub(1));
|
|
let items = state
|
|
.list_video_task_page(filter, offset, page_size)
|
|
.await?;
|
|
let pages = if total == 0 {
|
|
0
|
|
} else {
|
|
((total as usize) + page_size - 1) / page_size
|
|
};
|
|
|
|
Ok(VideoTaskPageResponse {
|
|
items,
|
|
total,
|
|
page,
|
|
page_size,
|
|
pages,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn read_video_task_page_summary(
|
|
state: &AppState,
|
|
filter: &VideoTaskQueryFilter,
|
|
page: usize,
|
|
page_size: usize,
|
|
) -> Result<VideoTaskPageResponse, GatewayError> {
|
|
let page = page.max(1);
|
|
let page_size = page_size.clamp(1, 100);
|
|
let total = state.count_video_tasks(filter).await?;
|
|
let offset = page_size.saturating_mul(page.saturating_sub(1));
|
|
let items = state
|
|
.list_video_task_page_summary(filter, offset, page_size)
|
|
.await?;
|
|
let pages = if total == 0 {
|
|
0
|
|
} else {
|
|
((total as usize) + page_size - 1) / page_size
|
|
};
|
|
|
|
Ok(VideoTaskPageResponse {
|
|
items,
|
|
total,
|
|
page,
|
|
page_size,
|
|
pages,
|
|
})
|
|
}
|
|
|
|
pub(crate) async fn read_video_task_detail(
|
|
state: &AppState,
|
|
task_id: &str,
|
|
) -> Result<Option<StoredVideoTask>, GatewayError> {
|
|
state.find_video_task_by_id(task_id).await
|
|
}
|
|
|
|
pub(crate) async fn read_video_task_detail_for_user(
|
|
state: &AppState,
|
|
task_id: &str,
|
|
user_id: &str,
|
|
) -> Result<Option<StoredVideoTask>, GatewayError> {
|
|
state.find_video_task_by_id_for_user(task_id, user_id).await
|
|
}
|
|
|
|
pub(crate) async fn read_video_task_video_source(
|
|
state: &AppState,
|
|
task_id: &str,
|
|
) -> Result<Option<VideoTaskVideoSource>, GatewayError> {
|
|
let Some(task) = read_video_task_detail(state, task_id).await? else {
|
|
return Ok(None);
|
|
};
|
|
video_task_video_source_from_task(state, &task).await
|
|
}
|
|
|
|
pub(crate) async fn video_task_video_source_from_task(
|
|
state: &AppState,
|
|
task: &StoredVideoTask,
|
|
) -> Result<Option<VideoTaskVideoSource>, GatewayError> {
|
|
let Some(video_url) = task
|
|
.video_url
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.map(ToOwned::to_owned)
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let video_url = parse_video_url(&video_url)?;
|
|
|
|
if task.effective_api_format() != Some("gemini:video") {
|
|
return Ok(Some(VideoTaskVideoSource::Redirect { url: video_url }));
|
|
}
|
|
|
|
let Some(provider_id) = task.provider_id.as_deref() else {
|
|
return Err(GatewayError::Internal(
|
|
"video task is missing provider_id for proxied video".to_string(),
|
|
));
|
|
};
|
|
let Some(endpoint_id) = task.endpoint_id.as_deref() else {
|
|
return Err(GatewayError::Internal(
|
|
"video task is missing endpoint_id for proxied video".to_string(),
|
|
));
|
|
};
|
|
let Some(key_id) = task.key_id.as_deref() else {
|
|
return Err(GatewayError::Internal(
|
|
"video task is missing key_id for proxied video".to_string(),
|
|
));
|
|
};
|
|
|
|
let Some(transport) = state
|
|
.read_provider_transport_snapshot(provider_id, endpoint_id, key_id)
|
|
.await?
|
|
else {
|
|
return Err(GatewayError::Internal(
|
|
"provider transport snapshot is unavailable for proxied video".to_string(),
|
|
));
|
|
};
|
|
|
|
let endpoint_url = parse_video_url(transport.endpoint.base_url.trim()).map_err(|_| {
|
|
GatewayError::Internal("provider endpoint URL is invalid for proxied video".to_string())
|
|
})?;
|
|
if !video_urls_share_origin(&endpoint_url, &video_url) {
|
|
return Err(GatewayError::Client {
|
|
status: axum::http::StatusCode::BAD_GATEWAY,
|
|
message: "video URL origin does not match its provider endpoint".to_string(),
|
|
});
|
|
}
|
|
let api_key = transport.key.decrypted_api_key.trim();
|
|
if api_key.is_empty() {
|
|
return Err(GatewayError::Internal(
|
|
"provider transport key is unavailable for proxied video".to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(Some(VideoTaskVideoSource::Proxy {
|
|
url: video_url,
|
|
header_name: "x-goog-api-key".to_string(),
|
|
header_value: api_key.to_string(),
|
|
filename: format!("video_{}.mp4", task.id),
|
|
}))
|
|
}
|
|
|
|
fn parse_video_url(raw_url: &str) -> Result<url::Url, GatewayError> {
|
|
let url = url::Url::parse(raw_url.trim()).map_err(|_| GatewayError::Client {
|
|
status: axum::http::StatusCode::BAD_GATEWAY,
|
|
message: "video URL is invalid".to_string(),
|
|
})?;
|
|
if !matches!(url.scheme(), "http" | "https")
|
|
|| url.host_str().is_none()
|
|
|| !url.username().is_empty()
|
|
|| url.password().is_some()
|
|
{
|
|
return Err(GatewayError::Client {
|
|
status: axum::http::StatusCode::BAD_GATEWAY,
|
|
message: "video URL must be an absolute HTTP(S) URL without credentials".to_string(),
|
|
});
|
|
}
|
|
Ok(url)
|
|
}
|
|
|
|
fn video_urls_share_origin(left: &url::Url, right: &url::Url) -> bool {
|
|
left.scheme() == right.scheme()
|
|
&& left.host() == right.host()
|
|
&& left.port_or_known_default() == right.port_or_known_default()
|
|
}
|
|
|
|
pub(crate) async fn read_video_task_stats(
|
|
state: &AppState,
|
|
filter: &VideoTaskQueryFilter,
|
|
now_unix_secs: u64,
|
|
) -> Result<VideoTaskStatsResponse, GatewayError> {
|
|
let total = state.count_video_tasks(filter).await?;
|
|
let by_status = state.count_video_tasks_by_status(filter).await?;
|
|
let by_model = state.top_video_task_models(filter, 10).await?;
|
|
let today_count = state
|
|
.count_video_tasks_created_since(filter, start_of_utc_day(now_unix_secs))
|
|
.await?;
|
|
let processing_count = by_status
|
|
.iter()
|
|
.filter(|entry| {
|
|
matches!(
|
|
entry.status,
|
|
VideoTaskStatus::Submitted | VideoTaskStatus::Queued | VideoTaskStatus::Processing
|
|
)
|
|
})
|
|
.map(|entry| entry.count)
|
|
.sum();
|
|
|
|
Ok(VideoTaskStatsResponse {
|
|
total,
|
|
by_status: map_status_counts(by_status),
|
|
by_model: map_model_counts(by_model),
|
|
today_count,
|
|
processing_count,
|
|
})
|
|
}
|
|
|
|
fn map_status_counts(counts: Vec<VideoTaskStatusCount>) -> BTreeMap<String, u64> {
|
|
counts
|
|
.into_iter()
|
|
.map(|entry| (status_key(entry.status), entry.count))
|
|
.collect()
|
|
}
|
|
|
|
fn map_model_counts(counts: Vec<VideoTaskModelCount>) -> BTreeMap<String, u64> {
|
|
counts
|
|
.into_iter()
|
|
.map(|entry| (entry.model, entry.count))
|
|
.collect()
|
|
}
|
|
|
|
fn status_key(status: VideoTaskStatus) -> String {
|
|
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",
|
|
}
|
|
.to_string()
|
|
}
|
|
|
|
fn start_of_utc_day(now_unix_secs: u64) -> u64 {
|
|
now_unix_secs - (now_unix_secs % 86_400)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
|
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
|
|
use aether_data::repository::video_tasks::InMemoryVideoTaskRepository;
|
|
use aether_data_contracts::repository::provider_catalog::{
|
|
ProviderCatalogReadRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
|
StoredProviderCatalogProvider,
|
|
};
|
|
use aether_data_contracts::repository::video_tasks::{UpsertVideoTask, VideoTaskStatus};
|
|
use serde_json::json;
|
|
|
|
use super::{
|
|
parse_video_url, video_task_video_source_from_task, video_urls_share_origin,
|
|
VideoTaskVideoSource,
|
|
};
|
|
use crate::{data::GatewayDataState, AppState};
|
|
|
|
fn legacy_gemini_video_task() -> aether_data_contracts::repository::video_tasks::StoredVideoTask
|
|
{
|
|
UpsertVideoTask {
|
|
id: "legacy-gemini-task".to_string(),
|
|
short_id: Some("legacy-short".to_string()),
|
|
request_id: "legacy-request".to_string(),
|
|
user_id: Some("user-1".to_string()),
|
|
api_key_id: Some("client-key-1".to_string()),
|
|
username: None,
|
|
api_key_name: None,
|
|
external_task_id: Some("operations/upstream-1".to_string()),
|
|
provider_id: Some("provider-1".to_string()),
|
|
endpoint_id: Some("endpoint-1".to_string()),
|
|
key_id: Some("provider-key-1".to_string()),
|
|
client_api_format: Some("gemini:video".to_string()),
|
|
provider_api_format: None,
|
|
format_converted: false,
|
|
model: Some("veo-3".to_string()),
|
|
prompt: None,
|
|
original_request_body: None,
|
|
duration_seconds: Some(8),
|
|
resolution: Some("720p".to_string()),
|
|
aspect_ratio: Some("16:9".to_string()),
|
|
size: Some("1280x720".to_string()),
|
|
status: VideoTaskStatus::Completed,
|
|
progress_percent: 100,
|
|
progress_message: None,
|
|
retry_count: 0,
|
|
poll_interval_seconds: 10,
|
|
next_poll_at_unix_secs: None,
|
|
poll_count: 1,
|
|
max_poll_count: 360,
|
|
created_at_unix_ms: 1,
|
|
submitted_at_unix_secs: Some(1),
|
|
completed_at_unix_secs: Some(2),
|
|
updated_at_unix_secs: 2,
|
|
error_code: None,
|
|
error_message: None,
|
|
video_url: Some(
|
|
"https://generativelanguage.googleapis.com/v1beta/files/video-1:download?alt=media"
|
|
.to_string(),
|
|
),
|
|
request_metadata: None,
|
|
}
|
|
.into_stored()
|
|
}
|
|
|
|
fn state_with_gemini_transport() -> AppState {
|
|
let state = AppState::new().expect("gateway state should build");
|
|
let provider = StoredProviderCatalogProvider::new(
|
|
"provider-1".to_string(),
|
|
"Gemini".to_string(),
|
|
Some("https://ai.google.dev".to_string()),
|
|
"gemini".to_string(),
|
|
)
|
|
.expect("provider should build");
|
|
let endpoint = StoredProviderCatalogEndpoint::new(
|
|
"endpoint-1".to_string(),
|
|
"provider-1".to_string(),
|
|
"gemini:video".to_string(),
|
|
None,
|
|
None,
|
|
true,
|
|
)
|
|
.expect("endpoint should build")
|
|
.with_transport_fields(
|
|
"https://generativelanguage.googleapis.com".to_string(),
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("endpoint transport should build");
|
|
let encrypted_api_key = state
|
|
.seal_provider_catalog_key_api_key(
|
|
"provider-1",
|
|
"provider-key-1",
|
|
"gemini-provider-secret",
|
|
)
|
|
.expect("provider key should encrypt");
|
|
let key = StoredProviderCatalogKey::new(
|
|
"provider-key-1".to_string(),
|
|
"provider-1".to_string(),
|
|
"default".to_string(),
|
|
"api_key".to_string(),
|
|
None,
|
|
true,
|
|
)
|
|
.expect("provider key should build")
|
|
.with_transport_fields(
|
|
Some(json!(["gemini:video"])),
|
|
encrypted_api_key,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.expect("provider key transport should build");
|
|
let provider_catalog: Arc<dyn ProviderCatalogReadRepository> = Arc::new(
|
|
InMemoryProviderCatalogReadRepository::seed(vec![provider], vec![endpoint], vec![key]),
|
|
);
|
|
let video_tasks = Arc::new(InMemoryVideoTaskRepository::default());
|
|
let data = GatewayDataState::with_video_task_repository_and_provider_transport_for_tests(
|
|
video_tasks,
|
|
provider_catalog,
|
|
DEVELOPMENT_ENCRYPTION_KEY,
|
|
);
|
|
state.with_data_state_for_tests(data)
|
|
}
|
|
|
|
#[test]
|
|
fn video_url_parser_rejects_non_http_and_embedded_credentials() {
|
|
for raw_url in [
|
|
"file:///etc/passwd",
|
|
"data:video/mp4;base64,AAAA",
|
|
"https://[email protected]/video.mp4",
|
|
"https://user:[email protected]/video.mp4",
|
|
"/relative/video.mp4",
|
|
] {
|
|
assert!(
|
|
parse_video_url(raw_url).is_err(),
|
|
"URL should be rejected: {raw_url}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn video_origin_comparison_uses_scheme_host_and_effective_port() {
|
|
let base = parse_video_url("https://generativelanguage.googleapis.com/v1beta").unwrap();
|
|
for same_origin in [
|
|
"https://generativelanguage.googleapis.com/file",
|
|
"https://generativelanguage.googleapis.com:443/file",
|
|
] {
|
|
assert!(video_urls_share_origin(
|
|
&base,
|
|
&parse_video_url(same_origin).unwrap()
|
|
));
|
|
}
|
|
for different_origin in [
|
|
"http://generativelanguage.googleapis.com/file",
|
|
"https://generativelanguage.googleapis.com:444/file",
|
|
"https://generativelanguage.googleapis.com.evil.test/file",
|
|
"https://evil.test/generativelanguage.googleapis.com/file",
|
|
] {
|
|
assert!(!video_urls_share_origin(
|
|
&base,
|
|
&parse_video_url(different_origin).unwrap()
|
|
));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn legacy_gemini_client_format_uses_authenticated_proxy_source() {
|
|
let source = video_task_video_source_from_task(
|
|
&state_with_gemini_transport(),
|
|
&legacy_gemini_video_task(),
|
|
)
|
|
.await
|
|
.expect("video source should resolve")
|
|
.expect("video source should exist");
|
|
|
|
match source {
|
|
VideoTaskVideoSource::Proxy {
|
|
url,
|
|
header_name,
|
|
header_value,
|
|
filename,
|
|
} => {
|
|
assert_eq!(
|
|
url.as_str(),
|
|
"https://generativelanguage.googleapis.com/v1beta/files/video-1:download?alt=media"
|
|
);
|
|
assert_eq!(header_name, "x-goog-api-key");
|
|
assert_eq!(header_value, "gemini-provider-secret");
|
|
assert_eq!(filename, "video_legacy-gemini-task.mp4");
|
|
}
|
|
VideoTaskVideoSource::Redirect { .. } => {
|
|
panic!("legacy Gemini video must not bypass the authenticated proxy")
|
|
}
|
|
}
|
|
}
|
|
}
|