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, None).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, None).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 mut file = None;
268 let mut alt = None;
269 loop {
270 let field = match multipart.next_field().await {
271 Ok(Some(field)) => field,
272 Ok(None) => break,
273 Err(_) => return mcp_upload_error(StatusCode::BAD_REQUEST, "文件读取失败"),
274 };
275 if field.name() == Some("alt") {
276 if alt.is_some() {
277 return mcp_upload_error(StatusCode::BAD_REQUEST, "alt 字段重复");
278 }
279 alt = match field.text().await {
280 Ok(value) => Some(value),
281 Err(_) => return mcp_upload_error(StatusCode::BAD_REQUEST, "alt 读取失败"),
282 };
283 } else if field.name() == Some("file") || field.file_name().is_some() {
284 if file.is_some() {
285 return mcp_upload_error(StatusCode::BAD_REQUEST, "每次只允许上传一个文件");
286 }
287 if !ALLOWED_MIME_TYPES.contains(&field.content_type().unwrap_or("")) {
288 return mcp_upload_error(StatusCode::BAD_REQUEST, "不支持的文件类型");
289 }
290 let filename = field.file_name().map(str::to_string);
291 let data = match field.bytes().await {
292 Ok(data) => data,
293 Err(_) => return mcp_upload_error(StatusCode::BAD_REQUEST, "文件读取失败"),
294 };
295 file = Some((data, filename));
296 } else {
297 return mcp_upload_error(StatusCode::BAD_REQUEST, "未知的上传字段");
298 }
299 }
300 let Some((data, original_filename)) = file else {
301 return mcp_upload_error(StatusCode::BAD_REQUEST, "未找到文件");
302 };
303
304 match process_image_upload(data, original_filename, alt).await {
306 Ok(out) => Json(json!({
307 "success": true,
308 "url": out.url,
309 "asset_id": out.asset_id,
310 "alt": out.alt,
311 "reused": out.reused,
312 "width": out.width,
313 "height": out.height,
314 "mime": out.mime
315 }))
316 .into_response(),
317 Err(e) => {
318 let (status, msg) = e.status_and_msg();
319 mcp_upload_error(status, msg)
320 }
321 }
322}
323
324#[cfg(feature = "server")]
330#[derive(Debug, serde::Serialize)]
331pub(crate) struct UploadOutcome {
332 pub url: String,
334 pub asset_id: String,
336 pub alt: Option<String>,
337 pub reused: bool,
339 pub width: u32,
340 pub height: u32,
341 pub mime: String,
343}
344
345#[cfg(feature = "server")]
347#[derive(Debug)]
348pub(crate) enum UploadError {
349 Empty,
350 BadType, TooLarge, Oversized, Corrupt, #[allow(dead_code)]
356 Internal(&'static str),
357}
358
359#[cfg(feature = "server")]
360impl UploadError {
361 fn internal<E: std::fmt::Display>(e: E, ctx: &'static str) -> Self {
363 tracing::error!("upload {ctx}: {e}");
364 UploadError::Internal(ctx)
365 }
366
367 fn status_and_msg(&self) -> (StatusCode, &'static str) {
369 match self {
370 UploadError::Empty => (StatusCode::BAD_REQUEST, "空文件"),
371 UploadError::BadType => (StatusCode::BAD_REQUEST, "不支持的文件类型"),
372 UploadError::TooLarge => (StatusCode::PAYLOAD_TOO_LARGE, "文件超过大小限制"),
373 UploadError::Oversized => (StatusCode::BAD_REQUEST, "图片尺寸超过上限"),
374 UploadError::Corrupt => (StatusCode::BAD_REQUEST, "图片文件损坏或格式不正确"),
375 UploadError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "文件保存失败"),
376 }
377 }
378}
379
380#[cfg(feature = "server")]
389pub(crate) async fn process_image_upload(
390 data: bytes::Bytes,
391 original_filename: Option<String>,
392 alt: Option<String>,
393) -> Result<UploadOutcome, UploadError> {
394 if data.is_empty() {
395 return Err(UploadError::Empty);
396 }
397 if data.len() > MAX_FILE_SIZE {
398 return Err(UploadError::TooLarge);
399 }
400
401 let mime_type = detect_mime(&data).ok_or(UploadError::BadType)?;
403
404 let (img_width, img_height) =
407 crate::api::image::upload_dimensions(&data, mime_type).map_err(|msg| {
408 tracing::warn!("upload dimensions check failed: {msg}");
409 UploadError::Oversized
410 })?;
411
412 let alt = alt.map(|value| value.trim().to_string());
413 let is_gif = mime_type == "image/gif";
414 let is_webp = mime_type == "image/webp";
415
416 let content_hash = {
422 use sha2::Digest;
423 hex::encode(sha2::Sha256::digest(&data))
424 };
425 {
426 let client = crate::db::pool::get_conn()
427 .await
428 .map_err(|e| UploadError::internal(e, "dedup conn"))?;
429 let reused = client
430 .query_opt(
431 "UPDATE assets SET created_at = NOW(), updated_at = NOW(), \
432 alt = CASE WHEN $2::text IS NULL THEN alt ELSE NULLIF($2, '') END \
433 WHERE content_hash = $1 RETURNING id, path, mime, width, height, alt",
434 &[&content_hash, &alt],
435 )
436 .await
437 .map_err(|e| UploadError::internal(e, "dedup check"))?;
438 if let Some(row) = reused {
439 let path: String = row.get("path");
440 tracing::info!(
441 "Image deduped: reuse {} (hash {})",
442 path,
443 &content_hash[..12]
444 );
445 return Ok(upload_outcome(&row, true));
446 }
447 }
448
449 if is_gif || is_webp {
452 let validate_data = data.clone();
453 let validate_mime = mime_type.to_string();
454 let is_valid = tokio::task::spawn_blocking(move || {
455 validate_raw_image(&validate_data, validate_mime.as_str())
456 })
457 .await
458 .map_err(|e| UploadError::internal(e, "validate task"))?;
459 if !is_valid {
460 return Err(UploadError::Corrupt);
461 }
462 }
463
464 let (final_data, final_ext) = transcode(data, mime_type, is_gif, is_webp).await;
467
468 let now = chrono::Utc::now();
471 let date = now.format("%Y/%m/%d");
472 let uuid_str = uuid::Uuid::new_v4().to_string();
473
474 let dir_path = format!("uploads/{}", date);
475 let file_name = format!("{}.{}.{}", now.format("%H%M%S"), uuid_str, final_ext);
476 let file_path = format!("{}/{}", dir_path, file_name);
477 let rel_path = format!("{}/{}", date, file_name);
478 let final_mime = mime_for_ext(&final_ext);
479
480 if let Err(e) = tokio::fs::create_dir_all(&dir_path).await {
481 return Err(UploadError::internal(e, "create dir"));
482 }
483 if let Err(e) = tokio::fs::write(&file_path, &final_data).await {
484 return Err(UploadError::internal(e, "write file"));
485 }
486
487 tracing::info!("Image uploaded: {} ({} bytes)", file_path, final_data.len());
488
489 let registered: Result<tokio_postgres::Row, UploadError> = async {
491 let client = crate::db::pool::get_conn()
492 .await
493 .map_err(|e| UploadError::internal(e, "register conn"))?;
494 client.query_one(
495 "INSERT INTO assets (id, path, filename, mime, size_bytes, width, height, content_hash, alt)
496 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULLIF($9, ''))
497 ON CONFLICT (content_hash) DO UPDATE SET
498 created_at = NOW(), updated_at = NOW(),
499 alt = CASE WHEN $9::text IS NULL THEN assets.alt ELSE NULLIF($9, '') END
500 RETURNING id, path, mime, width, height, alt",
501 &[&uuid::Uuid::new_v4(), &rel_path,
502 &original_filename.unwrap_or_else(|| file_name.clone()), &final_mime,
503 &(final_data.len() as i64), &(img_width as i32), &(img_height as i32),
504 &content_hash, &alt],
505 ).await.map_err(|e| UploadError::internal(e, "register asset"))
506 }.await;
507
508 match registered {
509 Ok(row) => {
510 let reused = row.get::<_, String>("path") != rel_path;
511 if reused {
512 let _ = tokio::fs::remove_file(&file_path).await;
513 }
514 Ok(upload_outcome(&row, reused))
515 }
516 Err(e) => {
517 let _ = tokio::fs::remove_file(&file_path).await;
519 Err(e)
520 }
521 }
522}
523
524#[cfg(feature = "server")]
525fn upload_outcome(row: &tokio_postgres::Row, reused: bool) -> UploadOutcome {
526 UploadOutcome {
527 url: format!("/uploads/{}", row.get::<_, String>("path")),
528 asset_id: row.get::<_, uuid::Uuid>("id").to_string(),
529 alt: row.get("alt"),
530 reused,
531 width: row.get::<_, i32>("width") as u32,
532 height: row.get::<_, i32>("height") as u32,
533 mime: row.get("mime"),
534 }
535}
536
537#[cfg(feature = "server")]
543pub(crate) fn detect_mime(data: &[u8]) -> Option<&'static str> {
544 if data.starts_with(&[0xFF, 0xD8, 0xFF]) {
545 Some("image/jpeg")
546 } else if data.starts_with(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) {
547 Some("image/png")
548 } else if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") {
549 Some("image/gif")
550 } else if data.len() >= 12 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
551 Some("image/webp")
552 } else {
553 None
554 }
555}
556
557#[cfg(feature = "server")]
558fn mime_to_ext(mime: &str) -> &'static str {
559 match mime {
560 "image/jpeg" => "jpg",
561 "image/png" => "png",
562 "image/webp" => "webp",
563 "image/gif" => "gif",
564 _ => "bin",
565 }
566}
567
568#[cfg(feature = "server")]
569fn mime_for_ext(ext: &str) -> &'static str {
570 match ext {
571 "jpg" => "image/jpeg",
572 "png" => "image/png",
573 "gif" => "image/gif",
574 _ => "image/webp",
575 }
576}
577
578#[cfg(feature = "server")]
580fn validate_raw_image(data: &[u8], mime_type: &str) -> bool {
581 match mime_type {
582 "image/webp" => crate::infra::webp::decode(data).is_ok(),
583 "image/gif" => image::load_from_memory(data).is_ok(),
584 _ => true,
585 }
586}
587
588#[cfg(feature = "server")]
590fn transcode_image_blocking(
591 data: &[u8],
592 mime: &'static str,
593 is_gif: bool,
594 is_webp: bool,
595) -> (Vec<u8>, String) {
596 if is_gif {
597 return (data.to_vec(), "gif".to_string());
598 }
599 if is_webp {
600 return (data.to_vec(), "webp".to_string());
601 }
602
603 let format = match mime {
605 "image/jpeg" => image::ImageFormat::Jpeg,
606 "image/png" => image::ImageFormat::Png,
607 _ => image::ImageFormat::Jpeg,
608 };
609 let cursor = std::io::Cursor::new(data);
610 let mut reader = image::ImageReader::with_format(cursor, format);
611 reader.limits(crate::api::image::image_reader_limits());
612
613 match reader.decode() {
614 Ok(img) => {
615 let config = crate::infra::webp::WEBP_CONFIG.clone();
616 match crate::infra::webp::encode(&img, config.quality, config.method) {
617 Ok(webp_data) if webp_data.len() < data.len() => {
618 tracing::info!(
619 "WebP conversion: {}x{} {} -> {} bytes",
620 img.width(),
621 img.height(),
622 data.len(),
623 webp_data.len()
624 );
625 (webp_data, "webp".to_string())
626 }
627 Ok(_) => {
628 (data.to_vec(), mime_to_ext(mime).to_string())
630 }
631 Err(e) => {
632 tracing::warn!("WebP encode failed ({}), keeping original", e);
633 (data.to_vec(), mime_to_ext(mime).to_string())
634 }
635 }
636 }
637 Err(e) => {
639 tracing::warn!("Failed to decode image ({}), keeping original format", e);
640 (data.to_vec(), mime_to_ext(mime).to_string())
641 }
642 }
643}
644
645#[cfg(feature = "server")]
648async fn transcode(
649 data: bytes::Bytes,
650 mime: &'static str,
651 is_gif: bool,
652 is_webp: bool,
653) -> (Vec<u8>, String) {
654 let for_task = data.clone();
655 match tokio::task::spawn_blocking(move || {
656 transcode_image_blocking(&for_task, mime, is_gif, is_webp)
657 })
658 .await
659 {
660 Ok(result) => result,
661 Err(e) => {
662 tracing::warn!("transcode task panicked ({}), keeping original", e);
663 (data.to_vec(), mime_to_ext(mime).to_string())
664 }
665 }
666}
667
668#[cfg(all(test, feature = "server"))]
669mod tests {
670
671 #[test]
673 #[ignore = "requires disposable DATABASE_URL database ygg_mcp_test"]
674 fn upload_metadata_database_and_http() {
675 crate::db::TEST_DATABASE_RUNTIME.block_on(upload_metadata_database_and_http_impl());
676 }
677
678 async fn upload_metadata_database_and_http_impl() {
679 let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
680 use super::*;
681 let mut client = crate::db::pool::get_conn().await.unwrap();
682 let database: String = client
683 .query_one("SELECT current_database()", &[])
684 .await
685 .unwrap()
686 .get(0);
687 assert_eq!(database, "ygg_mcp_test", "requires isolated test database");
688 crate::db::migrate::run_on_conn(&mut client).await.unwrap();
689 let owner = client
690 .query_opt("SELECT id FROM users WHERE role = 'admin'", &[])
691 .await
692 .unwrap();
693 let user_id: i32 = match owner {
694 Some(row) => row.get(0),
695 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),
696 };
697 let nonce = uuid::Uuid::new_v4();
698 let color = nonce.as_bytes();
699 let image = image::RgbaImage::from_fn(64, 64, |x, _| {
700 image::Rgba([color[x as usize % 16], color[1], color[2], 255])
701 });
702 let mut png = std::io::Cursor::new(Vec::new());
703 image.write_to(&mut png, image::ImageFormat::Png).unwrap();
704 let data = bytes::Bytes::from(png.into_inner());
705 let (a, b) = tokio::join!(
706 process_image_upload(
707 data.clone(),
708 Some("test.png".into()),
709 Some(" 首次图片 ".into())
710 ),
711 process_image_upload(
712 data.clone(),
713 Some("test.png".into()),
714 Some(" 首次图片 ".into())
715 ),
716 );
717 let (a, b) = (a.unwrap(), b.unwrap());
718 assert_eq!(a.asset_id, b.asset_id);
719 assert_eq!(a.url, b.url);
720 assert_ne!(a.reused, b.reused);
721 assert_eq!(a.mime, b.mime);
722 assert_eq!(a.alt.as_deref(), Some("首次图片"));
723 let reused = process_image_upload(data.clone(), None, None)
724 .await
725 .unwrap();
726 assert_eq!(reused.asset_id, a.asset_id);
727 assert_eq!(reused.alt, a.alt);
728 assert_eq!(reused.mime, a.mime);
729 let id = uuid::Uuid::parse_str(&a.asset_id).unwrap();
730 let stored = client
731 .query_one("SELECT alt, mime FROM assets WHERE id = $1", &[&id])
732 .await
733 .unwrap();
734 assert_eq!(stored.get::<_, Option<String>>(0), a.alt);
735 assert_eq!(stored.get::<_, String>(1), a.mime);
736
737 let token = format!("ygg_{nonce}");
739 client.execute("INSERT INTO mcp_tokens (id, user_id, name, scope, token_enc, token_hash) VALUES ($1, $2, 'upload-test', 'write', 'unused', $3)",
740 &[&nonce, &user_id, &crate::mcp::auth::hash_token(&token)]).await.unwrap();
741 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
742 let addr = listener.local_addr().unwrap();
743 let router = axum::Router::new().route("/upload", axum::routing::post(mcp_upload_image));
744 let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
745 let http = reqwest::Client::new();
746 let body = |alt: &str, alt_first: bool, duplicate: bool| {
747 let alt_part = format!(
748 "--test-boundary\r\nContent-Disposition: form-data; name=\"alt\"\r\n\r\n{alt}\r\n"
749 )
750 .into_bytes();
751 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();
752 file_part.extend_from_slice(&data);
753 file_part.extend_from_slice(b"\r\n");
754 let mut out = Vec::new();
755 if alt_first {
756 out.extend_from_slice(&alt_part);
757 }
758 out.extend_from_slice(&file_part);
759 if duplicate {
760 out.extend_from_slice(&file_part);
761 }
762 if !alt_first {
763 out.extend_from_slice(&alt_part);
764 }
765 out.extend_from_slice(b"--test-boundary--\r\n");
766 out
767 };
768 for (alt, alt_first) in [(" HTTP 图片 ", true), (" ", false)] {
769 let response = http
770 .post(format!("http://{addr}/upload"))
771 .bearer_auth(&token)
772 .header(
773 "content-type",
774 "multipart/form-data; boundary=test-boundary",
775 )
776 .body(body(alt, alt_first, false))
777 .send()
778 .await
779 .unwrap();
780 let status = response.status();
781 let text = response.text().await.unwrap();
782 assert_eq!(status, StatusCode::OK, "{text}");
783 let result: serde_json::Value = serde_json::from_str(&text).unwrap();
784 assert_eq!(result["asset_id"], a.asset_id);
785 assert_eq!(result["mime"], a.mime);
786 let expected = if alt.trim().is_empty() {
787 None
788 } else {
789 Some(alt.trim().to_string())
790 };
791 assert_eq!(result["alt"], serde_json::to_value(&expected).unwrap());
792 assert_eq!(
793 client
794 .query_one("SELECT alt FROM assets WHERE id = $1", &[&id])
795 .await
796 .unwrap()
797 .get::<_, Option<String>>(0),
798 expected
799 );
800 }
801 let response = http
802 .post(format!("http://{addr}/upload"))
803 .bearer_auth(&token)
804 .header(
805 "content-type",
806 "multipart/form-data; boundary=test-boundary",
807 )
808 .body(body("rejected", false, true))
809 .send()
810 .await
811 .unwrap();
812 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
813 assert!(client
814 .query_one("SELECT alt FROM assets WHERE id = $1", &[&id])
815 .await
816 .unwrap()
817 .get::<_, Option<String>>(0)
818 .is_none());
819 let response = http
820 .post(format!("http://{addr}/upload"))
821 .header(
822 "content-type",
823 "multipart/form-data; boundary=test-boundary",
824 )
825 .body(body("unauthorized", true, false))
826 .send()
827 .await
828 .unwrap();
829 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
830 server.abort();
831 client
832 .execute("DELETE FROM mcp_tokens WHERE id = $1", &[&nonce])
833 .await
834 .unwrap();
835 client
836 .execute("DELETE FROM assets WHERE id = $1", &[&id])
837 .await
838 .unwrap();
839 tokio::fs::remove_file(a.url.trim_start_matches('/'))
840 .await
841 .unwrap();
842 }
843
844 #[test]
845 fn filename_format_no_spaces() {
846 let now_str = "120000";
847 let uuid = "abc-123";
848 let ext = "jpg";
849 let file_name = format!("{}.{}.{}", now_str, uuid, ext);
850 assert!(
851 !file_name.contains(' '),
852 "filename should not contain spaces: got '{}'",
853 file_name
854 );
855 }
856
857 #[test]
858 fn should_use_webp_ext_for_non_gif() {
859 let ext = "jpg";
860 let mime = "image/jpeg";
861 let is_gif = mime == "image/gif";
862 let final_ext = if is_gif { ext } else { "webp" };
863 assert_eq!(final_ext, "webp");
864 }
865
866 #[test]
867 fn should_preserve_gif_ext() {
868 let ext = "gif";
869 let mime = "image/gif";
870 let is_gif = mime == "image/gif";
871 let final_ext = if is_gif { ext } else { "webp" };
872 assert_eq!(final_ext, "gif");
873 }
874
875 #[test]
876 fn convert_to_webp_produces_bytes() {
877 let img = image::DynamicImage::new_rgb8(10, 10);
878 let result = crate::infra::webp::encode(&img, 85.0, 4).unwrap();
879 assert!(!result.is_empty());
880 }
881
882 #[test]
883 fn webp_roundtrip_from_rgba() {
884 let img = image::DynamicImage::new_rgba8(2, 2);
885 let webp_bytes = crate::infra::webp::encode(&img, 85.0, 4).unwrap();
886 let loaded = crate::infra::webp::decode(&webp_bytes);
887 assert!(loaded.is_ok());
888 }
889
890 #[test]
891 fn mime_to_ext_maps_jpeg() {
892 assert_eq!(super::mime_to_ext("image/jpeg"), "jpg");
893 }
894
895 #[test]
896 fn mime_to_ext_maps_png() {
897 assert_eq!(super::mime_to_ext("image/png"), "png");
898 }
899
900 #[test]
901 fn mime_to_ext_maps_gif() {
902 assert_eq!(super::mime_to_ext("image/gif"), "gif");
903 }
904
905 #[test]
906 fn mime_to_ext_maps_webp() {
907 assert_eq!(super::mime_to_ext("image/webp"), "webp");
908 }
909
910 #[test]
911 fn mime_to_ext_falls_back_for_unknown_mime() {
912 assert_eq!(super::mime_to_ext("image/avif"), "bin");
913 assert_eq!(super::mime_to_ext("application/octet-stream"), "bin");
914 }
915
916 #[test]
917 fn mime_for_ext_roundtrip() {
918 assert_eq!(super::mime_for_ext("jpg"), "image/jpeg");
919 assert_eq!(super::mime_for_ext("png"), "image/png");
920 assert_eq!(super::mime_for_ext("gif"), "image/gif");
921 assert_eq!(super::mime_for_ext("webp"), "image/webp");
922 }
923
924 #[test]
925 fn detect_mime_jpeg() {
926 assert_eq!(
927 super::detect_mime(&[0xFF, 0xD8, 0xFF, 0xE0]),
928 Some("image/jpeg")
929 );
930 assert_eq!(super::detect_mime(&[0x89, 0x50]), None);
931 }
932
933 #[test]
934 fn detect_mime_png() {
935 assert_eq!(
936 super::detect_mime(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
937 Some("image/png")
938 );
939 assert_eq!(super::detect_mime(&[0xFF, 0xD8]), None);
940 }
941
942 #[test]
943 fn detect_mime_gif() {
944 assert_eq!(super::detect_mime(b"GIF89a"), Some("image/gif"));
945 assert_eq!(super::detect_mime(b"GIF87a"), Some("image/gif"));
946 assert_eq!(super::detect_mime(b"GIF90a"), None);
947 }
948
949 #[test]
950 fn detect_mime_webp() {
951 let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 ";
952 assert_eq!(super::detect_mime(&webp[..12]), Some("image/webp"));
953 assert_eq!(super::detect_mime(&[0xFF, 0xD8]), None);
954 }
955
956 #[test]
957 fn detect_mime_unknown() {
958 assert_eq!(super::detect_mime(b"hello world"), None);
959 assert_eq!(super::detect_mime(&[]), None);
960 }
961}