Initial commit: MyBlog full stack blog
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
文件上传路由。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/upload/avatar 头像上传(登录用户;jpg/png/webp,最大 2MB;成功后自动保存到头像字段)
|
||||
- POST /api/upload/article 文章图片上传(仅博主;jpg/png/webp,最大 6MB)
|
||||
- POST /api/upload/project 项目文件上传(仅博主;zip,最大 50MB)
|
||||
- POST /api/upload/doc 文章文档上传(仅博主;doc/docx,默认最大 20MB;仅用于生活/学习分区)
|
||||
|
||||
安全规则:
|
||||
- 随机文件名(uuid),不使用用户原始文件名,杜绝路径穿越
|
||||
- 校验扩展名 + 文件魔数(内容真实性),防止伪装类型
|
||||
- 文件仅作为静态资源由 Nginx 通过 /uploads/ 访问,禁止执行
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import PROJECT_ROOT, get_db
|
||||
from ..models import (
|
||||
ARTICLE_CATEGORY_LIFE,
|
||||
ARTICLE_CATEGORY_STUDY,
|
||||
ROLE_BLOGGER,
|
||||
ROLE_FRIEND,
|
||||
User,
|
||||
utcnow,
|
||||
)
|
||||
from ..schemas import UnifiedResponse
|
||||
|
||||
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
||||
|
||||
# 上传根目录(可通过 .env 的 UPLOADS_DIR 覆盖;部署时 Nginx 将 /uploads/ 静态映射到该目录)
|
||||
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
|
||||
|
||||
# 允许的扩展名(值仅用于展示)
|
||||
ALLOWED_IMAGES = {"jpg", "jpeg", "png", "webp"}
|
||||
ALLOWED_VIDEO = {"mp4", "webm"}
|
||||
ALLOWED_PROJECT = {"zip"}
|
||||
ALLOWED_DOC = {"doc", "docx"}
|
||||
|
||||
# 大小上限(字节):头像上限可通过 .env 的 AVATAR_MAX_SIZE_MB 调整(默认 4MB)
|
||||
MAX_AVATAR_SIZE = int(os.getenv("AVATAR_MAX_SIZE_MB", "4")) * 1024 * 1024
|
||||
# 文章图片(封面/正文插图)大小上限:可通过 .env 的 ARTICLE_IMAGE_MAX_SIZE_MB 调整(默认 6MB)
|
||||
MAX_IMAGE_SIZE = int(os.getenv("ARTICLE_IMAGE_MAX_SIZE_MB", "6")) * 1024 * 1024
|
||||
MAX_VIDEO_SIZE = 100 * 1024 * 1024 # 文章视频 100MB
|
||||
MAX_PROJECT_SIZE = 50 * 1024 * 1024 # 项目文件 50MB
|
||||
# 文章文档(doc/docx)大小上限:可通过 .env 的 DOC_MAX_SIZE_MB 调整(默认 20MB)
|
||||
MAX_DOC_SIZE = int(os.getenv("DOC_MAX_SIZE_MB", "20")) * 1024 * 1024
|
||||
|
||||
|
||||
def check_magic_bytes(content: bytes, ext: str) -> bool:
|
||||
"""校验文件魔数,防止伪造扩展名。"""
|
||||
if ext in ("jpg", "jpeg"):
|
||||
return content[:3] == b"\xff\xd8\xff"
|
||||
if ext == "png":
|
||||
return content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
if ext == "webp":
|
||||
return content[:4] == b"RIFF" and content[8:12] == b"WEBP"
|
||||
if ext == "mp4":
|
||||
return content[4:8] == b"ftyp"
|
||||
if ext == "webm":
|
||||
return content[:4] == b"\x1aE\xdf\xa3"
|
||||
if ext == "zip":
|
||||
return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
|
||||
if ext == "doc":
|
||||
# Word 97-2003:OLE 复合文档魔数
|
||||
return content[:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
||||
if ext == "docx":
|
||||
# Word 2007+:本质是 zip 压缩包
|
||||
return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
|
||||
return False
|
||||
|
||||
|
||||
def save_upload(file: UploadFile, subdir: str, allowed: set, max_size: int) -> dict:
|
||||
"""校验并保存上传文件,返回 URL 等元信息。"""
|
||||
original_name = file.filename or ""
|
||||
ext = original_name.rsplit(".", 1)[-1].lower() if "." in original_name else ""
|
||||
if ext not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的文件类型(允许:{' / '.join(sorted(allowed))})",
|
||||
)
|
||||
|
||||
# 一次读入,超出上限直接拒绝(+1 用于判断是否超限)
|
||||
content = file.file.read(max_size + 1)
|
||||
if len(content) > max_size:
|
||||
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="文件大小超出限制")
|
||||
|
||||
if not check_magic_bytes(content, ext):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件内容与扩展名不符")
|
||||
|
||||
# 随机文件名,避免覆盖与路径穿越
|
||||
saved_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
target_dir = UPLOADS_ROOT / subdir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / saved_name).write_bytes(content)
|
||||
|
||||
return {
|
||||
"url": f"/uploads/{subdir}/{saved_name}",
|
||||
"filename": original_name,
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/avatar", response_model=UnifiedResponse)
|
||||
def upload_avatar(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""头像上传:仅好友或博主可上传(访客除注册外禁止一切上传);成功后自动更新头像字段。
|
||||
更换频率限制:同一账号两次更换之间至少间隔 AVATAR_CHANGE_INTERVAL_HOURS 小时(默认 24)。
|
||||
"""
|
||||
if current_user.role not in (ROLE_FRIEND, ROLE_BLOGGER):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="访客不能上传头像")
|
||||
|
||||
# 更换冷却:距离上次更换不足 N 小时则拒绝,并提示剩余等待时间
|
||||
interval_hours = int(os.getenv("AVATAR_CHANGE_INTERVAL_HOURS", "24"))
|
||||
if current_user.avatar_updated_time is not None:
|
||||
elapsed = utcnow() - current_user.avatar_updated_time
|
||||
if elapsed < timedelta(hours=interval_hours):
|
||||
remaining = timedelta(hours=interval_hours) - elapsed
|
||||
hours = int(remaining.total_seconds() // 3600)
|
||||
minutes = int((remaining.total_seconds() % 3600) // 60)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"头像更换过于频繁,请约 {hours} 小时 {minutes} 分钟后再试",
|
||||
)
|
||||
|
||||
data = save_upload(file, "avatar", ALLOWED_IMAGES, MAX_AVATAR_SIZE)
|
||||
current_user.avatar = data["url"]
|
||||
current_user.avatar_updated_time = utcnow()
|
||||
db.commit()
|
||||
return UnifiedResponse(success=True, data=data, message="头像上传成功")
|
||||
|
||||
|
||||
@router.post("/article", response_model=UnifiedResponse)
|
||||
def upload_article_image(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""文章图片上传:仅博主可上传(用于封面与正文插图)。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章图片")
|
||||
data = save_upload(file, "article", ALLOWED_IMAGES, MAX_IMAGE_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="图片上传成功")
|
||||
|
||||
|
||||
@router.post("/video", response_model=UnifiedResponse)
|
||||
def upload_article_video(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""文章视频上传:仅博主可上传(mp4 / webm,最大 100MB),供正文插入视频。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章视频")
|
||||
data = save_upload(file, "article", ALLOWED_VIDEO, MAX_VIDEO_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="视频上传成功")
|
||||
|
||||
|
||||
@router.post("/project", response_model=UnifiedResponse)
|
||||
def upload_project_file(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""项目文件上传:仅博主可上传,支持 zip 压缩包(供下载项目使用)。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传项目文件")
|
||||
data = save_upload(file, "project", ALLOWED_PROJECT, MAX_PROJECT_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="项目文件上传成功")
|
||||
|
||||
@router.post("/doc", response_model=UnifiedResponse)
|
||||
def upload_article_doc(
|
||||
file: UploadFile,
|
||||
category: str = Form(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""文章文档上传(doc / docx):仅博主可上传,且只能用于“我的生活 / 我的学习”分区的文章。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文档")
|
||||
if category not in (ARTICLE_CATEGORY_LIFE, ARTICLE_CATEGORY_STUDY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文档只能用于生活或学习分区的文章",
|
||||
)
|
||||
data = save_upload(file, "article", ALLOWED_DOC, MAX_DOC_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="文档上传成功")
|
||||
Reference in New Issue
Block a user