Skip to main content

yggdrasil/api/
upload.rs

1//! 图片上传:web 处理器 + 共享入库流水线。
2//!
3//! 三条入口共用 `process_image_upload`:
4//! - web `POST /api/upload`(cookie 鉴权 admin,multipart)—— 见 `upload_image`;
5//! - web `POST /api/comments/upload`(评论区,允许匿名,IP 双层限流)—— 见 `comment_upload_image`;
6//! - MCP `POST /api/mcp/upload`(bearer 鉴权,multipart)—— 见 `mcp_upload_image`;
7//! - MCP `upload_media` 工具(URL 抓取)—— 见 `src/mcp/tools/media.rs`。
8//!
9//! 流水线:magic bytes 检 MIME → 大小校验 → 尺寸/像素校验 → SHA-256 内容去重
10//! (命中即复用)→ GIF/WebP 解码校验 → `spawn_blocking` 转码(GIF/WebP 原样,
11//! JPEG/PNG 仅在更小时转 WebP)→ 按日期落盘 → assets 登记(含并发竞态补偿)。
12//! JPEG/PNG 自动转 WebP(若体积更小则保留原格式),GIF/WebP 保持原样。
13//! 文件按日期分目录存放于 `uploads/`。
14//!
15//! 内容去重(CAS):以原始上传字节的 SHA-256 为内容指纹(`assets.content_hash`,
16//! 唯一索引)。重复上传同一内容时复用已登记素材——同一行、同一文件,不重复
17//! 落盘,响应带 `"reused": true`;并发同内容上传由唯一索引 + ON CONFLICT 兜底。
18//! 仅精确去重:尺寸/压缩不同的视觉相似图不合并(那是感知哈希 pHash 的领域,
19//! 有意不做)。
20//! 本模块属于手动注册的 Axum 路由,仅在 `feature = "server"` 时可用。
21
22#[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// ===========================================================================
44// web 处理器(cookie 鉴权)
45// ===========================================================================
46
47/// 构造统一的 JSON 错误响应:`{ "success": false, "error": msg }`。
48/// pub(crate):备份导入(api/database/backup.rs)复用同一契约。
49#[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/// 处理图片上传的 Axum handler(web 端,cookie 鉴权)。
58///
59/// 流程:限流 → 解析 session → 校验 admin → 读取 multipart → 早拒非法声明类型 →
60/// 读取字节 → 交给共享流水线 `process_image_upload`。
61///
62/// `ConnectInfo` 以可选扩展注入:`dioxus::server::serve()` 接管了 listener,
63/// 无法调用 `into_make_service_with_connect_info::<SocketAddr>()`,所以这里
64/// 与 `serve_image` 保持一致的优雅降级——扩展缺失时退回 `"unknown"` 限流桶。
65/// 生产环境应在反向代理后部署并配置 `TRUSTED_PROXY_COUNT`,让限流拿到真实 IP。
66#[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    // 0. Rate limit check
73    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    // 1. Extract session from cookie
80    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    // 2. Verify admin
93    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    // 3. Read multipart field
105    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    // 4. 早拒非法声明类型(快速路径,避免读字节后再判)。
117    //    流水线仍以 magic bytes 为权威——声明 jpeg 但实为 png 会被识别为 png 接受。
118    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    // 原始文件名(客户端提供,仅作 assets 表展示字段);需在 bytes() 消耗 field 前取出。
124    let original_filename = field.file_name().map(|s| s.to_string());
125
126    // 5. Read file data
127    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    // 6. 共享入库流水线。
139    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// ===========================================================================
153// 评论图片处理器(允许匿名,IP 双层限流)
154// ===========================================================================
155
156/// 评论图片上传的 Axum handler(评论区用,**允许匿名**)。
157///
158/// 与 admin `upload_image` 的区别:
159/// - 不做会话/角色校验——匿名访客也能为评论传图;
160/// - 限流用独立的 comment_upload 双层桶(每秒突发 + 每日总额),与 admin 的
161///   upload 桶隔离,匿名滥用不会挤占 admin 额度;
162/// - 入库流水线与响应契约完全一致(magic bytes 检 MIME、5MiB、尺寸/像素校验、
163///   SHA-256 去重、转码、assets 登记)。
164///
165/// 未引用素材由 `tasks::orphan_asset_purge` 定期回收(评论未提交、评论被删等
166/// 场景产生的孤儿图)。
167#[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    // 0. 双层限流(先于一切 body 读取)。
174    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    // 1. Read multipart field
181    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    // 2. 早拒非法声明类型(快速路径;流水线仍以 magic bytes 为权威)。
193    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    // 3. Read file data
201    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    // 4. 共享入库流水线。
213    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// ===========================================================================
227// MCP 处理器(bearer 鉴权,multipart 二进制,带外传输)
228// ===========================================================================
229
230/// MCP bearer 上传错误 → JSON 响应(与 web 端格式一致)。
231#[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/// 处理图片上传的 Axum handler(MCP 端,bearer token 鉴权)。
237///
238/// 与 web `upload_image` 的区别:
239/// - 鉴权用 `Authorization: Bearer ygg_...`(不是 cookie),经
240///   [`crate::mcp::auth::resolve_bearer_principal`] 解析;
241/// - 不挂 CSRF 中间件——bearer 在请求头里,浏览器不会自动附带,无 CSRF 风险;
242/// - 限流按 token_id 计数(复用 MCP 的 token-keyed governor)。
243///
244/// 供 AI 客户端的 host/shell 直接 POST 二进制(Claude Code 的 Bash+curl 等),
245/// 二进制不经 JSON-RPC,绕开 rmcp 4MiB 请求体上限。返回可直接嵌入 Markdown 的
246/// `/uploads/...` URL。
247#[cfg(feature = "server")]
248pub async fn mcp_upload_image(headers: HeaderMap, mut multipart: Multipart) -> Response {
249    // 1. bearer → principal(含 scope 校验:media 需要 write)。
250    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    // 2. token-keyed 限流(与 /mcp 中间件的 MCP_LIMITER 隔离:上传单独配额)。
262    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    // 3. 读取 multipart 字段。
267    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    // 早拒非法声明类型(快速路径)。
277    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    // 4. 共享入库流水线。
292    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// ===========================================================================
310// 共享入库流水线
311// ===========================================================================
312
313/// 单条图片入库的结果。
314#[cfg(feature = "server")]
315#[derive(Debug, serde::Serialize)]
316pub(crate) struct UploadOutcome {
317    /// 可直接嵌入 Markdown 的相对 URL:`/uploads/YYYY/MM/DD/HHMMSS.uuid.ext`。
318    pub url: String,
319    /// 是否命中已登记素材(内容去重或并发竞态复用)。
320    pub reused: bool,
321    pub width: u32,
322    pub height: u32,
323    /// 最终 MIME(转码后;JPEG→WebP 成功则为 image/webp)。
324    pub mime: String,
325}
326
327/// 流水线错误:映射到 HTTP 状态 + 脱敏消息(不泄露 SQL/路径细节)。
328#[cfg(feature = "server")]
329#[derive(Debug)]
330pub(crate) enum UploadError {
331    Empty,
332    BadType,   // magic bytes 无法识别为 JPEG/PNG/GIF/WebP
333    TooLarge,  // 超过 MAX_FILE_SIZE
334    Oversized, // 像素超过 MAX_IMAGE_PIXELS
335    Corrupt,   // GIF/WebP 解码失败
336    /// 内部错误:携带静态上下文标签供 Debug 诊断(status_and_msg 统一返回脱敏消息)。
337    #[allow(dead_code)]
338    Internal(&'static str),
339}
340
341#[cfg(feature = "server")]
342impl UploadError {
343    /// 包装底层错误:服务端日志记完整 `{e}`,客户端只见静态 `ctx`。
344    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    /// 映射到 (HTTP 状态, 脱敏消息)。
350    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/// 单一图片入库流水线(web 上传 / MCP bearer 端点 / MCP URL 抓取共用)。
363///
364/// 输入:原始字节 + 可选展示文件名。**不信任客户端声明的 MIME**——以 magic
365/// bytes 为唯一真相。输出可直接嵌入 Markdown 的 `/uploads/...` URL。
366///
367/// 步骤:大小校验 → magic bytes 检 MIME → 尺寸/像素校验 → SHA-256 去重(命中
368/// 即复用,跳过最贵的转码)→ GIF/WebP 解码校验 → `spawn_blocking` 转码 →
369/// 按日期落盘 → assets 登记(含并发竞态补偿,落败者删自己的文件复用胜出者)。
370#[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    // 1. magic bytes 检 MIME(不信任声明类型/扩展名)。
383    let mime_type = detect_mime(&data).ok_or(UploadError::BadType)?;
384
385    // 2. 仅读 header 校验尺寸/像素上限,并拿回 (w,h) 供 assets 登记,避免二次解析。
386    //    超限直接拒绝,避免大图 decode 后被静默降级(原 fallback 存原图)。
387    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    // 3. 内容去重(CAS):对原始上传字节算 SHA-256,命中已登记素材直接复用,
397    //    跳过 GIF/WebP 解码验证、转码与落盘(省下整个流程最贵的 CPU)。
398    //    放在安全性校验之后、转码之前。命中时刷新 created_at/updated_at:
399    //    重传代表使用意图,重启 7 天清理保护窗(PURGE_GRACE_DAYS 保护的是
400    //    「刚上传还没被文章引用」的素材)。
401    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    // 4. GIF/WebP 解码校验(不经过重编码的格式必须验真,防伪造扩展名的恶意文件)。
435    //    GIF 走 image::load_from_memory 会完整解码,移到阻塞线程池避免拖住 async 运行时。
436    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    // 5. 转码:GIF/WebP 原样;JPEG/PNG 仅在 WebP 更小时转。
450    //    Bytes clone 廉价(引用计数 +1),move 进阻塞闭包无需全文件深拷贝。
451    let (final_data, final_ext) = transcode(data, mime_type, is_gif, is_webp).await;
452
453    // 6. 按上传时间组织目录:uploads/YYYY/MM/DD。
454    //    chrono 的 DelayedFormat 实现 Display,可直接进 format!,省掉中间 String。
455    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    // 7. 登记 assets 注册表。失败时补偿删除已落盘文件,避免产生未登记的孤儿文件。
476    //    ON CONFLICT (content_hash) DO NOTHING 兜底并发竞态:两个请求同时上传同一
477    //    新内容时会双双错过上面的去重检查,唯一索引保证只有一个 INSERT 成功;
478    //    落败者删自己的落盘文件、复用胜出者的路径(返回 Some(reused_path))。
479    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        // id 用 Uuid 类型直连 uuid 列(with-uuid-1 桥接),避免 String→uuid 序列化失败。
484        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            // 竞态落败:胜出者的行必然已提交(唯一索引冲突即可见),取其路径复用。
505            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            // 登记失败:补偿删除已落盘文件。
539            let _ = tokio::fs::remove_file(&file_path).await;
540            Err(e)
541        }
542    }
543}
544
545// ===========================================================================
546// 图片处理辅助
547// ===========================================================================
548
549/// 从 magic bytes 检测 MIME 类型(不信任客户端声明的扩展名/Content-Type)。
550#[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/// 解码验证 GIF/WebP 原始字节,确保不是伪造扩展名的恶意文件。
587#[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/// 转码核心(同步):GIF/WebP 保持原格式,JPEG/PNG 尝试转 WebP(更小才采用)。
597#[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    // JPEG/PNG → 尝试 WebP。
612    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                    // WebP 更大,保留原格式。
637                    (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        // 到这里尺寸校验已通过(超限在 header 阶段被拒),decode 失败只能是真损坏。
646        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/// 在阻塞线程中执行转码,避免阻塞 async 运行时。
654/// Bytes clone 廉价(引用计数 +1);join 失败(panic)时回退原格式。
655#[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}