1#[cfg(feature = "server")]
22use axum::extract::{ConnectInfo, Extension, Multipart};
23#[cfg(feature = "server")]
24use axum::http::{HeaderMap, StatusCode};
25#[cfg(feature = "server")]
26use axum::response::Response;
27#[cfg(feature = "server")]
28use axum::{response::IntoResponse, Json};
29#[cfg(feature = "server")]
30use serde_json::{json, Value};
31#[cfg(feature = "server")]
32use std::net::SocketAddr;
33
34#[cfg(feature = "server")]
35use crate::auth::session::parse_session_token;
36
37#[cfg(feature = "server")]
38const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
39#[cfg(feature = "server")]
40use crate::utils::server::MAX_FILE_SIZE;
41
42#[cfg(feature = "server")]
48fn upload_error<T: serde::Serialize>(status: StatusCode, msg: T) -> (StatusCode, Json<Value>) {
49 (status, Json(json!({ "success": false, "error": msg })))
50}
51
52#[cfg(feature = "server")]
62pub async fn upload_image(
63 connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
64 headers: HeaderMap,
65 mut multipart: Multipart,
66) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
67 let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
69 let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer);
70 if let Err(msg) = crate::api::rate_limit::check_upload_limit(&ip) {
71 return Err(upload_error(StatusCode::TOO_MANY_REQUESTS, msg));
72 }
73
74 let cookie_header = headers
76 .get("cookie")
77 .and_then(|h| h.to_str().ok())
78 .unwrap_or("");
79
80 let token = match parse_session_token(cookie_header) {
81 Some(t) => t,
82 None => {
83 return Err(upload_error(StatusCode::UNAUTHORIZED, "未登录"));
84 }
85 };
86
87 let user = match crate::api::auth::get_user_by_token(token).await {
89 Ok(Some(u)) => u,
90 _ => {
91 return Err(upload_error(StatusCode::UNAUTHORIZED, "会话已过期"));
92 }
93 };
94
95 if user.role != crate::models::user::UserRole::Admin {
96 return Err(upload_error(StatusCode::FORBIDDEN, "权限不足"));
97 }
98
99 let field = match multipart.next_field().await {
101 Ok(Some(f)) => f,
102 Ok(None) => {
103 return Err(upload_error(StatusCode::BAD_REQUEST, "未找到文件"));
104 }
105 Err(e) => {
106 tracing::error!("Multipart error: {:?}", e);
107 return Err(upload_error(StatusCode::BAD_REQUEST, "文件读取失败"));
108 }
109 };
110
111 let declared_mime = field.content_type().unwrap_or("").to_string();
114 if !ALLOWED_MIME_TYPES.contains(&declared_mime.as_str()) {
115 return Err(upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型"));
116 }
117
118 let original_filename = field.file_name().map(|s| s.to_string());
120
121 let data = match field.bytes().await {
123 Ok(d) => d,
124 Err(e) => {
125 tracing::error!("Read file error: {:?}", e);
126 return Err(upload_error(
127 StatusCode::INTERNAL_SERVER_ERROR,
128 "文件读取失败",
129 ));
130 }
131 };
132
133 match process_image_upload(data, original_filename).await {
135 Ok(out) => Ok(Json(json!({
136 "success": true,
137 "url": out.url,
138 "reused": out.reused
139 }))),
140 Err(e) => {
141 let (status, msg) = e.status_and_msg();
142 Err(upload_error(status, msg))
143 }
144 }
145}
146
147#[cfg(feature = "server")]
153fn mcp_upload_error<T: serde::Serialize>(status: StatusCode, msg: T) -> Response {
154 (status, Json(json!({ "success": false, "error": msg }))).into_response()
155}
156
157#[cfg(feature = "server")]
169pub async fn mcp_upload_image(headers: HeaderMap, mut multipart: Multipart) -> Response {
170 let principal = match crate::mcp::auth::resolve_bearer_principal(&headers).await {
172 Ok(p) => p,
173 Err(status) => return mcp_upload_error(status, "未授权或令牌无效"),
174 };
175 if !principal
176 .scope
177 .grants(crate::models::mcp_token::TokenScope::Write)
178 {
179 return mcp_upload_error(StatusCode::FORBIDDEN, "权限不足:需要 write 作用域");
180 }
181
182 if let Err(msg) = crate::mcp::auth::check_mcp_upload_limit(&principal.token_id) {
184 return mcp_upload_error(StatusCode::TOO_MANY_REQUESTS, msg);
185 }
186
187 let field = match multipart.next_field().await {
189 Ok(Some(f)) => f,
190 Ok(None) => return mcp_upload_error(StatusCode::BAD_REQUEST, "未找到文件"),
191 Err(e) => {
192 tracing::error!("MCP multipart error: {:?}", e);
193 return mcp_upload_error(StatusCode::BAD_REQUEST, "文件读取失败");
194 }
195 };
196
197 let declared_mime = field.content_type().unwrap_or("").to_string();
199 if !ALLOWED_MIME_TYPES.contains(&declared_mime.as_str()) {
200 return mcp_upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型");
201 }
202
203 let original_filename = field.file_name().map(|s| s.to_string());
204 let data = match field.bytes().await {
205 Ok(d) => d,
206 Err(e) => {
207 tracing::error!("MCP read file error: {:?}", e);
208 return mcp_upload_error(StatusCode::INTERNAL_SERVER_ERROR, "文件读取失败");
209 }
210 };
211
212 match process_image_upload(data, original_filename).await {
214 Ok(out) => Json(json!({
215 "success": true,
216 "url": out.url,
217 "reused": out.reused,
218 "width": out.width,
219 "height": out.height,
220 "mime": out.mime
221 }))
222 .into_response(),
223 Err(e) => {
224 let (status, msg) = e.status_and_msg();
225 mcp_upload_error(status, msg)
226 }
227 }
228}
229
230#[cfg(feature = "server")]
236#[derive(Debug, serde::Serialize)]
237pub(crate) struct UploadOutcome {
238 pub url: String,
240 pub reused: bool,
242 pub width: u32,
243 pub height: u32,
244 pub mime: String,
246}
247
248#[cfg(feature = "server")]
250#[derive(Debug)]
251pub(crate) enum UploadError {
252 Empty,
253 BadType, TooLarge, Oversized, Corrupt, #[allow(dead_code)]
259 Internal(&'static str),
260}
261
262#[cfg(feature = "server")]
263impl UploadError {
264 fn internal<E: std::fmt::Display>(e: E, ctx: &'static str) -> Self {
266 tracing::error!("upload {ctx}: {e}");
267 UploadError::Internal(ctx)
268 }
269
270 fn status_and_msg(&self) -> (StatusCode, &'static str) {
272 match self {
273 UploadError::Empty => (StatusCode::BAD_REQUEST, "空文件"),
274 UploadError::BadType => (StatusCode::BAD_REQUEST, "不支持的文件类型"),
275 UploadError::TooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "文件超过大小限制"),
276 UploadError::Oversized => (StatusCode::BAD_REQUEST, "图片尺寸超过上限"),
277 UploadError::Corrupt => (StatusCode::BAD_REQUEST, "图片文件损坏或格式不正确"),
278 UploadError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "文件保存失败"),
279 }
280 }
281}
282
283#[cfg(feature = "server")]
292pub(crate) async fn process_image_upload(
293 data: bytes::Bytes,
294 original_filename: Option<String>,
295) -> Result<UploadOutcome, UploadError> {
296 if data.is_empty() {
297 return Err(UploadError::Empty);
298 }
299 if data.len() > MAX_FILE_SIZE {
300 return Err(UploadError::TooLarge);
301 }
302
303 let mime_type = detect_mime(&data).ok_or(UploadError::BadType)?;
305
306 let (img_width, img_height) =
309 crate::api::image::upload_dimensions(&data, mime_type).map_err(|msg| {
310 tracing::warn!("upload dimensions check failed: {msg}");
311 UploadError::Oversized
312 })?;
313
314 let is_gif = mime_type == "image/gif";
315 let is_webp = mime_type == "image/webp";
316
317 let content_hash = {
323 use sha2::Digest;
324 hex::encode(sha2::Sha256::digest(&data))
325 };
326 {
327 let client = crate::db::pool::get_conn()
328 .await
329 .map_err(|e| UploadError::internal(e, "dedup conn"))?;
330 let reused = client
331 .query_opt(
332 "UPDATE assets SET created_at = NOW(), updated_at = NOW() \
333 WHERE content_hash = $1 RETURNING path",
334 &[&content_hash],
335 )
336 .await
337 .map_err(|e| UploadError::internal(e, "dedup check"))?;
338 if let Some(row) = reused {
339 let path: String = row.get("path");
340 tracing::info!(
341 "Image deduped: reuse {} (hash {})",
342 path,
343 &content_hash[..12]
344 );
345 return Ok(UploadOutcome {
346 url: format!("/uploads/{}", path),
347 reused: true,
348 width: img_width,
349 height: img_height,
350 mime: mime_type.to_string(),
351 });
352 }
353 }
354
355 if is_gif || is_webp {
358 let validate_data = data.clone();
359 let validate_mime = mime_type.to_string();
360 let is_valid = tokio::task::spawn_blocking(move || {
361 validate_raw_image(&validate_data, validate_mime.as_str())
362 })
363 .await
364 .map_err(|e| UploadError::internal(e, "validate task"))?;
365 if !is_valid {
366 return Err(UploadError::Corrupt);
367 }
368 }
369
370 let (final_data, final_ext) = transcode(data, mime_type, is_gif, is_webp).await;
373
374 let now = chrono::Utc::now();
377 let date = now.format("%Y/%m/%d");
378 let uuid_str = uuid::Uuid::new_v4().to_string();
379
380 let dir_path = format!("uploads/{}", date);
381 let file_name = format!("{}.{}.{}", now.format("%H%M%S"), uuid_str, final_ext);
382 let file_path = format!("{}/{}", dir_path, file_name);
383 let rel_path = format!("{}/{}", date, file_name);
384 let url_path = format!("/uploads/{}", rel_path);
385 let final_mime = mime_for_ext(&final_ext);
386
387 if let Err(e) = tokio::fs::create_dir_all(&dir_path).await {
388 return Err(UploadError::internal(e, "create dir"));
389 }
390 if let Err(e) = tokio::fs::write(&file_path, &final_data).await {
391 return Err(UploadError::internal(e, "write file"));
392 }
393
394 tracing::info!("Image uploaded: {} ({} bytes)", file_path, final_data.len());
395
396 let registered: Result<Option<String>, UploadError> = async {
401 let client = crate::db::pool::get_conn()
402 .await
403 .map_err(|e| UploadError::internal(e, "register conn"))?;
404 let asset_id = uuid::Uuid::new_v4();
406 let inserted = client
407 .execute(
408 "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height, content_hash)\
409 VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
410 ON CONFLICT (content_hash) DO NOTHING",
411 &[
412 &asset_id,
413 &rel_path,
414 &original_filename.unwrap_or_else(|| file_name.clone()),
415 &final_mime,
416 &(final_data.len() as i64),
417 &(img_width as i32),
418 &(img_height as i32),
419 &content_hash,
420 ],
421 )
422 .await
423 .map_err(|e| UploadError::internal(e, "register asset"))?;
424 if inserted == 0 {
425 let row = client
427 .query_one(
428 "SELECT path FROM assets WHERE content_hash = $1",
429 &[&content_hash],
430 )
431 .await
432 .map_err(|e| UploadError::internal(e, "select reused asset"))?;
433 return Ok(Some(row.get("path")));
434 }
435 Ok(None)
436 }
437 .await;
438
439 match registered {
440 Ok(Some(reused_path)) => {
441 let _ = tokio::fs::remove_file(&file_path).await;
442 tracing::info!("Image deduped (concurrent race): reuse {}", reused_path);
443 Ok(UploadOutcome {
444 url: format!("/uploads/{}", reused_path),
445 reused: true,
446 width: img_width,
447 height: img_height,
448 mime: mime_type.to_string(),
449 })
450 }
451 Ok(None) => Ok(UploadOutcome {
452 url: url_path,
453 reused: false,
454 width: img_width,
455 height: img_height,
456 mime: final_mime.to_string(),
457 }),
458 Err(e) => {
459 let _ = tokio::fs::remove_file(&file_path).await;
461 Err(e)
462 }
463 }
464}
465
466#[cfg(feature = "server")]
472pub(crate) fn detect_mime(data: &[u8]) -> Option<&'static str> {
473 if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
474 Some("image/jpeg")
475 } else if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
476 Some("image/png")
477 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
478 Some("image/gif")
479 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
480 Some("image/webp")
481 } else {
482 None
483 }
484}
485
486#[cfg(feature = "server")]
487fn mime_to_ext(mime: &str) -> &'static str {
488 match mime {
489 "image/jpeg" => "jpg",
490 "image/png" => "png",
491 "image/webp" => "webp",
492 "image/gif" => "gif",
493 _ => "bin",
494 }
495}
496
497#[cfg(feature = "server")]
498fn mime_for_ext(ext: &str) -> &'static str {
499 match ext {
500 "jpg" => "image/jpeg",
501 "png" => "image/png",
502 "gif" => "image/gif",
503 _ => "image/webp",
504 }
505}
506
507#[cfg(feature = "server")]
509fn validate_raw_image(data: &[u8], mime_type: &str) -> bool {
510 match mime_type {
511 "image/webp" => crate::webp::decode(data).is_ok(),
512 "image/gif" => image::load_from_memory(data).is_ok(),
513 _ => true,
514 }
515}
516
517#[cfg(feature = "server")]
519fn transcode_image_blocking(
520 data: &[u8],
521 mime: &'static str,
522 is_gif: bool,
523 is_webp: bool,
524) -> (Vec<u8>, String) {
525 if is_gif {
526 return (data.to_vec(), "gif".to_string());
527 }
528 if is_webp {
529 return (data.to_vec(), "webp".to_string());
530 }
531
532 let format = match mime {
534 "image/jpeg" => image::ImageFormat::Jpeg,
535 "image/png" => image::ImageFormat::Png,
536 _ => image::ImageFormat::Jpeg,
537 };
538 let cursor = std::io::Cursor::new(data);
539 let mut reader = image::ImageReader::with_format(cursor, format);
540 reader.limits(crate::api::image::image_reader_limits());
541
542 match reader.decode() {
543 Ok(img) => {
544 let config = crate::webp::WEBP_CONFIG.clone();
545 match crate::webp::encode(&img, config.quality, config.method) {
546 Ok(webp_data) if webp_data.len() < data.len() => {
547 tracing::info!(
548 "WebP conversion: {}x{} {} -> {} bytes",
549 img.width(),
550 img.height(),
551 data.len(),
552 webp_data.len()
553 );
554 (webp_data, "webp".to_string())
555 }
556 Ok(_) => {
557 (data.to_vec(), mime_to_ext(mime).to_string())
559 }
560 Err(e) => {
561 tracing::warn!("WebP encode failed ({}), keeping original", e);
562 (data.to_vec(), mime_to_ext(mime).to_string())
563 }
564 }
565 }
566 Err(e) => {
568 tracing::warn!("Failed to decode image ({}), keeping original format", e);
569 (data.to_vec(), mime_to_ext(mime).to_string())
570 }
571 }
572}
573
574#[cfg(feature = "server")]
577async fn transcode(
578 data: bytes::Bytes,
579 mime: &'static str,
580 is_gif: bool,
581 is_webp: bool,
582) -> (Vec<u8>, String) {
583 let for_task = data.clone();
584 match tokio::task::spawn_blocking(move || {
585 transcode_image_blocking(&for_task, mime, is_gif, is_webp)
586 })
587 .await
588 {
589 Ok(result) => result,
590 Err(e) => {
591 tracing::warn!("transcode task panicked ({}), keeping original", e);
592 (data.to_vec(), mime_to_ext(mime).to_string())
593 }
594 }
595}
596
597#[cfg(all(test, feature = "server"))]
598mod tests {
599 #[test]
600 fn filename_format_no_spaces() {
601 let now_str = "120000";
602 let uuid = "abc-123";
603 let ext = "jpg";
604 let file_name = format!("{}.{}.{}", now_str, uuid, ext);
605 assert!(
606 !file_name.contains(' '),
607 "filename should not contain spaces: got '{}'",
608 file_name
609 );
610 }
611
612 #[test]
613 fn should_use_webp_ext_for_non_gif() {
614 let ext = "jpg";
615 let mime = "image/jpeg";
616 let is_gif = mime == "image/gif";
617 let final_ext = if is_gif { ext } else { "webp" };
618 assert_eq!(final_ext, "webp");
619 }
620
621 #[test]
622 fn should_preserve_gif_ext() {
623 let ext = "gif";
624 let mime = "image/gif";
625 let is_gif = mime == "image/gif";
626 let final_ext = if is_gif { ext } else { "webp" };
627 assert_eq!(final_ext, "gif");
628 }
629
630 #[test]
631 fn convert_to_webp_produces_bytes() {
632 let img = image::DynamicImage::new_rgb8(10, 10);
633 let result = crate::webp::encode(&img, 85.0, 4).unwrap();
634 assert!(!result.is_empty());
635 }
636
637 #[test]
638 fn webp_roundtrip_from_rgba() {
639 let img = image::DynamicImage::new_rgba8(2, 2);
640 let webp_bytes = crate::webp::encode(&img, 85.0, 4).unwrap();
641 let loaded = crate::webp::decode(&webp_bytes);
642 assert!(loaded.is_ok());
643 }
644
645 #[test]
646 fn mime_to_ext_maps_jpeg() {
647 assert_eq!(super::mime_to_ext("image/jpeg"), "jpg");
648 }
649
650 #[test]
651 fn mime_to_ext_maps_png() {
652 assert_eq!(super::mime_to_ext("image/png"), "png");
653 }
654
655 #[test]
656 fn mime_to_ext_maps_gif() {
657 assert_eq!(super::mime_to_ext("image/gif"), "gif");
658 }
659
660 #[test]
661 fn mime_to_ext_maps_webp() {
662 assert_eq!(super::mime_to_ext("image/webp"), "webp");
663 }
664
665 #[test]
666 fn mime_to_ext_falls_back_for_unknown_mime() {
667 assert_eq!(super::mime_to_ext("image/avif"), "bin");
668 assert_eq!(super::mime_to_ext("application/octet-stream"), "bin");
669 }
670
671 #[test]
672 fn mime_for_ext_roundtrip() {
673 assert_eq!(super::mime_for_ext("jpg"), "image/jpeg");
674 assert_eq!(super::mime_for_ext("png"), "image/png");
675 assert_eq!(super::mime_for_ext("gif"), "image/gif");
676 assert_eq!(super::mime_for_ext("webp"), "image/webp");
677 }
678
679 #[test]
680 fn detect_mime_jpeg() {
681 assert_eq!(
682 super::detect_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
683 Some("image/jpeg")
684 );
685 assert_eq!(super::detect_mime(&[0x89, 0x50]), None);
686 }
687
688 #[test]
689 fn detect_mime_png() {
690 assert_eq!(
691 super::detect_mime(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
692 Some("image/png")
693 );
694 assert_eq!(super::detect_mime(&[0xFF, 0xD8]), None);
695 }
696
697 #[test]
698 fn detect_mime_gif() {
699 assert_eq!(super::detect_mime(b"GIF89a"), Some("image/gif"));
700 assert_eq!(super::detect_mime(b"GIF87a"), Some("image/gif"));
701 assert_eq!(super::detect_mime(b"GIF90a"), None);
702 }
703
704 #[test]
705 fn detect_mime_webp() {
706 let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ";
707 assert_eq!(super::detect_mime(&webp[..12]), Some("image/webp"));
708 assert_eq!(super::detect_mime(&[0xFF, 0xD8]), None);
709 }
710
711 #[test]
712 fn detect_mime_unknown() {
713 assert_eq!(super::detect_mime(b"hello world"), None);
714 assert_eq!(super::detect_mime(&[]), None);
715 }
716}