From 1bae3446e6edc14e3be60ced3dc01f77012b7ee8 Mon Sep 17 00:00:00 2001 From: xfy Date: Fri, 5 Jun 2026 14:56:29 +0800 Subject: [PATCH] feat(api): add image upload handler with admin auth --- Cargo.toml | 3 + src/api/mod.rs | 1 + src/api/upload.rs | 195 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 src/api/upload.rs diff --git a/Cargo.toml b/Cargo.toml index b49fb35..5e4aaae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,8 @@ tower-http = { version = "0.6", features = ["trace", "fs"], optional = true } rand = { version = "0.8", features = ["getrandom"] } getrandom = { version = "0.2", features = ["js"] } http = "1" +axum = { version = "0.7", optional = true, features = ["multipart"] } +serde_json = "1.0" ammonia = { version = "4", optional = true } syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "default-fancy", "html", "parsing", "dump-load", "yaml-load"], optional = true } @@ -52,4 +54,5 @@ server = [ "dep:tower-http", "dep:ammonia", "dep:syntect", + "dep:axum", ] diff --git a/src/api/mod.rs b/src/api/mod.rs index 48191ec..781aaef 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,3 +1,4 @@ pub mod auth; pub mod posts; +pub mod upload; pub mod utils; diff --git a/src/api/upload.rs b/src/api/upload.rs new file mode 100644 index 0000000..558714c --- /dev/null +++ b/src/api/upload.rs @@ -0,0 +1,195 @@ +#![allow(clippy::unused_unit)] + +use axum::{ + extract::Multipart, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use serde_json::{json, Value}; + +#[cfg(feature = "server")] +use crate::auth::session::parse_session_token; + +const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; +const MAX_FILE_SIZE: usize = 5 * 1024 * 1024; // 5MB + +#[derive(Debug, Clone, serde::Serialize)] +struct UploadResponse { + success: bool, + url: Option, + error: Option, +} + +#[cfg(feature = "server")] +pub async fn upload_image( + headers: HeaderMap, + mut multipart: Multipart, +) -> Result, (StatusCode, Json)> { + // 1. Extract session from cookie + let cookie_header = headers + .get("cookie") + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + + let token = match parse_session_token(cookie_header) { + Some(t) => t, + None => { + return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ + "success": false, + "error": "未登录" + })), + )); + } + }; + + // 2. Verify admin + let user = match crate::api::auth::get_user_by_token(token).await { + Ok(Some(u)) => u, + _ => { + return Err(( + StatusCode::UNAUTHORIZED, + Json(json!({ + "success": false, + "error": "会话已过期" + })), + )); + } + }; + + if user.role != crate::models::user::UserRole::Admin { + return Err(( + StatusCode::FORBIDDEN, + Json(json!({ + "success": false, + "error": "权限不足" + })), + )); + } + + // 3. Read multipart field + let field = match multipart.next_field().await { + Ok(Some(f)) => f, + Ok(None) => { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "success": false, + "error": "未找到文件" + })), + )); + } + Err(e) => { + tracing::error!("Multipart error: {:?}", e); + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "success": false, + "error": "文件读取失败" + })), + )); + } + }; + + // 4. Validate mime type + let mime_type = field.content_type().unwrap_or("").to_string(); + if !ALLOWED_MIME_TYPES.contains(&mime_type.as_str()) { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "success": false, + "error": "不支持的文件类型" + })), + )); + } + + // 5. Read file data + let data = match field.bytes().await { + Ok(d) => d, + Err(e) => { + tracing::error!("Read file error: {:?}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "success": false, + "error": "文件读取失败" + })), + )); + } + }; + + if data.len() > MAX_FILE_SIZE { + return Err(( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({ + "success": false, + "error": "文件超过大小限制" + })), + )); + } + + // 6. Generate path: uploads/{year}/{month}/{day}/{uuid}.{ext} + let now = chrono::Utc::now(); + let year = now.format("%Y").to_string(); + let month = now.format("%m").to_string(); + let day = now.format("%d").to_string(); + let uuid = uuid::Uuid::new_v4().to_string(); + + let ext = match mime_type.as_str() { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + _ => "bin", + }; + + let dir_path = format!("uploads/{}/{}/{}", year, month, day); + let file_name = format!("{}.{}. {}", now.format("%H%M%S"), uuid, ext); + let file_path = format!("{}/{}", dir_path, file_name); + let url_path = format!("/uploads/{}/{}/{}/{}", year, month, day, file_name); + + // 7. Create directory and write file + if let Err(e) = tokio::fs::create_dir_all(&dir_path).await { + tracing::error!("Create dir error: {:?}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "success": false, + "error": "文件保存失败" + })), + )); + } + + if let Err(e) = tokio::fs::write(&file_path, &data).await { + tracing::error!("Write file error: {:?}", e); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "success": false, + "error": "文件保存失败" + })), + )); + } + + tracing::info!("Image uploaded: {} ({} bytes)", file_path, data.len()); + + Ok(Json(json!({ + "success": true, + "url": url_path + }))) +} + +#[cfg(not(feature = "server"))] +pub async fn upload_image( + _headers: HeaderMap, + _multipart: Multipart, +) -> Result, (StatusCode, Json)> { + Err(( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "success": false, + "error": "服务器功能未启用" + })), + )) +}