use axum::{ extract::{Multipart, Path, State}, http::{header, StatusCode}, response::IntoResponse, Json, }; use serde_json::Value; use sqlx::Row; use crate::{ models::cdn::{CdnObject, CreateCdnObject, UpdateCdnObject}, state::AppState, }; pub async fn cdn_list(State(state): State) -> Result>, StatusCode> { let objects = sqlx::query_as::<_, CdnObject>( "SELECT id, key, content_type, description, created_at \ FROM cdn_objects ORDER BY id", ) .fetch_all(&state.pool) .await .map_err(|e| { tracing::error!("cdn_list: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(objects)) } pub async fn cdn_proxy( State(state): State, Path(key): Path, ) -> Result { let row = sqlx::query("SELECT content_type FROM cdn_objects WHERE key = $1") .bind(&key) .fetch_optional(&state.pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; let content_type: Option = row .try_get::, _>("content_type") .ok() .flatten(); let url = format!("{}/{}", state.cdn_base_url, key); let upstream = state.http_client.get(&url).send().await.map_err(|e| { tracing::error!("cdn_proxy upstream error for key={}: {}", key, e); StatusCode::BAD_GATEWAY })?; let status = StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); let bytes = upstream .bytes() .await .map_err(|_| StatusCode::BAD_GATEWAY)?; let ct = content_type.unwrap_or_else(|| "application/octet-stream".to_string()); Ok((status, [(header::CONTENT_TYPE, ct)], bytes)) } pub async fn cdn_create( State(state): State, Json(body): Json, ) -> Result, StatusCode> { let obj = sqlx::query_as::<_, CdnObject>( "INSERT INTO cdn_objects (key, content_type, description) \ VALUES ($1, $2, $3) \ RETURNING id, key, content_type, description, created_at", ) .bind(&body.key) .bind(&body.content_type) .bind(&body.description) .fetch_one(&state.pool) .await .map_err(|e| { tracing::error!("cdn_create: {}", e); if let sqlx::Error::Database(db_err) = &e { if db_err.code().as_deref() == Some("23505") { return StatusCode::CONFLICT; } } StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(obj)) } pub async fn cdn_update( State(state): State, Path(key): Path, Json(body): Json, ) -> Result, StatusCode> { let obj = sqlx::query_as::<_, CdnObject>( "UPDATE cdn_objects \ SET key = COALESCE($2, key), \ content_type = COALESCE($3, content_type), \ description = COALESCE($4, description) \ WHERE key = $1 \ RETURNING id, key, content_type, description, created_at", ) .bind(&key) .bind(&body.key) .bind(&body.content_type) .bind(&body.description) .fetch_optional(&state.pool) .await .map_err(|e| { tracing::error!("cdn_update: {}", e); if let sqlx::Error::Database(db_err) = &e { if db_err.code().as_deref() == Some("23505") { return StatusCode::CONFLICT; } } StatusCode::INTERNAL_SERVER_ERROR })? .ok_or(StatusCode::NOT_FOUND)?; Ok(Json(obj)) } pub async fn cdn_delete( State(state): State, Path(key): Path, ) -> Result, StatusCode> { let rows = sqlx::query("DELETE FROM cdn_objects WHERE key = $1") .bind(&key) .execute(&state.pool) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .rows_affected(); if rows == 0 { return Err(StatusCode::NOT_FOUND); } Ok(Json(serde_json::json!({ "deleted": true }))) } /// Receive a multipart upload, PUT the file to MinIO, then register it in cdn_objects. /// Fields: `file` (required), `key` (optional — defaults to filename), `content_type` /// (optional — defaults to detected), `description` (optional). pub async fn cdn_upload( State(state): State, mut multipart: Multipart, ) -> Result, StatusCode> { let mut file_bytes: Option = None; let mut filename: Option = None; let mut key_override: Option = None; let mut content_type_override: Option = None; let mut description: Option = None; while let Some(field) = multipart .next_field() .await .map_err(|_| StatusCode::BAD_REQUEST)? { let field_name = field.name().unwrap_or("").to_string(); match field_name.as_str() { "file" => { filename = field.file_name().map(|s| s.to_string()); if content_type_override.is_none() { content_type_override = field.content_type().map(|s| s.to_string()); } file_bytes = Some(field.bytes().await.map_err(|_| StatusCode::BAD_REQUEST)?); } "key" => { let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?; if !v.is_empty() { key_override = Some(v); } } "content_type" => { let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?; if !v.is_empty() { content_type_override = Some(v); } } "description" => { let v = field.text().await.map_err(|_| StatusCode::BAD_REQUEST)?; if !v.is_empty() { description = Some(v); } } _ => {} } } let bytes = file_bytes.ok_or(StatusCode::BAD_REQUEST)?; let key = key_override.or(filename).ok_or(StatusCode::BAD_REQUEST)?; let ct = content_type_override.unwrap_or_else(|| "application/octet-stream".to_string()); // Upload to MinIO (bucket is anonymous-public, so no auth needed) let upload_url = format!("{}/{}", state.cdn_base_url, key); let res = state .http_client .put(&upload_url) .header("Content-Type", &ct) .body(bytes) .send() .await .map_err(|e| { tracing::error!("cdn_upload: MinIO PUT failed: {}", e); StatusCode::BAD_GATEWAY })?; if !res.status().is_success() { tracing::error!("cdn_upload: MinIO returned {}", res.status()); return Err(StatusCode::BAD_GATEWAY); } let obj = sqlx::query_as::<_, CdnObject>( "INSERT INTO cdn_objects (key, content_type, description) \ VALUES ($1, $2, $3) \ ON CONFLICT (key) DO UPDATE \ SET content_type = EXCLUDED.content_type, \ description = EXCLUDED.description \ RETURNING id, key, content_type, description, created_at", ) .bind(&key) .bind(&ct) .bind(&description) .fetch_one(&state.pool) .await .map_err(|e| { tracing::error!("cdn_upload: DB insert failed: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; Ok(Json(obj)) }