1#[cfg(feature = "server")]
23use axum::extract::{ConnectInfo, Extension, Multipart};
24#[cfg(feature = "server")]
25use axum::http::{HeaderMap, StatusCode};
26#[cfg(feature = "server")]
27use axum::response::Response;
28#[cfg(feature = "server")]
29use axum::{response::IntoResponse, Json};
30#[cfg(feature = "server")]
31use serde_json::{json, Value};
32#[cfg(feature = "server")]
33use std::net::SocketAddr;
34
35#[cfg(feature = "server")]
36use crate::auth::session::parse_session_token;
37
38#[cfg(feature = "server")]
39const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
40#[cfg(feature = "server")]
41use crate::utils::server::MAX_FILE_SIZE;
42
43#[cfg(feature = "server")]
50pub(crate) fn upload_error<T: serde::Serialize>(
51 status: StatusCode,
52 msg: T,
53) -> (StatusCode, Json<Value>) {
54 (status, Json(json!({ "success": false, "error": msg })))
55}
56
57#[cfg(feature = "server")]
67pub async fn upload_image(
68 connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
69 headers: HeaderMap,
70 mut multipart: Multipart,
71) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
72 let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
74 let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
75 if let Err(msg) = crate::api::rate_limit::check_upload_limit(&ip) {
76 return Err(upload_error(StatusCode::TOO_MANY_REQUESTS, msg));
77 }
78
79 let cookie_header = headers
81 .get("cookie")
82 .and_then(|h| h.to_str().ok())
83 .unwrap_or("");
84
85 let token = match parse_session_token(cookie_header) {
86 Some(t) => t,
87 None => {
88 return Err(upload_error(StatusCode::UNAUTHORIZED, "未登录"));
89 }
90 };
91
92 let user = match crate::api::auth::get_user_by_token(token).await {
94 Ok(Some(u)) => u,
95 _ => {
96 return Err(upload_error(StatusCode::UNAUTHORIZED, "会话已过期"));
97 }
98 };
99
100 if user.role != crate::models::user::UserRole::Admin {
101 return Err(upload_error(StatusCode::FORBIDDEN, "权限不足"));
102 }
103
104 let field = match multipart.next_field().await {
106 Ok(Some(f)) => f,
107 Ok(None) => {
108 return Err(upload_error(StatusCode::BAD_REQUEST, "未找到文件"));
109 }
110 Err(e) => {
111 tracing::error!("Multipart error: {:?}", e);
112 return Err(upload_error(StatusCode::BAD_REQUEST, "文件读取失败"));
113 }
114 };
115
116 let declared_mime = field.content_type().unwrap_or("").to_string();
119 if !ALLOWED_MIME_TYPES.contains(&declared_mime.as_str()) {
120 return Err(upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型"));
121 }
122
123 let original_filename = field.file_name().map(|s| s.to_string());
125
126 let data = match field.bytes().await {
128 Ok(d) => d,
129 Err(e) => {
130 tracing::error!("Read file error: {:?}", e);
131 return Err(upload_error(
132 StatusCode::INTERNAL_SERVER_ERROR,
133 "文件读取失败",
134 ));
135 }
136 };
137
138 match process_image_upload(data, original_filename).await {
140 Ok(out) => Ok(Json(json!({
141 "success": true,
142 "url": out.url,
143 "reused": out.reused
144 }))),
145 Err(e) => {
146 let (status, msg) = e.status_and_msg();
147 Err(upload_error(status, msg))
148 }
149 }
150}
151
152#[cfg(feature = "server")]
168pub async fn comment_upload_image(
169 connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
170 headers: HeaderMap,
171 mut multipart: Multipart,
172) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
173 let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
175 let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
176 if let Err(msg) = crate::api::rate_limit::check_comment_upload_limit(&ip) {
177 return Err(upload_error(StatusCode::TOO_MANY_REQUESTS, msg));
178 }
179
180 let field = match multipart.next_field().await {
182 Ok(Some(f)) => f,
183 Ok(None) => {
184 return Err(upload_error(StatusCode::BAD_REQUEST, "未找到文件"));
185 }
186 Err(e) => {
187 tracing::error!("Comment multipart error: {:?}", e);
188 return Err(upload_error(StatusCode::BAD_REQUEST, "文件读取失败"));
189 }
190 };
191
192 let declared_mime = field.content_type().unwrap_or("").to_string();
194 if !ALLOWED_MIME_TYPES.contains(&declared_mime.as_str()) {
195 return Err(upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型"));
196 }
197
198 let original_filename = field.file_name().map(|s| s.to_string());
199
200 let data = match field.bytes().await {
202 Ok(d) => d,
203 Err(e) => {
204 tracing::error!("Comment read file error: {:?}", e);
205 return Err(upload_error(
206 StatusCode::INTERNAL_SERVER_ERROR,
207 "文件读取失败",
208 ));
209 }
210 };
211
212 match process_image_upload(data, original_filename).await {
214 Ok(out) => Ok(Json(json!({
215 "success": true,
216 "url": out.url,
217 "reused": out.reused
218 }))),
219 Err(e) => {
220 let (status, msg) = e.status_and_msg();
221 Err(upload_error(status, msg))
222 }
223 }
224}
225
226#[cfg(feature = "server")]
232fn mcp_upload_error<T: serde::Serialize>(status: StatusCode, msg: T) -> Response {
233 (status, Json(json!({ "success": false, "error": msg }))).into_response()
234}
235
236#[cfg(feature = "server")]
248pub async fn mcp_upload_image(headers: HeaderMap, mut multipart: Multipart) -> Response {
249 let principal = match crate::mcp::auth::resolve_bearer_principal(&headers).await {
251 Ok(p) => p,
252 Err(status) => return mcp_upload_error(status, "未授权或令牌无效"),
253 };
254 if !principal
255 .scope
256 .grants(crate::models::mcp_token::TokenScope::Write)
257 {
258 return mcp_upload_error(StatusCode::FORBIDDEN, "权限不足:需要 write 作用域");
259 }
260
261 if let Err(msg) = crate::mcp::auth::check_mcp_upload_limit(&principal.token_id) {
263 return mcp_upload_error(StatusCode::TOO_MANY_REQUESTS, msg);
264 }
265
266 let field = match multipart.next_field().await {
268 Ok(Some(f)) => f,
269 Ok(None) => return mcp_upload_error(StatusCode::BAD_REQUEST, "未找到文件"),
270 Err(e) => {
271 tracing::error!("MCP multipart error: {:?}", e);
272 return mcp_upload_error(StatusCode::BAD_REQUEST, "文件读取失败");
273 }
274 };
275
276 let declared_mime = field.content_type().unwrap_or("").to_string();
278 if !ALLOWED_MIME_TYPES.contains(&declared_mime.as_str()) {
279 return mcp_upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型");
280 }
281
282 let original_filename = field.file_name().map(|s| s.to_string());
283 let data = match field.bytes().await {
284 Ok(d) => d,
285 Err(e) => {
286 tracing::error!("MCP read file error: {:?}", e);
287 return mcp_upload_error(StatusCode::INTERNAL_SERVER_ERROR, "文件读取失败");
288 }
289 };
290
291 match process_image_upload(data, original_filename).await {
293 Ok(out) => Json(json!({
294 "success": true,
295 "url": out.url,
296 "reused": out.reused,
297 "width": out.width,
298 "height": out.height,
299 "mime": out.mime
300 }))
301 .into_response(),
302 Err(e) => {
303 let (status, msg) = e.status_and_msg();
304 mcp_upload_error(status, msg)
305 }
306 }
307}
308
309#[cfg(feature = "server")]
315#[derive(Debug, serde::Serialize)]
316pub(crate) struct UploadOutcome {
317 pub url: String,
319 pub reused: bool,
321 pub width: u32,
322 pub height: u32,
323 pub mime: String,
325}
326
327#[cfg(feature = "server")]
329#[derive(Debug)]
330pub(crate) enum UploadError {
331 Empty,
332 BadType, TooLarge, Oversized, Corrupt, #[allow(dead_code)]
338 Internal(&'static str),
339}
340
341#[cfg(feature = "server")]
342impl UploadError {
343 fn internal<E: std::fmt::Display>(e: E, ctx: &'static str) -> Self {
345 tracing::error!("upload {ctx}: {e}");
346 UploadError::Internal(ctx)
347 }
348
349 fn status_and_msg(&self) -> (StatusCode, &'static str) {
351 match self {
352 UploadError::Empty => (StatusCode::BAD_REQUEST, "空文件"),
353 UploadError::BadType => (StatusCode::BAD_REQUEST, "不支持的文件类型"),
354 UploadError::TooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "文件超过大小限制"),
355 UploadError::Oversized => (StatusCode::BAD_REQUEST, "图片尺寸超过上限"),
356 UploadError::Corrupt => (StatusCode::BAD_REQUEST, "图片文件损坏或格式不正确"),
357 UploadError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "文件保存失败"),
358 }
359 }
360}
361
362#[cfg(feature = "server")]
371pub(crate) async fn process_image_upload(
372 data: bytes::Bytes,
373 original_filename: Option<String>,
374) -> Result<UploadOutcome, UploadError> {
375 if data.is_empty() {
376 return Err(UploadError::Empty);
377 }
378 if data.len() > MAX_FILE_SIZE {
379 return Err(UploadError::TooLarge);
380 }
381
382 let mime_type = detect_mime(&data).ok_or(UploadError::BadType)?;
384
385 let (img_width, img_height) =
388 crate::api::image::upload_dimensions(&data, mime_type).map_err(|msg| {
389 tracing::warn!("upload dimensions check failed: {msg}");
390 UploadError::Oversized
391 })?;
392
393 let is_gif = mime_type == "image/gif";
394 let is_webp = mime_type == "image/webp";
395
396 let content_hash = {
402 use sha2::Digest;
403 hex::encode(sha2::Sha256::digest(&data))
404 };
405 {
406 let client = crate::db::pool::get_conn()
407 .await
408 .map_err(|e| UploadError::internal(e, "dedup conn"))?;
409 let reused = client
410 .query_opt(
411 "UPDATE assets SET created_at = NOW(), updated_at = NOW() \
412 WHERE content_hash = $1 RETURNING path",
413 &[&content_hash],
414 )
415 .await
416 .map_err(|e| UploadError::internal(e, "dedup check"))?;
417 if let Some(row) = reused {
418 let path: String = row.get("path");
419 tracing::info!(
420 "Image deduped: reuse {} (hash {})",
421 path,
422 &content_hash[..12]
423 );
424 return Ok(UploadOutcome {
425 url: format!("/uploads/{}", path),
426 reused: true,
427 width: img_width,
428 height: img_height,
429 mime: mime_type.to_string(),
430 });
431 }
432 }
433
434 if is_gif || is_webp {
437 let validate_data = data.clone();
438 let validate_mime = mime_type.to_string();
439 let is_valid = tokio::task::spawn_blocking(move || {
440 validate_raw_image(&validate_data, validate_mime.as_str())
441 })
442 .await
443 .map_err(|e| UploadError::internal(e, "validate task"))?;
444 if !is_valid {
445 return Err(UploadError::Corrupt);
446 }
447 }
448
449 let (final_data, final_ext) = transcode(data, mime_type, is_gif, is_webp).await;
452
453 let now = chrono::Utc::now();
456 let date = now.format("%Y/%m/%d");
457 let uuid_str = uuid::Uuid::new_v4().to_string();
458
459 let dir_path = format!("uploads/{}", date);
460 let file_name = format!("{}.{}.{}", now.format("%H%M%S"), uuid_str, final_ext);
461 let file_path = format!("{}/{}", dir_path, file_name);
462 let rel_path = format!("{}/{}", date, file_name);
463 let url_path = format!("/uploads/{}", rel_path);
464 let final_mime = mime_for_ext(&final_ext);
465
466 if let Err(e) = tokio::fs::create_dir_all(&dir_path).await {
467 return Err(UploadError::internal(e, "create dir"));
468 }
469 if let Err(e) = tokio::fs::write(&file_path, &final_data).await {
470 return Err(UploadError::internal(e, "write file"));
471 }
472
473 tracing::info!("Image uploaded: {} ({} bytes)", file_path, final_data.len());
474
475 let registered: Result<Option<String>, UploadError> = async {
480 let client = crate::db::pool::get_conn()
481 .await
482 .map_err(|e| UploadError::internal(e, "register conn"))?;
483 let asset_id = uuid::Uuid::new_v4();
485 let inserted = client
486 .execute(
487 "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height, content_hash)\
488 VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
489 ON CONFLICT (content_hash) DO NOTHING",
490 &[
491 &asset_id,
492 &rel_path,
493 &original_filename.unwrap_or_else(|| file_name.clone()),
494 &final_mime,
495 &(final_data.len() as i64),
496 &(img_width as i32),
497 &(img_height as i32),
498 &content_hash,
499 ],
500 )
501 .await
502 .map_err(|e| UploadError::internal(e, "register asset"))?;
503 if inserted == 0 {
504 let row = client
506 .query_one(
507 "SELECT path FROM assets WHERE content_hash = $1",
508 &[&content_hash],
509 )
510 .await
511 .map_err(|e| UploadError::internal(e, "select reused asset"))?;
512 return Ok(Some(row.get("path")));
513 }
514 Ok(None)
515 }
516 .await;
517
518 match registered {
519 Ok(Some(reused_path)) => {
520 let _ = tokio::fs::remove_file(&file_path).await;
521 tracing::info!("Image deduped (concurrent race): reuse {}", reused_path);
522 Ok(UploadOutcome {
523 url: format!("/uploads/{}", reused_path),
524 reused: true,
525 width: img_width,
526 height: img_height,
527 mime: mime_type.to_string(),
528 })
529 }
530 Ok(None) => Ok(UploadOutcome {
531 url: url_path,
532 reused: false,
533 width: img_width,
534 height: img_height,
535 mime: final_mime.to_string(),
536 }),
537 Err(e) => {
538 let _ = tokio::fs::remove_file(&file_path).await;
540 Err(e)
541 }
542 }
543}
544
545#[cfg(feature = "server")]
551pub(crate) fn detect_mime(data: &[u8]) -> Option<&'static str> {
552 if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
553 Some("image/jpeg")
554 } else if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
555 Some("image/png")
556 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
557 Some("image/gif")
558 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
559 Some("image/webp")
560 } else {
561 None
562 }
563}
564
565#[cfg(feature = "server")]
566fn mime_to_ext(mime: &str) -> &'static str {
567 match mime {
568 "image/jpeg" => "jpg",
569 "image/png" => "png",
570 "image/webp" => "webp",
571 "image/gif" => "gif",
572 _ => "bin",
573 }
574}
575
576#[cfg(feature = "server")]
577fn mime_for_ext(ext: &str) -> &'static str {
578 match ext {
579 "jpg" => "image/jpeg",
580 "png" => "image/png",
581 "gif" => "image/gif",
582 _ => "image/webp",
583 }
584}
585
586#[cfg(feature = "server")]
588fn validate_raw_image(data: &[u8], mime_type: &str) -> bool {
589 match mime_type {
590 "image/webp" => crate::infra::webp::decode(data).is_ok(),
591 "image/gif" => image::load_from_memory(data).is_ok(),
592 _ => true,
593 }
594}
595
596#[cfg(feature = "server")]
598fn transcode_image_blocking(
599 data: &[u8],
600 mime: &'static str,
601 is_gif: bool,
602 is_webp: bool,
603) -> (Vec<u8>, String) {
604 if is_gif {
605 return (data.to_vec(), "gif".to_string());
606 }
607 if is_webp {
608 return (data.to_vec(), "webp".to_string());
609 }
610
611 let format = match mime {
613 "image/jpeg" => image::ImageFormat::Jpeg,
614 "image/png" => image::ImageFormat::Png,
615 _ => image::ImageFormat::Jpeg,
616 };
617 let cursor = std::io::Cursor::new(data);
618 let mut reader = image::ImageReader::with_format(cursor, format);
619 reader.limits(crate::api::image::image_reader_limits());
620
621 match reader.decode() {
622 Ok(img) => {
623 let config = crate::infra::webp::WEBP_CONFIG.clone();
624 match crate::infra::webp::encode(&img, config.quality, config.method) {
625 Ok(webp_data) if webp_data.len() < data.len() => {
626 tracing::info!(
627 "WebP conversion: {}x{} {} -> {} bytes",
628 img.width(),
629 img.height(),
630 data.len(),
631 webp_data.len()
632 );
633 (webp_data, "webp".to_string())
634 }
635 Ok(_) => {
636 (data.to_vec(), mime_to_ext(mime).to_string())
638 }
639 Err(e) => {
640 tracing::warn!("WebP encode failed ({}), keeping original", e);
641 (data.to_vec(), mime_to_ext(mime).to_string())
642 }
643 }
644 }
645 Err(e) => {
647 tracing::warn!("Failed to decode image ({}), keeping original format", e);
648 (data.to_vec(), mime_to_ext(mime).to_string())
649 }
650 }
651}
652
653#[cfg(feature = "server")]
656async fn transcode(
657 data: bytes::Bytes,
658 mime: &'static str,
659 is_gif: bool,
660 is_webp: bool,
661) -> (Vec<u8>, String) {
662 let for_task = data.clone();
663 match tokio::task::spawn_blocking(move || {
664 transcode_image_blocking(&for_task, mime, is_gif, is_webp)
665 })
666 .await
667 {
668 Ok(result) => result,
669 Err(e) => {
670 tracing::warn!("transcode task panicked ({}), keeping original", e);
671 (data.to_vec(), mime_to_ext(mime).to_string())
672 }
673 }
674}
675
676#[cfg(all(test, feature = "server"))]
677mod tests {
678 #[test]
679 fn filename_format_no_spaces() {
680 let now_str = "120000";
681 let uuid = "abc-123";
682 let ext = "jpg";
683 let file_name = format!("{}.{}.{}", now_str, uuid, ext);
684 assert!(
685 !file_name.contains(' '),
686 "filename should not contain spaces: got '{}'",
687 file_name
688 );
689 }
690
691 #[test]
692 fn should_use_webp_ext_for_non_gif() {
693 let ext = "jpg";
694 let mime = "image/jpeg";
695 let is_gif = mime == "image/gif";
696 let final_ext = if is_gif { ext } else { "webp" };
697 assert_eq!(final_ext, "webp");
698 }
699
700 #[test]
701 fn should_preserve_gif_ext() {
702 let ext = "gif";
703 let mime = "image/gif";
704 let is_gif = mime == "image/gif";
705 let final_ext = if is_gif { ext } else { "webp" };
706 assert_eq!(final_ext, "gif");
707 }
708
709 #[test]
710 fn convert_to_webp_produces_bytes() {
711 let img = image::DynamicImage::new_rgb8(10, 10);
712 let result = crate::infra::webp::encode(&img, 85.0, 4).unwrap();
713 assert!(!result.is_empty());
714 }
715
716 #[test]
717 fn webp_roundtrip_from_rgba() {
718 let img = image::DynamicImage::new_rgba8(2, 2);
719 let webp_bytes = crate::infra::webp::encode(&img, 85.0, 4).unwrap();
720 let loaded = crate::infra::webp::decode(&webp_bytes);
721 assert!(loaded.is_ok());
722 }
723
724 #[test]
725 fn mime_to_ext_maps_jpeg() {
726 assert_eq!(super::mime_to_ext("image/jpeg"), "jpg");
727 }
728
729 #[test]
730 fn mime_to_ext_maps_png() {
731 assert_eq!(super::mime_to_ext("image/png"), "png");
732 }
733
734 #[test]
735 fn mime_to_ext_maps_gif() {
736 assert_eq!(super::mime_to_ext("image/gif"), "gif");
737 }
738
739 #[test]
740 fn mime_to_ext_maps_webp() {
741 assert_eq!(super::mime_to_ext("image/webp"), "webp");
742 }
743
744 #[test]
745 fn mime_to_ext_falls_back_for_unknown_mime() {
746 assert_eq!(super::mime_to_ext("image/avif"), "bin");
747 assert_eq!(super::mime_to_ext("application/octet-stream"), "bin");
748 }
749
750 #[test]
751 fn mime_for_ext_roundtrip() {
752 assert_eq!(super::mime_for_ext("jpg"), "image/jpeg");
753 assert_eq!(super::mime_for_ext("png"), "image/png");
754 assert_eq!(super::mime_for_ext("gif"), "image/gif");
755 assert_eq!(super::mime_for_ext("webp"), "image/webp");
756 }
757
758 #[test]
759 fn detect_mime_jpeg() {
760 assert_eq!(
761 super::detect_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
762 Some("image/jpeg")
763 );
764 assert_eq!(super::detect_mime(&[0x89, 0x50]), None);
765 }
766
767 #[test]
768 fn detect_mime_png() {
769 assert_eq!(
770 super::detect_mime(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
771 Some("image/png")
772 );
773 assert_eq!(super::detect_mime(&[0xFF, 0xD8]), None);
774 }
775
776 #[test]
777 fn detect_mime_gif() {
778 assert_eq!(super::detect_mime(b"GIF89a"), Some("image/gif"));
779 assert_eq!(super::detect_mime(b"GIF87a"), Some("image/gif"));
780 assert_eq!(super::detect_mime(b"GIF90a"), None);
781 }
782
783 #[test]
784 fn detect_mime_webp() {
785 let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ";
786 assert_eq!(super::detect_mime(&webp[..12]), Some("image/webp"));
787 assert_eq!(super::detect_mime(&[0xFF, 0xD8]), None);
788 }
789
790 #[test]
791 fn detect_mime_unknown() {
792 assert_eq!(super::detect_mime(b"hello world"), None);
793 assert_eq!(super::detect_mime(&[]), None);
794 }
795}