229 lines
7.3 KiB
Rust
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))
|
|
}
|