Mercury/src/routes/cdn.rs
Matthew L McPeak a202a06562
All checks were successful
ci / build-ui (push) Successful in 14s
ci / test (push) Successful in 3m13s
ci / publish (push) Successful in 2m55s
Inital Commit
2026-06-18 19:24:40 -04:00

229 lines
7.3 KiB
Rust

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<AppState>) -> Result<Json<Vec<CdnObject>>, 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<AppState>,
Path(key): Path<String>,
) -> Result<impl IntoResponse, StatusCode> {
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<String> = row
.try_get::<Option<String>, _>("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<AppState>,
Json(body): Json<CreateCdnObject>,
) -> Result<Json<CdnObject>, 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<AppState>,
Path(key): Path<String>,
Json(body): Json<UpdateCdnObject>,
) -> Result<Json<CdnObject>, 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<AppState>,
Path(key): Path<String>,
) -> Result<Json<Value>, 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<AppState>,
mut multipart: Multipart,
) -> Result<Json<CdnObject>, StatusCode> {
let mut file_bytes: Option<axum::body::Bytes> = None;
let mut filename: Option<String> = None;
let mut key_override: Option<String> = None;
let mut content_type_override: Option<String> = None;
let mut description: Option<String> = 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))
}