Skip to main content

yggdrasil/api/
upload.rs

1//! 图片上传:web 处理器 + 共享入库流水线。
2//!
3//! 两条入口共用 `process_image_upload`:
4//! - web `POST /api/upload`(cookie 鉴权,multipart)—— 见 `upload_image`;
5//! - MCP `POST /api/mcp/upload`(bearer 鉴权,multipart)—— 见 `mcp_upload_image`;
6//! - MCP `upload_media` 工具(URL 抓取)—— 见 `src/mcp/tools/media.rs`。
7//!
8//! 流水线:magic bytes 检 MIME → 大小校验 → 尺寸/像素校验 → SHA-256 内容去重
9//! (命中即复用)→ GIF/WebP 解码校验 → `spawn_blocking` 转码(GIF/WebP 原样,
10//! JPEG/PNG 仅在更小时转 WebP)→ 按日期落盘 → assets 登记(含并发竞态补偿)。
11//! JPEG/PNG 自动转 WebP(若体积更小则保留原格式),GIF/WebP 保持原样。
12//! 文件按日期分目录存放于 `uploads/`。
13//!
14//! 内容去重(CAS):以原始上传字节的 SHA-256 为内容指纹(`assets.content_hash`,
15//! 唯一索引)。重复上传同一内容时复用已登记素材——同一行、同一文件,不重复
16//! 落盘,响应带 `"reused": true`;并发同内容上传由唯一索引 + ON CONFLICT 兜底。
17//! 仅精确去重:尺寸/压缩不同的视觉相似图不合并(那是感知哈希 pHash 的领域,
18//! 有意不做)。
19//! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。
20
21#[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// ===========================================================================
43// web 处理器(cookie 鉴权)
44// ===========================================================================
45
46/// 构造统一的 JSON 错误响应:`{ "success": false, "error": msg }`。
47#[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/// 处理图片上传的 Axum handler(web 端,cookie 鉴权)。
53///
54/// 流程:限流 → 解析 session → 校验 admin → 读取 multipart → 早拒非法声明类型 →
55/// 读取字节 → 交给共享流水线 `process_image_upload`。
56///
57/// `ConnectInfo` 以可选扩展注入:`dioxus::server::serve()` 接管了 listener,
58/// 无法调用 `into_make_service_with_connect_info::<SocketAddr>()`,所以这里
59/// 与 `serve_image` 保持一致的优雅降级——扩展缺失时退回 `"unknown"` 限流桶。
60/// 生产环境应在反向代理后部署并配置 `TRUSTED_PROXY_COUNT`,让限流拿到真实 IP。
61#[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    // 0. Rate limit check
68    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    // 1. Extract session from cookie
75    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    // 2. Verify admin
88    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    // 3. Read multipart field
100    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    // 4. 早拒非法声明类型(快速路径,避免读字节后再判)。
112    //    流水线仍以 magic bytes 为权威——声明 jpeg 但实为 png 会被识别为 png 接受。
113    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    // 原始文件名(客户端提供,仅作 assets 表展示字段);需在 bytes() 消耗 field 前取出。
119    let original_filename = field.file_name().map(|s| s.to_string());
120
121    // 5. Read file data
122    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    // 6. 共享入库流水线。
134    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// ===========================================================================
148// MCP 处理器(bearer 鉴权,multipart 二进制,带外传输)
149// ===========================================================================
150
151/// MCP bearer 上传错误 → JSON 响应(与 web 端格式一致)。
152#[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/// 处理图片上传的 Axum handler(MCP 端,bearer token 鉴权)。
158///
159/// 与 web `upload_image` 的区别:
160/// - 鉴权用 `Authorization: Bearer ygg_...`(不是 cookie),经
161///   [`crate::mcp::auth::resolve_bearer_principal`] 解析;
162/// - 不挂 CSRF 中间件——bearer 在请求头里,浏览器不会自动附带,无 CSRF 风险;
163/// - 限流按 token_id 计数(复用 MCP 的 token-keyed governor)。
164///
165/// 供 AI 客户端的 host/shell 直接 POST 二进制(Claude Code 的 Bash+curl 等),
166/// 二进制不经 JSON-RPC,绕开 rmcp 4MiB 请求体上限。返回可直接嵌入 Markdown 的
167/// `/uploads/...` URL。
168#[cfg(feature = "server")]
169pub async fn mcp_upload_image(headers: HeaderMap, mut multipart: Multipart) -> Response {
170    // 1. bearer → principal(含 scope 校验:media 需要 write)。
171    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    // 2. token-keyed 限流(与 /mcp 中间件的 MCP_LIMITER 隔离:上传单独配额)。
183    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    // 3. 读取 multipart 字段。
188    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    // 早拒非法声明类型(快速路径)。
198    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    // 4. 共享入库流水线。
213    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// ===========================================================================
231// 共享入库流水线
232// ===========================================================================
233
234/// 单条图片入库的结果。
235#[cfg(feature = "server")]
236#[derive(Debug, serde::Serialize)]
237pub(crate) struct UploadOutcome {
238    /// 可直接嵌入 Markdown 的相对 URL:`/uploads/YYYY/MM/DD/HHMMSS.uuid.ext`。
239    pub url: String,
240    /// 是否命中已登记素材(内容去重或并发竞态复用)。
241    pub reused: bool,
242    pub width: u32,
243    pub height: u32,
244    /// 最终 MIME(转码后;JPEG→WebP 成功则为 image/webp)。
245    pub mime: String,
246}
247
248/// 流水线错误:映射到 HTTP 状态 + 脱敏消息(不泄露 SQL/路径细节)。
249#[cfg(feature = "server")]
250#[derive(Debug)]
251pub(crate) enum UploadError {
252    Empty,
253    BadType,   // magic bytes 无法识别为 JPEG/PNG/GIF/WebP
254    TooLarge,  // 超过 MAX_FILE_SIZE
255    Oversized, // 像素超过 MAX_IMAGE_PIXELS
256    Corrupt,   // GIF/WebP 解码失败
257    /// 内部错误:携带静态上下文标签供 Debug 诊断(status_and_msg 统一返回脱敏消息)。
258    #[allow(dead_code)]
259    Internal(&'static str),
260}
261
262#[cfg(feature = "server")]
263impl UploadError {
264    /// 包装底层错误:服务端日志记完整 `{e}`,客户端只见静态 `ctx`。
265    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    /// 映射到 (HTTP 状态, 脱敏消息)。
271    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/// 单一图片入库流水线(web 上传 / MCP bearer 端点 / MCP URL 抓取共用)。
284///
285/// 输入:原始字节 + 可选展示文件名。**不信任客户端声明的 MIME**——以 magic
286/// bytes 为唯一真相。输出可直接嵌入 Markdown 的 `/uploads/...` URL。
287///
288/// 步骤:大小校验 → magic bytes 检 MIME → 尺寸/像素校验 → SHA-256 去重(命中
289/// 即复用,跳过最贵的转码)→ GIF/WebP 解码校验 → `spawn_blocking` 转码 →
290/// 按日期落盘 → assets 登记(含并发竞态补偿,落败者删自己的文件复用胜出者)。
291#[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    // 1. magic bytes 检 MIME(不信任声明类型/扩展名)。
304    let mime_type = detect_mime(&data).ok_or(UploadError::BadType)?;
305
306    // 2. 仅读 header 校验尺寸/像素上限,并拿回 (w,h) 供 assets 登记,避免二次解析。
307    //    超限直接拒绝,避免大图 decode 后被静默降级(原 fallback 存原图)。
308    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    // 3. 内容去重(CAS):对原始上传字节算 SHA-256,命中已登记素材直接复用,
318    //    跳过 GIF/WebP 解码验证、转码与落盘(省下整个流程最贵的 CPU)。
319    //    放在安全性校验之后、转码之前。命中时刷新 created_at/updated_at:
320    //    重传代表使用意图,重启 7 天清理保护窗(PURGE_GRACE_DAYS 保护的是
321    //    「刚上传还没被文章引用」的素材)。
322    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    // 4. GIF/WebP 解码校验(不经过重编码的格式必须验真,防伪造扩展名的恶意文件)。
356    //    GIF 走 image::load_from_memory 会完整解码,移到阻塞线程池避免拖住 async 运行时。
357    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    // 5. 转码:GIF/WebP 原样;JPEG/PNG 仅在 WebP 更小时转。
371    //    Bytes clone 廉价(引用计数 +1),move 进阻塞闭包无需全文件深拷贝。
372    let (final_data, final_ext) = transcode(data, mime_type, is_gif, is_webp).await;
373
374    // 6. 按上传时间组织目录:uploads/YYYY/MM/DD。
375    //    chrono 的 DelayedFormat 实现 Display,可直接进 format!,省掉中间 String。
376    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    // 7. 登记 assets 注册表。失败时补偿删除已落盘文件,避免产生未登记的孤儿文件。
397    //    ON CONFLICT (content_hash) DO NOTHING 兜底并发竞态:两个请求同时上传同一
398    //    新内容时会双双错过上面的去重检查,唯一索引保证只有一个 INSERT 成功;
399    //    落败者删自己的落盘文件、复用胜出者的路径(返回 Some(reused_path))。
400    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        // id 用 Uuid 类型直连 uuid 列(with-uuid-1 桥接),避免 String→uuid 序列化失败。
405        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            // 竞态落败:胜出者的行必然已提交(唯一索引冲突即可见),取其路径复用。
426            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            // 登记失败:补偿删除已落盘文件。
460            let _ = tokio::fs::remove_file(&file_path).await;
461            Err(e)
462        }
463    }
464}
465
466// ===========================================================================
467// 图片处理辅助
468// ===========================================================================
469
470/// 从 magic bytes 检测 MIME 类型(不信任客户端声明的扩展名/Content-Type)。
471#[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/// 解码验证 GIF/WebP 原始字节,确保不是伪造扩展名的恶意文件。
508#[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/// 转码核心(同步):GIF/WebP 保持原格式,JPEG/PNG 尝试转 WebP(更小才采用)。
518#[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    // JPEG/PNG → 尝试 WebP。
533    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                    // WebP 更大,保留原格式。
558                    (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        // 到这里尺寸校验已通过(超限在 header 阶段被拒),decode 失败只能是真损坏。
567        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/// 在阻塞线程中执行转码,避免阻塞 async 运行时。
575/// Bytes clone 廉价(引用计数 +1);join 失败(panic)时回退原格式。
576#[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}