feat(api): add image upload handler with admin auth

This commit is contained in:
xfy 2026-06-05 14:56:29 +08:00
parent eaec6d5da7
commit 1bae3446e6
3 changed files with 199 additions and 0 deletions

View File

@ -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",
]

View File

@ -1,3 +1,4 @@
pub mod auth;
pub mod posts;
pub mod upload;
pub mod utils;

195
src/api/upload.rs Normal file
View File

@ -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<String>,
error: Option<String>,
}
#[cfg(feature = "server")]
pub async fn upload_image(
headers: HeaderMap,
mut multipart: Multipart,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
// 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<Json<Value>, (StatusCode, Json<Value>)> {
Err((
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({
"success": false,
"error": "服务器功能未启用"
})),
))
}