diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index e0ee6fd4d..e542acccf 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -2,6 +2,7 @@ from typing import Any from uuid import uuid4 from app.auth import AuthenticatedUser, get_current_user +from app.api.routes.permissions import require_workspace_member from app.config import get_settings from app.core.celery_app import celery_app from app.core.storage import OSSStorageService, get_storage_service @@ -25,15 +26,38 @@ from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() +# 允许上传的文件 MIME 类型 +ALLOWED_MIME_TYPES = frozenset({ + # 视频 + "video/mp4", "video/mpeg", "video/quicktime", "video/x-msvideo", + "video/webm", "video/x-matroska", "video/3gpp", + # 音频 + "audio/mpeg", "audio/wav", "audio/ogg", "audio/flac", "audio/aac", + "audio/mp3", "audio/x-m4a", "audio/webm", + # 图片 + "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", + "image/svg+xml", "image/tiff", +}) -def _require_workspace_member( - workspace_id: str, - authenticated_user: AuthenticatedUser, - workspace_member_repository: WorkspaceMemberRepository, -) -> None: - member = workspace_member_repository.find_by_workspace_and_user(workspace_id, authenticated_user.user.id) - if member is None: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Workspace access denied") + +def _validate_mime_type(content_type: str | None) -> str: + """验证并返回标准化的 MIME 类型,如果无效则抛出异常。""" + if not content_type: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Content-Type header is required", + ) + + # 处理带参数的类型,如 "video/mp4; charset=utf-8" + base_type = content_type.split(";")[0].strip().lower() + + if base_type not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + detail=f"File type '{base_type}' is not supported. Allowed types: video, audio, and image files.", + ) + + return base_type def _require_project_and_library( @@ -90,7 +114,10 @@ async def prepare_direct_upload( detail=f"File exceeds upload limit ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)", ) - _require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) + # P2-5: 服务端验证 MIME 类型 + validated_content_type = _validate_mime_type(request.content_type) + + require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) _require_project_and_library( request.workspace_id, request.project_id, @@ -105,7 +132,7 @@ async def prepare_direct_upload( try: payload = storage_service.create_direct_upload_post( storage_key=storage_key, - content_type=request.content_type or "application/octet-stream", + content_type=validated_content_type, max_size_bytes=max_size_bytes, expires_seconds=settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS, ) @@ -133,7 +160,7 @@ async def complete_direct_upload( storage_service: OSSStorageService = Depends(get_storage_service), ) -> DirectUploadCompleteResponse: """确认浏览器直传完成并创建导入任务。""" - _require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) + require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) _require_project_and_library( request.workspace_id, request.project_id, @@ -171,16 +198,20 @@ async def upload_asset( storage_service: OSSStorageService = Depends(get_storage_service), ) -> UploadAssetResponse: """上传素材文件并触发导入流水线。""" - _require_workspace_member(workspace_id, authenticated_user, workspace_member_repository) + require_workspace_member(workspace_id, authenticated_user, workspace_member_repository) _require_project_and_library(workspace_id, project_id, library_id, project_repository, asset_library_repository) + # P2-5: 服务端验证 MIME 类型 + validated_content_type = _validate_mime_type(file.content_type) + file_id = uuid4().hex[:8] - storage_key = f"uploads/{file_id}/{file.filename}" + safe_filename = file.filename.replace("/", "_").replace("\\", "_") if file.filename else "unknown" + storage_key = f"uploads/{file_id}/{safe_filename}" file_url = storage_service.upload_file( file.file, storage_key, - content_type=file.content_type or "application/octet-stream", + content_type=validated_content_type, ) job = _submit_ingest_job(