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")]
36const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
37#[cfg(feature = "server")]
38use crate::utils::server::MAX_FILE_SIZE;
39
40#[cfg(feature = "server")]
47pub(crate) fn upload_error<T: serde::Serialize>(
48 status: StatusCode,
49 msg: T,
50) -> (StatusCode, Json<Value>) {
51 (status, Json(json!({ "success": false, "error": msg })))
52}
53
54#[cfg(feature = "server")]
63pub async fn upload_image(
64 connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
65 headers: HeaderMap,
66 mut multipart: Multipart,
67) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
68 let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
70 let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
71 if let Err(msg) = crate::api::rate_limit::check_upload_limit(&ip) {
72 return Err(upload_error(StatusCode::TOO_MANY_REQUESTS, msg));
73 }
74
75 crate::api::auth::require_admin_from_headers(&headers)
77 .await
78 .map_err(|(status, msg)| upload_error(status, msg))?;
79
80 let field = match multipart.next_field().await {
82 Ok(Some(f)) => f,
83 Ok(None) => {
84 return Err(upload_error(StatusCode::BAD_REQUEST, "未找到文件"));
85 }
86 Err(e) => {
87 tracing::error!("Multipart error: {:?}", e);
88 return Err(upload_error(StatusCode::BAD_REQUEST, "文件读取失败"));
89 }
90 };
91
92 let declared_mime = field.content_type().unwrap_or("").to_string();
95 if !ALLOWED_MIME_TYPES.contains(&declared_mime.as_str()) {
96 return Err(upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型"));
97 }
98
99 let original_filename = field.file_name().map(|s| s.to_string());
101
102 let data = match field.bytes().await {
104 Ok(d) => d,
105 Err(e) => {
106 tracing::error!("Read file error: {:?}", e);
107 return Err(upload_error(
108 StatusCode::INTERNAL_SERVER_ERROR,
109 "文件读取失败",
110 ));
111 }
112 };
113
114 match process_image_upload(data, original_filename, None).await {
116 Ok(out) => Ok(Json(json!({
117 "success": true,
118 "url": out.url,
119 "reused": out.reused
120 }))),
121 Err(e) => {
122 let (status, msg) = e.status_and_msg();
123 Err(upload_error(status, msg))
124 }
125 }
126}
127
128#[cfg(feature = "server")]
144pub async fn comment_upload_image(
145 connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
146 headers: HeaderMap,
147 mut multipart: Multipart,
148) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
149 let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
151 let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
152 if let Err(msg) = crate::api::rate_limit::check_comment_upload_limit(&ip) {
153 return Err(upload_error(StatusCode::TOO_MANY_REQUESTS, msg));
154 }
155
156 let field = match multipart.next_field().await {
158 Ok(Some(f)) => f,
159 Ok(None) => {
160 return Err(upload_error(StatusCode::BAD_REQUEST, "未找到文件"));
161 }
162 Err(e) => {
163 tracing::error!("Comment multipart error: {:?}", e);
164 return Err(upload_error(StatusCode::BAD_REQUEST, "文件读取失败"));
165 }
166 };
167
168 let declared_mime = field.content_type().unwrap_or("").to_string();
170 if !ALLOWED_MIME_TYPES.contains(&declared_mime.as_str()) {
171 return Err(upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型"));
172 }
173
174 let original_filename = field.file_name().map(|s| s.to_string());
175
176 let data = match field.bytes().await {
178 Ok(d) => d,
179 Err(e) => {
180 tracing::error!("Comment read file error: {:?}", e);
181 return Err(upload_error(
182 StatusCode::INTERNAL_SERVER_ERROR,
183 "文件读取失败",
184 ));
185 }
186 };
187
188 match process_image_upload(data, original_filename, None).await {
190 Ok(out) => Ok(Json(json!({
191 "success": true,
192 "url": out.url,
193 "reused": out.reused
194 }))),
195 Err(e) => {
196 let (status, msg) = e.status_and_msg();
197 Err(upload_error(status, msg))
198 }
199 }
200}
201
202#[cfg(feature = "server")]
208fn mcp_upload_error<T: serde::Serialize>(status: StatusCode, msg: T) -> Response {
209 (status, Json(json!({ "success": false, "error": msg }))).into_response()
210}
211
212#[cfg(feature = "server")]
224pub async fn mcp_upload_image(
225 connect_info: Option<Extension<ConnectInfo<SocketAddr>>>,
226 headers: HeaderMap,
227 mut multipart: Multipart,
228) -> Response {
229 let peer = connect_info.map(|Extension(ConnectInfo(addr))| addr);
231 let ip = crate::api::rate_limit::get_client_ip_with_peer(&headers, peer).await;
232 if let Err(msg) = crate::api::rate_limit::check_mcp_preauth_limit(&ip) {
233 return mcp_upload_error(StatusCode::TOO_MANY_REQUESTS, msg);
234 }
235
236 let principal = match crate::mcp::auth::resolve_bearer_principal(&headers).await {
238 Ok(p) => p,
239 Err(status) => return mcp_upload_error(status, "未授权或令牌无效"),
240 };
241 if !principal
242 .scope
243 .grants(crate::models::mcp_token::TokenScope::Write)
244 {
245 return mcp_upload_error(StatusCode::FORBIDDEN, "权限不足:需要 write 作用域");
246 }
247
248 if let Err(msg) = crate::mcp::auth::check_mcp_upload_limit(&principal.token_id) {
250 return mcp_upload_error(StatusCode::TOO_MANY_REQUESTS, msg);
251 }
252
253 let mut file = None;
255 let mut alt = None;
256 loop {
257 let field = match multipart.next_field().await {
258 Ok(Some(field)) => field,
259 Ok(None) => break,
260 Err(_) => return mcp_upload_error(StatusCode::BAD_REQUEST, "文件读取失败"),
261 };
262 if field.name() == Some("alt") {
263 if alt.is_some() {
264 return mcp_upload_error(StatusCode::BAD_REQUEST, "alt 字段重复");
265 }
266 alt = match field.text().await {
267 Ok(value) => Some(value),
268 Err(_) => return mcp_upload_error(StatusCode::BAD_REQUEST, "alt 读取失败"),
269 };
270 } else if field.name() == Some("file") || field.file_name().is_some() {
271 if file.is_some() {
272 return mcp_upload_error(StatusCode::BAD_REQUEST, "每次只允许上传一个文件");
273 }
274 if !ALLOWED_MIME_TYPES.contains(&field.content_type().unwrap_or("")) {
275 return mcp_upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型");
276 }
277 let filename = field.file_name().map(str::to_string);
278 let data = match field.bytes().await {
279 Ok(data) => data,
280 Err(_) => return mcp_upload_error(StatusCode::BAD_REQUEST, "文件读取失败"),
281 };
282 file = Some((data, filename));
283 } else {
284 return mcp_upload_error(StatusCode::BAD_REQUEST, "未知的上传字段");
285 }
286 }
287 let Some((data, original_filename)) = file else {
288 return mcp_upload_error(StatusCode::BAD_REQUEST, "未找到文件");
289 };
290
291 match process_image_upload(data, original_filename, alt).await {
293 Ok(out) => Json(json!({
294 "success": true,
295 "url": out.url,
296 "asset_id": out.asset_id,
297 "alt": out.alt,
298 "reused": out.reused,
299 "width": out.width,
300 "height": out.height,
301 "mime": out.mime
302 }))
303 .into_response(),
304 Err(e) => {
305 let (status, msg) = e.status_and_msg();
306 mcp_upload_error(status, msg)
307 }
308 }
309}
310
311#[cfg(feature = "server")]
317#[derive(Debug, serde::Serialize)]
318pub(crate) struct UploadOutcome {
319 pub url: String,
321 pub asset_id: String,
323 pub alt: Option<String>,
324 pub reused: bool,
326 pub width: u32,
327 pub height: u32,
328 pub mime: String,
330}
331
332#[cfg(feature = "server")]
334#[derive(Debug)]
335pub(crate) enum UploadError {
336 Empty,
337 BadType, TooLarge, Oversized, Corrupt, #[allow(dead_code)]
343 Internal(&'static str),
344}
345
346#[cfg(feature = "server")]
347impl UploadError {
348 fn internal<E: std::fmt::Display>(e: E, ctx: &'static str) -> Self {
350 tracing::error!("upload {ctx}: {e}");
351 UploadError::Internal(ctx)
352 }
353
354 fn status_and_msg(&self) -> (StatusCode, &'static str) {
356 match self {
357 UploadError::Empty => (StatusCode::BAD_REQUEST, "空文件"),
358 UploadError::BadType => (StatusCode::BAD_REQUEST, "不支持的文件类型"),
359 UploadError::TooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "文件超过大小限制"),
360 UploadError::Oversized => (StatusCode::BAD_REQUEST, "图片尺寸超过上限"),
361 UploadError::Corrupt => (StatusCode::BAD_REQUEST, "图片文件损坏或格式不正确"),
362 UploadError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "文件保存失败"),
363 }
364 }
365}
366
367#[cfg(feature = "server")]
376pub(crate) async fn process_image_upload(
377 data: bytes::Bytes,
378 original_filename: Option<String>,
379 alt: Option<String>,
380) -> Result<UploadOutcome, UploadError> {
381 if data.is_empty() {
382 return Err(UploadError::Empty);
383 }
384 if data.len() > MAX_FILE_SIZE {
385 return Err(UploadError::TooLarge);
386 }
387
388 let mime_type = detect_mime(&data).ok_or(UploadError::BadType)?;
390
391 let (img_width, img_height) =
394 crate::api::image::upload_dimensions(&data, mime_type).map_err(|msg| {
395 tracing::warn!("upload dimensions check failed: {msg}");
396 UploadError::Oversized
397 })?;
398
399 let alt = alt.map(|value| value.trim().to_string());
400 let is_gif = mime_type == "image/gif";
401 let is_webp = mime_type == "image/webp";
402
403 let content_hash = {
411 use sha2::Digest;
412 hex::encode(sha2::Sha256::digest(&data))
413 };
414 let missing_path = {
415 let client = crate::db::pool::get_conn()
416 .await
417 .map_err(|e| UploadError::internal(e, "dedup conn"))?;
418 let reused = client
419 .query_opt(
420 "UPDATE assets SET created_at = NOW(), updated_at = NOW(), \
421 alt = CASE WHEN $2::text IS NULL THEN alt ELSE NULLIF($2, '') END \
422 WHERE content_hash = $1 RETURNING id, path, mime, width, height, alt",
423 &[&content_hash, &alt],
424 )
425 .await
426 .map_err(|e| UploadError::internal(e, "dedup check"))?;
427 match reused {
428 Some(row) => {
429 let path: String = row.get("path");
430 if matches!(
432 tokio::fs::try_exists(format!("uploads/{path}")).await,
433 Ok(false)
434 ) {
435 tracing::warn!(
436 "Asset file missing, restoring from upload: {} (hash {})",
437 path,
438 &content_hash[..12]
439 );
440 Some(path)
441 } else {
442 tracing::info!(
443 "Image deduped: reuse {} (hash {})",
444 path,
445 &content_hash[..12]
446 );
447 return Ok(upload_outcome(&row, true));
448 }
449 }
450 None => None,
451 }
452 };
453
454 if is_gif || is_webp {
457 let validate_data = data.clone();
458 let validate_mime = mime_type.to_string();
459 let is_valid = tokio::task::spawn_blocking(move || {
460 validate_raw_image(&validate_data, validate_mime.as_str())
461 })
462 .await
463 .map_err(|e| UploadError::internal(e, "validate task"))?;
464 if !is_valid {
465 return Err(UploadError::Corrupt);
466 }
467 }
468
469 let (final_data, final_ext) = transcode(data, mime_type, is_gif, is_webp).await;
472
473 let final_mime = mime_for_ext(&final_ext);
476 let restore_in_place = missing_path
477 .as_deref()
478 .filter(|path| path.ends_with(&format!(".{final_ext}")));
479 let (rel_path, file_name) = match restore_in_place {
480 Some(path) => (
481 path.to_string(),
482 path.rsplit('/').next().unwrap_or(path).to_string(),
483 ),
484 None => {
485 let now = chrono::Utc::now();
486 let file_name = upload_file_name(now, uuid::Uuid::new_v4(), &final_ext);
487 (
489 format!("{}/{}", now.format("%Y/%m/%d"), file_name),
490 file_name,
491 )
492 }
493 };
494 let file_path = format!("uploads/{rel_path}");
495
496 if let Some(dir) = std::path::Path::new(&file_path).parent() {
497 if let Err(e) = tokio::fs::create_dir_all(dir).await {
498 return Err(UploadError::internal(e, "create dir"));
499 }
500 }
501 let tmp_path = format!("{file_path}.{}.tmp", uuid::Uuid::new_v4());
503 if let Err(e) = tokio::fs::write(&tmp_path, &final_data).await {
504 let _ = tokio::fs::remove_file(&tmp_path).await;
505 return Err(UploadError::internal(e, "write file"));
506 }
507 if let Err(e) = tokio::fs::rename(&tmp_path, &file_path).await {
508 let _ = tokio::fs::remove_file(&tmp_path).await;
509 return Err(UploadError::internal(e, "write file"));
510 }
511
512 tracing::info!("Image uploaded: {} ({} bytes)", file_path, final_data.len());
513
514 let registered: Result<tokio_postgres::Row, UploadError> = async {
517 let client = crate::db::pool::get_conn()
518 .await
519 .map_err(|e| UploadError::internal(e, "register conn"))?;
520 client.query_one(
521 "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height, content_hash, alt)
522 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULLIF($9, ''))
523 ON CONFLICT (content_hash) DO UPDATE SET
524 created_at = NOW(), updated_at = NOW(),
525 alt = CASE WHEN $9::text IS NULL THEN assets.alt ELSE NULLIF($9, '') END,
526 path = CASE WHEN assets.path = $10::text THEN EXCLUDED.path ELSE assets.path END,
527 mime = CASE WHEN assets.path = $10::text THEN EXCLUDED.mime ELSE assets.mime END,
528 size_bytes = CASE WHEN assets.path = $10::text
529 THEN EXCLUDED.size_bytes ELSE assets.size_bytes END,
530 width = CASE WHEN assets.path = $10::text THEN EXCLUDED.width ELSE assets.width END,
531 height = CASE WHEN assets.path = $10::text THEN EXCLUDED.height ELSE assets.height END
532 RETURNING id, path, mime, width, height, alt",
533 &[&uuid::Uuid::new_v4(), &rel_path,
534 &original_filename.unwrap_or_else(|| file_name.clone()), &final_mime,
535 &(final_data.len() as i64), &(img_width as i32), &(img_height as i32),
536 &content_hash, &alt, &missing_path],
537 ).await.map_err(|e| UploadError::internal(e, "register asset"))
538 }.await;
539
540 match registered {
541 Ok(row) => {
542 let reused = row.get::<_, String>("path") != rel_path;
543 if reused {
544 let _ = tokio::fs::remove_file(&file_path).await;
545 }
546 Ok(upload_outcome(&row, reused))
547 }
548 Err(e) => {
549 if restore_in_place.is_none() {
551 let _ = tokio::fs::remove_file(&file_path).await;
552 }
553 Err(e)
554 }
555 }
556}
557
558#[cfg(feature = "server")]
560fn upload_file_name(now: chrono::DateTime<chrono::Utc>, id: uuid::Uuid, ext: &str) -> String {
561 format!("{}.{}.{}", now.format("%H%M%S"), id, ext)
562}
563
564#[cfg(feature = "server")]
565fn upload_outcome(row: &tokio_postgres::Row, reused: bool) -> UploadOutcome {
566 UploadOutcome {
567 url: format!("/uploads/{}", row.get::<_, String>("path")),
568 asset_id: row.get::<_, uuid::Uuid>("id").to_string(),
569 alt: row.get("alt"),
570 reused,
571 width: row.get::<_, i32>("width") as u32,
572 height: row.get::<_, i32>("height") as u32,
573 mime: row.get("mime"),
574 }
575}
576
577#[cfg(feature = "server")]
583pub(crate) fn detect_mime(data: &[u8]) -> Option<&'static str> {
584 if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
585 Some("image/jpeg")
586 } else if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
587 Some("image/png")
588 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
589 Some("image/gif")
590 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
591 Some("image/webp")
592 } else {
593 None
594 }
595}
596
597#[cfg(feature = "server")]
598fn mime_to_ext(mime: &str) -> &'static str {
599 match mime {
600 "image/jpeg" => "jpg",
601 "image/png" => "png",
602 "image/webp" => "webp",
603 "image/gif" => "gif",
604 _ => "bin",
605 }
606}
607
608#[cfg(feature = "server")]
609fn mime_for_ext(ext: &str) -> &'static str {
610 match ext {
611 "jpg" => "image/jpeg",
612 "png" => "image/png",
613 "gif" => "image/gif",
614 _ => "image/webp",
615 }
616}
617
618#[cfg(feature = "server")]
620fn validate_raw_image(data: &[u8], mime_type: &str) -> bool {
621 match mime_type {
622 "image/webp" => crate::infra::webp::decode(data).is_ok(),
623 "image/gif" => image::load_from_memory(data).is_ok(),
624 _ => true,
625 }
626}
627
628#[cfg(feature = "server")]
630fn transcode_image_blocking(
631 data: &[u8],
632 mime: &'static str,
633 is_gif: bool,
634 is_webp: bool,
635) -> (Vec<u8>, String) {
636 if is_gif {
637 return (data.to_vec(), "gif".to_string());
638 }
639 if is_webp {
640 return (data.to_vec(), "webp".to_string());
641 }
642
643 let format = match mime {
645 "image/jpeg" => image::ImageFormat::Jpeg,
646 "image/png" => image::ImageFormat::Png,
647 _ => image::ImageFormat::Jpeg,
648 };
649 let cursor = std::io::Cursor::new(data);
650 let mut reader = image::ImageReader::with_format(cursor, format);
651 reader.limits(crate::api::image::image_reader_limits());
652
653 match reader.decode() {
654 Ok(img) => {
655 let config = crate::infra::webp::WEBP_CONFIG.clone();
656 match crate::infra::webp::encode(&img, config.quality, config.method) {
657 Ok(webp_data) if webp_data.len() < data.len() => {
658 tracing::info!(
659 "WebP conversion: {}x{} {} -> {} bytes",
660 img.width(),
661 img.height(),
662 data.len(),
663 webp_data.len()
664 );
665 (webp_data, "webp".to_string())
666 }
667 Ok(_) => {
668 (data.to_vec(), mime_to_ext(mime).to_string())
670 }
671 Err(e) => {
672 tracing::warn!("WebP encode failed ({}), keeping original", e);
673 (data.to_vec(), mime_to_ext(mime).to_string())
674 }
675 }
676 }
677 Err(e) => {
679 tracing::warn!("Failed to decode image ({}), keeping original format", e);
680 (data.to_vec(), mime_to_ext(mime).to_string())
681 }
682 }
683}
684
685#[cfg(feature = "server")]
688async fn transcode(
689 data: bytes::Bytes,
690 mime: &'static str,
691 is_gif: bool,
692 is_webp: bool,
693) -> (Vec<u8>, String) {
694 let for_task = data.clone();
695 match tokio::task::spawn_blocking(move || {
696 transcode_image_blocking(&for_task, mime, is_gif, is_webp)
697 })
698 .await
699 {
700 Ok(result) => result,
701 Err(e) => {
702 tracing::warn!("transcode task panicked ({}), keeping original", e);
703 (data.to_vec(), mime_to_ext(mime).to_string())
704 }
705 }
706}
707
708#[cfg(all(test, feature = "server"))]
709mod tests {
710
711 #[test]
713 #[ignore = "requires disposable DATABASE_URL database ygg_mcp_test"]
714 fn upload_metadata_database_and_http() {
715 crate::db::TEST_DATABASE_RUNTIME.block_on(upload_metadata_database_and_http_impl());
716 }
717
718 async fn upload_metadata_database_and_http_impl() {
719 let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
720 use super::*;
721 let mut client = crate::db::pool::get_conn().await.unwrap();
722 let database: String = client
723 .query_one("SELECT current_database()", &[])
724 .await
725 .unwrap()
726 .get(0);
727 assert_eq!(database, "ygg_mcp_test", "requires isolated test database");
728 crate::db::migrate::run_on_conn(&mut client).await.unwrap();
729 let owner = client
730 .query_opt("SELECT id FROM users WHERE role = 'admin'", &[])
731 .await
732 .unwrap();
733 let user_id: i32 = match owner {
734 Some(row) => row.get(0),
735 None => client.query_one("INSERT INTO users (username, email, password_hash, role) VALUES ('upload-test', 'upload@test.invalid', 'unused', 'admin') RETURNING id", &[]).await.unwrap().get(0),
736 };
737 let nonce = uuid::Uuid::new_v4();
738 let color = nonce.as_bytes();
739 let image = image::RgbaImage::from_fn(64, 64, |x, _| {
740 image::Rgba([color[x as usize % 16], color[1], color[2], 255])
741 });
742 let mut png = std::io::Cursor::new(Vec::new());
743 image.write_to(&mut png, image::ImageFormat::Png).unwrap();
744 let data = bytes::Bytes::from(png.into_inner());
745 let (a, b) = tokio::join!(
746 process_image_upload(
747 data.clone(),
748 Some("test.png".into()),
749 Some(" 首次图片 ".into())
750 ),
751 process_image_upload(
752 data.clone(),
753 Some("test.png".into()),
754 Some(" 首次图片 ".into())
755 ),
756 );
757 let (a, b) = (a.unwrap(), b.unwrap());
758 assert_eq!(a.asset_id, b.asset_id);
759 assert_eq!(a.url, b.url);
760 assert_ne!(a.reused, b.reused);
761 assert_eq!(a.mime, b.mime);
762 assert_eq!(a.alt.as_deref(), Some("首次图片"));
763 let reused = process_image_upload(data.clone(), None, None)
764 .await
765 .unwrap();
766 assert_eq!(reused.asset_id, a.asset_id);
767 assert_eq!(reused.alt, a.alt);
768 assert_eq!(reused.mime, a.mime);
769 let id = uuid::Uuid::parse_str(&a.asset_id).unwrap();
770 let stored = client
771 .query_one("SELECT alt, mime FROM assets WHERE id = $1", &[&id])
772 .await
773 .unwrap();
774 assert_eq!(stored.get::<_, Option<String>>(0), a.alt);
775 assert_eq!(stored.get::<_, String>(1), a.mime);
776
777 let disk_path = a.url.trim_start_matches('/').to_string();
779 tokio::fs::remove_file(&disk_path).await.unwrap();
780 let restored = process_image_upload(data.clone(), None, None)
781 .await
782 .unwrap();
783 assert_eq!(restored.asset_id, a.asset_id);
784 assert_eq!(restored.url, a.url);
785 assert!(!restored.reused);
786 assert!(tokio::fs::try_exists(&disk_path).await.unwrap());
787 assert!(
788 process_image_upload(data.clone(), None, None)
789 .await
790 .unwrap()
791 .reused
792 );
793
794 let ghost = "2000/01/01/000000.ghost.bin";
796 let original_path = a.url.trim_start_matches("/uploads/").to_string();
797 client
798 .execute("UPDATE assets SET path = $2 WHERE id = $1", &[&id, &ghost])
799 .await
800 .unwrap();
801 tokio::fs::remove_file(&disk_path).await.unwrap();
802 let moved = process_image_upload(data.clone(), None, None)
803 .await
804 .unwrap();
805 assert_eq!(moved.asset_id, a.asset_id);
806 assert!(!moved.reused);
807 assert_ne!(moved.url, format!("/uploads/{ghost}"));
808 let moved_path = moved.url.trim_start_matches('/');
809 assert!(tokio::fs::try_exists(moved_path).await.unwrap());
810 tokio::fs::rename(moved_path, &disk_path).await.unwrap();
812 client
813 .execute(
814 "UPDATE assets SET path = $2 WHERE id = $1",
815 &[&id, &original_path],
816 )
817 .await
818 .unwrap();
819
820 let token = format!("ygg_{nonce}");
822 client.execute("INSERT INTO mcp_tokens (id, user_id, name, scope, token_enc, token_hash) VALUES ($1, $2, 'upload-test', 'write', 'unused', $3)",
823 &[&nonce, &user_id, &crate::mcp::auth::hash_token(&token)]).await.unwrap();
824 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
825 let addr = listener.local_addr().unwrap();
826 let router = axum::Router::new().route("/upload", axum::routing::post(mcp_upload_image));
827 let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
828 let http = reqwest::Client::new();
829 let body = |alt: &str, alt_first: bool, duplicate: bool| {
830 let alt_part = format!(
831 "--test-boundary\r\nContent-Disposition: form-data; name=\"alt\"\r\n\r\n{alt}\r\n"
832 )
833 .into_bytes();
834 let mut file_part = b"--test-boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.png\"\r\nContent-Type: image/png\r\n\r\n".to_vec();
835 file_part.extend_from_slice(&data);
836 file_part.extend_from_slice(b"\r\n");
837 let mut out = Vec::new();
838 if alt_first {
839 out.extend_from_slice(&alt_part);
840 }
841 out.extend_from_slice(&file_part);
842 if duplicate {
843 out.extend_from_slice(&file_part);
844 }
845 if !alt_first {
846 out.extend_from_slice(&alt_part);
847 }
848 out.extend_from_slice(b"--test-boundary--\r\n");
849 out
850 };
851 for (alt, alt_first) in [(" HTTP 图片 ", true), (" ", false)] {
852 let response = http
853 .post(format!("http://{addr}/upload"))
854 .bearer_auth(&token)
855 .header(
856 "content-type",
857 "multipart/form-data; boundary=test-boundary",
858 )
859 .body(body(alt, alt_first, false))
860 .send()
861 .await
862 .unwrap();
863 let status = response.status();
864 let text = response.text().await.unwrap();
865 assert_eq!(status, StatusCode::OK, "{text}");
866 let result: serde_json::Value = serde_json::from_str(&text).unwrap();
867 assert_eq!(result["asset_id"], a.asset_id);
868 assert_eq!(result["mime"], a.mime);
869 let expected = if alt.trim().is_empty() {
870 None
871 } else {
872 Some(alt.trim().to_string())
873 };
874 assert_eq!(result["alt"], serde_json::to_value(&expected).unwrap());
875 assert_eq!(
876 client
877 .query_one("SELECT alt FROM assets WHERE id = $1", &[&id])
878 .await
879 .unwrap()
880 .get::<_, Option<String>>(0),
881 expected
882 );
883 }
884 let response = http
885 .post(format!("http://{addr}/upload"))
886 .bearer_auth(&token)
887 .header(
888 "content-type",
889 "multipart/form-data; boundary=test-boundary",
890 )
891 .body(body("rejected", false, true))
892 .send()
893 .await
894 .unwrap();
895 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
896 assert!(client
897 .query_one("SELECT alt FROM assets WHERE id = $1", &[&id])
898 .await
899 .unwrap()
900 .get::<_, Option<String>>(0)
901 .is_none());
902 let response = http
903 .post(format!("http://{addr}/upload"))
904 .header(
905 "content-type",
906 "multipart/form-data; boundary=test-boundary",
907 )
908 .body(body("unauthorized", true, false))
909 .send()
910 .await
911 .unwrap();
912 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
913 server.abort();
914 client
915 .execute("DELETE FROM mcp_tokens WHERE id = $1", &[&nonce])
916 .await
917 .unwrap();
918 client
919 .execute("DELETE FROM assets WHERE id = $1", &[&id])
920 .await
921 .unwrap();
922 tokio::fs::remove_file(a.url.trim_start_matches('/'))
923 .await
924 .unwrap();
925 }
926
927 fn encode_with(img: &image::DynamicImage, format: image::ImageFormat) -> Vec<u8> {
928 let mut out = std::io::Cursor::new(Vec::new());
929 img.write_to(&mut out, format).unwrap();
930 out.into_inner()
931 }
932
933 fn encode_jpeg(img: &image::DynamicImage, quality: u8) -> Vec<u8> {
934 let mut out = Vec::new();
935 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality)
936 .encode_image(img)
937 .unwrap();
938 out
939 }
940
941 fn gradient_image() -> image::DynamicImage {
943 image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(128, 128, |x, y| {
944 image::Rgb([(x * 2) as u8, (y * 2) as u8, (x + y) as u8])
945 }))
946 }
947
948 fn noise_image() -> image::DynamicImage {
950 let mut seed: u32 = 0x2545_f491;
951 image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(64, 64, |_, _| {
952 let mut px = [0u8; 3];
953 for c in &mut px {
954 seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
955 *c = (seed >> 24) as u8;
956 }
957 image::Rgb(px)
958 }))
959 }
960
961 #[test]
962 fn transcode_keeps_gif_bytes_and_extension() {
963 let gif = encode_with(&gradient_image(), image::ImageFormat::Gif);
964 let (data, ext) = super::transcode_image_blocking(&gif, "image/gif", true, false);
965 assert_eq!(ext, "gif");
966 assert_eq!(data, gif);
967 }
968
969 #[test]
970 fn transcode_keeps_webp_input_unchanged() {
971 let webp = crate::infra::webp::encode(&gradient_image(), 85.0, 2).unwrap();
972 let (data, ext) = super::transcode_image_blocking(&webp, "image/webp", false, true);
973 assert_eq!(ext, "webp");
974 assert_eq!(data, webp);
975 }
976
977 #[test]
978 fn transcode_converts_png_to_webp_when_smaller() {
979 let png = encode_with(&gradient_image(), image::ImageFormat::Png);
980 let (data, ext) = super::transcode_image_blocking(&png, "image/png", false, false);
981 assert_eq!(ext, "webp");
982 assert!(data.len() < png.len());
983 assert_eq!(super::detect_mime(&data), Some("image/webp"));
984 }
985
986 #[test]
987 fn transcode_converts_jpeg_to_webp_when_smaller() {
988 let jpeg = encode_jpeg(&gradient_image(), 90);
989 let (data, ext) = super::transcode_image_blocking(&jpeg, "image/jpeg", false, false);
990 assert_eq!(ext, "webp");
991 assert!(data.len() < jpeg.len());
992 assert_eq!(super::detect_mime(&data), Some("image/webp"));
993 }
994
995 #[test]
996 fn transcode_keeps_jpeg_when_webp_would_be_larger() {
997 let jpeg = encode_jpeg(&noise_image(), 10);
998 let (data, ext) = super::transcode_image_blocking(&jpeg, "image/jpeg", false, false);
999 assert_eq!(ext, "jpg");
1000 assert_eq!(data, jpeg);
1001 }
1002
1003 #[test]
1004 fn upload_file_name_joins_time_uuid_and_ext_without_spaces() {
1005 let now = chrono::DateTime::parse_from_rfc3339("2026-10-22T08:09:10Z")
1006 .unwrap()
1007 .with_timezone(&chrono::Utc);
1008 let id = uuid::Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
1009 let name = super::upload_file_name(now, id, "webp");
1010 assert_eq!(name, "080910.01234567-89ab-cdef-0123-456789abcdef.webp");
1011 assert!(!name.contains(' '));
1012 }
1013
1014 #[test]
1015 fn convert_to_webp_produces_bytes() {
1016 let img = image::DynamicImage::new_rgb8(10, 10);
1017 let result = crate::infra::webp::encode(&img, 85.0, 4).unwrap();
1018 assert!(!result.is_empty());
1019 }
1020
1021 #[test]
1022 fn webp_roundtrip_from_rgba() {
1023 let img = image::DynamicImage::new_rgba8(2, 2);
1024 let webp_bytes = crate::infra::webp::encode(&img, 85.0, 4).unwrap();
1025 let loaded = crate::infra::webp::decode(&webp_bytes);
1026 assert!(loaded.is_ok());
1027 }
1028
1029 #[test]
1030 fn mime_to_ext_maps_jpeg() {
1031 assert_eq!(super::mime_to_ext("image/jpeg"), "jpg");
1032 }
1033
1034 #[test]
1035 fn mime_to_ext_maps_png() {
1036 assert_eq!(super::mime_to_ext("image/png"), "png");
1037 }
1038
1039 #[test]
1040 fn mime_to_ext_maps_gif() {
1041 assert_eq!(super::mime_to_ext("image/gif"), "gif");
1042 }
1043
1044 #[test]
1045 fn mime_to_ext_maps_webp() {
1046 assert_eq!(super::mime_to_ext("image/webp"), "webp");
1047 }
1048
1049 #[test]
1050 fn mime_to_ext_falls_back_for_unknown_mime() {
1051 assert_eq!(super::mime_to_ext("image/avif"), "bin");
1052 assert_eq!(super::mime_to_ext("application/octet-stream"), "bin");
1053 }
1054
1055 #[test]
1056 fn mime_for_ext_roundtrip() {
1057 assert_eq!(super::mime_for_ext("jpg"), "image/jpeg");
1058 assert_eq!(super::mime_for_ext("png"), "image/png");
1059 assert_eq!(super::mime_for_ext("gif"), "image/gif");
1060 assert_eq!(super::mime_for_ext("webp"), "image/webp");
1061 }
1062
1063 #[test]
1064 fn detect_mime_jpeg() {
1065 assert_eq!(
1066 super::detect_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
1067 Some("image/jpeg")
1068 );
1069 assert_eq!(super::detect_mime(&[0x89, 0x50]), None);
1070 }
1071
1072 #[test]
1073 fn detect_mime_png() {
1074 assert_eq!(
1075 super::detect_mime(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
1076 Some("image/png")
1077 );
1078 assert_eq!(super::detect_mime(&[0xFF, 0xD8]), None);
1079 }
1080
1081 #[test]
1082 fn detect_mime_gif() {
1083 assert_eq!(super::detect_mime(b"GIF89a"), Some("image/gif"));
1084 assert_eq!(super::detect_mime(b"GIF87a"), Some("image/gif"));
1085 assert_eq!(super::detect_mime(b"GIF90a"), None);
1086 }
1087
1088 #[test]
1089 fn detect_mime_webp() {
1090 let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ";
1091 assert_eq!(super::detect_mime(&webp[..12]), Some("image/webp"));
1092 assert_eq!(super::detect_mime(&[0xFF, 0xD8]), None);
1093 }
1094
1095 #[test]
1096 fn detect_mime_unknown() {
1097 assert_eq!(super::detect_mime(b"hello world"), None);
1098 assert_eq!(super::detect_mime(&[]), None);
1099 }
1100}