Files
base/crates/erp-server/src/handlers/upload.rs
iven 3772afd987 chore: 干净 ERP 基座 — 删除 health/ai/wechat 业务代码
删除内容:
- 前端: health/(67文件), ai/(2文件), Copilot, MediaPicker, 相关API/Store/Hook
- 后端: wechat_handler, wechat_service, wechat_user entity, analytics handler, ai_workflow_seed
- 配置: WechatConfig, AppConfig.wechat, AuthState wechat 字段
- 启动: 微信凭据检查块, ensure_ai_workflows() 调用
- 迁移: 新增 m20260613_000170_drop_wechat_users.rs
- 脚本: api_test_health_alert.py, api_test_mp.py, mpsync.sh/ps1
- E2E: health-data page, flows/ 目录

保留: erp-core/auth/workflow/message/config/plugin + 基座前端 + 通用组件
2026-06-13 00:32:50 +08:00

221 lines
6.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use axum::Extension;
use axum::extract::{FromRef, Multipart, State};
use axum::response::Json;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, TenantContext};
use serde::Serialize;
use uuid::Uuid;
use crate::state::AppState;
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UploadResp {
pub url: String,
pub filename: String,
pub size: u64,
pub content_type: String,
}
/// 上传单个文件。
///
/// 接受 multipart/form-data将文件保存到本地目录
/// 返回可通过 `/uploads/` 前缀访问的 URL。
#[utoipa::path(
post,
path = "/upload",
request_body(content_type = "multipart/form-data"),
responses(
(status = 200, description = "上传成功", body = ApiResponse<UploadResp>),
(status = 413, description = "文件过大"),
(status = 400, description = "无文件或不支持的类型"),
),
tag = "文件上传",
)]
pub async fn upload_file<S>(
State(state): State<AppState>,
Extension(ctx): Extension<TenantContext>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<UploadResp>>, AppError>
where
AppState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let max_size = state.config.storage.max_file_size_bytes();
let upload_dir = &state.config.storage.upload_dir;
// 确保上传目录存在
let base_dir = std::path::Path::new(upload_dir);
let tenant_dir = base_dir.join(ctx.tenant_id.to_string());
tokio::fs::create_dir_all(&tenant_dir)
.await
.map_err(|e| AppError::Internal(format!("创建上传目录失败: {}", e)))?;
// 读取第一个 field 作为上传文件
let field = multipart
.next_field()
.await
.map_err(|e| AppError::Validation(format!("读取上传数据失败: {}", e)))?
.ok_or_else(|| AppError::Validation("未找到上传文件".to_string()))?;
let content_type = field
.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// 验证文件类型
validate_content_type(&content_type)?;
let original_name = field.name().unwrap_or("file").to_string();
let data = field
.bytes()
.await
.map_err(|e| AppError::Validation(format!("读取文件数据失败: {}", e)))?;
if data.len() as u64 > max_size {
return Err(AppError::Validation(format!(
"文件大小超过限制(最大 {}",
format_size(max_size)
)));
}
// 校验 magic bytes验证文件实际内容与声明的 Content-Type 一致
validate_magic_bytes(&content_type, &data)?;
// 生成唯一文件名,保留原始扩展名
let ext = std::path::Path::new(&original_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin");
let file_id = Uuid::now_v7();
let filename = format!("{}.{}", file_id, ext);
let file_path = tenant_dir.join(&filename);
let data_vec: Vec<u8> = data.to_vec();
tokio::fs::write(&file_path, &data_vec)
.await
.map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?;
let url = format!("/uploads/{}/{}", ctx.tenant_id, filename);
tracing::info!(
tenant_id = %ctx.tenant_id,
filename = %filename,
size = data_vec.len(),
content_type = %content_type,
"文件上传成功"
);
Ok(Json(ApiResponse::ok(UploadResp {
url,
filename: original_name,
size: data_vec.len() as u64,
content_type,
})))
}
/// 允许的文件类型
fn validate_content_type(content_type: &str) -> Result<(), AppError> {
const ALLOWED: &[&str] = &[
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
];
if !ALLOWED.contains(&content_type) {
return Err(AppError::Validation(format!(
"不支持的文件类型: {}",
content_type
)));
}
Ok(())
}
/// 校验文件 magic bytes文件签名与声明的 Content-Type 是否一致。
///
/// 防止攻击者通过修改 Content-Type 头上传恶意文件。
/// 对于 Office 格式等复杂签名,跳过 magic bytes 校验(仅依赖白名单)。
fn validate_magic_bytes(content_type: &str, data: &[u8]) -> Result<(), AppError> {
// 需要至少几个字节才能校验
if data.is_empty() {
return Err(AppError::Validation("文件内容为空".to_string()));
}
let signature: &[u8] = match content_type {
"image/jpeg" => {
// JPEG: FF D8 FF
b"\xFF\xD8\xFF"
}
"image/png" => {
// PNG: 89 50 4E 47 0D 0A 1A 0A
b"\x89PNG\r\n\x1A\n"
}
"image/gif" => {
// GIF: 47 49 46 38 (GIF8)
b"GIF8"
}
"image/webp" => {
// WebP: RIFF....WEBP (12 bytes)
// 前 4 字节: 52 49 46 46 (RIFF)
// 字节 8-11: 57 45 42 50 (WEBP)
if data.len() < 12 {
return Err(AppError::Validation(
"文件数据不足,无法验证 WebP 格式".to_string(),
));
}
let riff_ok = &data[0..4] == b"RIFF";
let webp_ok = &data[8..12] == b"WEBP";
if riff_ok && webp_ok {
return Ok(());
}
return Err(AppError::Validation(
"文件内容与声明的类型 (image/webp) 不匹配".to_string(),
));
}
"application/pdf" => {
// PDF: 25 50 44 46 (%PDF)
b"%PDF"
}
// Office 格式的 magic bytes 较复杂OLE2 / ZIP-based OOXML
// 仅依赖白名单,跳过 magic bytes 校验
"application/msword"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.ms-excel"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
return Ok(());
}
_ => return Ok(()),
};
if data.len() < signature.len() {
return Err(AppError::Validation(
"文件数据不足,无法验证文件格式".to_string(),
));
}
if &data[..signature.len()] != signature {
return Err(AppError::Validation(format!(
"文件内容与声明的类型 ({}) 不匹配",
content_type
)));
}
Ok(())
}
fn format_size(bytes: u64) -> String {
if bytes >= 1024 * 1024 * 1024 {
format!("{}GB", bytes / (1024 * 1024 * 1024))
} else if bytes >= 1024 * 1024 {
format!("{}MB", bytes / (1024 * 1024))
} else {
format!("{}KB", bytes / 1024)
}
}