- 21 个模块目录(auth/system/dictionary/configuration/cms/member/region/link/notice/job/log/upload/index) - 每个模块 router.py(合并模块导出多个 router)/schemas.py/service.py/repository.py/models.py - routers/v1.py 与 routers/__init__.py 改为直接引用 modules,前缀与权限串原样保留 - core/middleware.py 懒加载指向 modules.system.menu / modules.log;core/redis_client.py 指向 modules.job.scheduler - apps/views|services|forms 全部改为 re-export 垫片 - 迁移后 grep 守门通过:modules/core 无 apps/extensions 残留引用 - 实测登录、27+ 模块接口、写操作 commit、操作日志落库均正常 Co-Authored-By: Claude <noreply@anthropic.com>
253 lines
8.2 KiB
Python
253 lines
8.2 KiB
Python
import asyncio
|
|
import io
|
|
import logging
|
|
import mimetypes
|
|
import os
|
|
import random
|
|
import re
|
|
import time
|
|
|
|
from config import FASTAPI_TEMP_PATH, FASTAPI_IMAGE_URL, FASTAPI_UPLOAD_DIR
|
|
from config.app import UPLOAD_ALLOWED_EXTS, UPLOAD_MAX_SIZE_MB
|
|
from core import R
|
|
from utils.file import mkdir, verify_file_magic
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _parse_allowed_extensions(exts):
|
|
"""解析允许的扩展名参数,统一转为带点的列表格式
|
|
|
|
支持列表或字符串输入,字符串支持逗号/竖线分隔
|
|
"""
|
|
if not exts:
|
|
return []
|
|
|
|
if isinstance(exts, list):
|
|
return [ext if ext.startswith('.') else f'.{ext}' for ext in exts]
|
|
|
|
if isinstance(exts, str):
|
|
if '|' in exts:
|
|
parts = exts.split('|')
|
|
elif ',' in exts:
|
|
parts = exts.split(',')
|
|
else:
|
|
parts = [exts]
|
|
|
|
result = []
|
|
for part in parts:
|
|
part = part.strip().lower()
|
|
if part:
|
|
if not part.startswith('.'):
|
|
part = f'.{part}'
|
|
result.append(part)
|
|
return result
|
|
|
|
return []
|
|
|
|
|
|
def _is_safe_filename(filename: str) -> bool:
|
|
"""校验文件名安全性,防止路径遍历攻击
|
|
|
|
拒绝包含路径分隔符或 .. 的文件名,确保不会跳出目标目录。
|
|
"""
|
|
if not filename:
|
|
return False
|
|
# 规范化后检测路径遍历
|
|
normalized = os.path.normpath(filename)
|
|
if '..' in normalized.split(os.sep):
|
|
return False
|
|
# 文件名不应包含路径分隔符
|
|
basename = os.path.basename(normalized)
|
|
return basename == normalized or basename == filename
|
|
|
|
|
|
def _guess_mime_type(ext_with_dot):
|
|
"""根据扩展名推断MIME类型"""
|
|
mime_map = {
|
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
|
'.gif': 'image/gif', '.bmp': 'image/bmp', '.webp': 'image/webp',
|
|
'.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
'.txt': 'text/plain', '.pdf': 'application/pdf',
|
|
'.doc': 'application/msword',
|
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'.xls': 'application/vnd.ms-excel',
|
|
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'.ppt': 'application/vnd.ms-powerpoint',
|
|
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'.mp3': 'audio/mpeg', '.mp4': 'video/mp4',
|
|
'.zip': 'application/zip', '.rar': 'application/x-rar-compressed',
|
|
'.7z': 'application/x-7z-compressed',
|
|
}
|
|
return mime_map.get(ext_with_dot, 'application/octet-stream')
|
|
|
|
|
|
def _get_mime_type(filename, ext_with_dot):
|
|
"""获取文件的MIME类型"""
|
|
mime_type, _ = mimetypes.guess_type(filename)
|
|
if not mime_type:
|
|
mime_type = _guess_mime_type(ext_with_dot)
|
|
return mime_type
|
|
|
|
|
|
def _generate_filename(ext_with_dot):
|
|
"""生成唯一文件名:年月日时分秒 + 5位随机数 + 扩展名"""
|
|
timestamp = time.strftime('%Y%m%d%H%M%S')
|
|
random_num = '%05d' % random.randint(0, 99999)
|
|
return f"{timestamp}{random_num}{ext_with_dot}"
|
|
|
|
|
|
# SVG中需要移除的危险标签和属性
|
|
_SVG_DANGEROUS_TAGS = re.compile(
|
|
r'<\s*/?(script|iframe|object|embed|applet|form|input|button|textarea|select|link|meta|base)'
|
|
r'\b[^>]*>',
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
|
|
_SVG_DANGEROUS_ATTRS = re.compile(
|
|
r'\s+'
|
|
r'(on\w+' # on* 事件属性:onclick, onload, onerror 等
|
|
r'|href\s*=\s*["\']?\s*javascript' # javascript: 链接
|
|
r'|xlink:href\s*=\s*["\']?\s*javascript)'
|
|
r'[^>]*>',
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def _sanitize_svg(content: bytes) -> bytes:
|
|
"""清洗SVG内容,移除可能导致XSS攻击的危险标签和属性"""
|
|
try:
|
|
text = content.decode('utf-8')
|
|
except UnicodeDecodeError:
|
|
return content
|
|
|
|
# 移除危险标签
|
|
text = _SVG_DANGEROUS_TAGS.sub('', text)
|
|
# 移除危险属性
|
|
text = _SVG_DANGEROUS_ATTRS.sub('>', text)
|
|
return text.encode('utf-8')
|
|
|
|
|
|
def _save_upload(content, original_name, file_ext_with_dot):
|
|
"""同步校验文件内容并落盘,返回 R 结果(在线程池中执行)"""
|
|
file_size = len(content)
|
|
|
|
# 校验文件大小
|
|
max_size_bytes = UPLOAD_MAX_SIZE_MB * 1024 * 1024
|
|
if file_size > max_size_bytes:
|
|
return R.failed(msg=f"文件大小超过限制,最大允许 {UPLOAD_MAX_SIZE_MB}MB")
|
|
|
|
# 校验魔术字节
|
|
is_valid, magic_msg = verify_file_magic(io.BytesIO(content), file_ext_with_dot)
|
|
if not is_valid:
|
|
return R.failed(msg=magic_msg)
|
|
|
|
# 生成文件名和路径
|
|
generated_name = _generate_filename(file_ext_with_dot)
|
|
date_dir = time.strftime('%Y%m%d')
|
|
save_dir = os.path.join(FASTAPI_TEMP_PATH, date_dir)
|
|
mkdir(save_dir)
|
|
file_path = os.path.join(save_dir, generated_name)
|
|
|
|
# 保存文件
|
|
if file_ext_with_dot == '.svg':
|
|
content = _sanitize_svg(content)
|
|
with open(file_path, 'wb') as f:
|
|
f.write(content)
|
|
|
|
# 生成访问信息
|
|
relative_path = file_path.replace(FASTAPI_UPLOAD_DIR, "").replace("\\", "/")
|
|
file_url = FASTAPI_IMAGE_URL + relative_path
|
|
file_ext = file_ext_with_dot[1:] if file_ext_with_dot.startswith('.') else file_ext_with_dot
|
|
mime_type = _get_mime_type(original_name, file_ext_with_dot)
|
|
|
|
result = {
|
|
'originalName': original_name,
|
|
'fileExtension': file_ext,
|
|
'fileType': mime_type,
|
|
'fileSize': file_size,
|
|
'fileName': generated_name,
|
|
'filePath': relative_path,
|
|
'fileUrl': file_url,
|
|
}
|
|
|
|
return R.ok(msg="上传成功", data=result)
|
|
|
|
|
|
async def upload_image(file):
|
|
"""上传图片
|
|
|
|
Args:
|
|
file: FastAPI UploadFile 对象
|
|
|
|
Returns:
|
|
R: 上传结果,包含文件信息
|
|
"""
|
|
try:
|
|
if not file or not file.filename:
|
|
return R.failed(msg="请选择要上传的文件")
|
|
|
|
# 获取文件基本信息
|
|
original_name = file.filename
|
|
|
|
# 校验文件名安全性,防止路径遍历
|
|
if not _is_safe_filename(original_name):
|
|
return R.failed(msg="文件名不合法")
|
|
|
|
file_ext_with_dot = os.path.splitext(original_name)[1].lower()
|
|
|
|
# 验证图片扩展名
|
|
allowed_image_exts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico']
|
|
if file_ext_with_dot not in allowed_image_exts:
|
|
return R.failed(msg="文件格式不支持,仅支持图片格式")
|
|
|
|
# 读取文件内容
|
|
content = await file.read()
|
|
|
|
# 同步校验与落盘放入线程池,避免阻塞事件循环
|
|
return await asyncio.to_thread(_save_upload, content, original_name, file_ext_with_dot)
|
|
|
|
except Exception as e:
|
|
logger.error(f"图片上传异常: {str(e)}")
|
|
return R.failed(msg=f"上传失败: {str(e)}")
|
|
|
|
|
|
async def upload_file(file, file_exts=None):
|
|
"""上传文件(兼容图片和各类文件)
|
|
|
|
Args:
|
|
file: FastAPI UploadFile 对象
|
|
file_exts: 允许的文件扩展名,默认使用 UPLOAD_ALLOWED_EXTS 全局配置
|
|
|
|
Returns:
|
|
R: 上传结果,包含文件信息
|
|
"""
|
|
try:
|
|
if not file or not file.filename:
|
|
return R.failed(msg="请选择要上传的文件")
|
|
|
|
# 获取文件基本信息
|
|
original_name = file.filename
|
|
|
|
# 校验文件名安全性,防止路径遍历
|
|
if not _is_safe_filename(original_name):
|
|
return R.failed(msg="文件名不合法")
|
|
|
|
file_ext_with_dot = os.path.splitext(original_name)[1].lower()
|
|
file_ext = file_ext_with_dot[1:] if file_ext_with_dot.startswith('.') else ''
|
|
|
|
# 验证文件扩展名
|
|
allowed_exts = _parse_allowed_extensions(file_exts or UPLOAD_ALLOWED_EXTS)
|
|
if allowed_exts and file_ext_with_dot not in allowed_exts:
|
|
return R.failed(msg=f"文件格式不支持,仅支持 {', '.join(allowed_exts)} 格式")
|
|
|
|
# 读取文件内容
|
|
content = await file.read()
|
|
|
|
# 同步校验与落盘放入线程池,避免阻塞事件循环
|
|
return await asyncio.to_thread(_save_upload, content, original_name, file_ext_with_dot)
|
|
|
|
except Exception as e:
|
|
logger.error(f"文件上传异常: {str(e)}")
|
|
return R.failed(msg=f"上传失败: {str(e)}")
|