diff --git a/apps/api/app/api/routes/upload.py b/apps/api/app/api/routes/upload.py index e0ee6fd4d..2e16d836b 100644 --- a/apps/api/app/api/routes/upload.py +++ b/apps/api/app/api/routes/upload.py @@ -25,6 +25,13 @@ from packages.ports.workspace_member_repository import WorkspaceMemberRepository router = APIRouter() +# Allowed MIME types for file uploads (P2-5: Server-side validation) +ALLOWED_MIME_TYPES = { + "image/jpeg", "image/png", "image/gif", "image/webp", + "video/mp4", "video/quicktime", "video/x-msvideo", "video/webm", + "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp3", +} + def _require_workspace_member( workspace_id: str, @@ -72,6 +79,37 @@ def _submit_ingest_job( return job +def _validate_file_type(content: bytes, filename: str) -> str: + """ + P2-5: Server-side file type validation using python-magic. + + Args: + content: File content bytes + filename: Original filename + + Returns: + Detected MIME type + + Raises: + HTTPException: If file type is not allowed + """ + try: + import magic + detected_mime = magic.from_buffer(content, mime=True) + except ImportError: + # Fallback: infer from extension if magic is not available + import mimetypes + detected_mime = mimetypes.guess_type(filename)[0] or "application/octet-stream" + + if detected_mime not in ALLOWED_MIME_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported file type: {detected_mime}. Allowed types: {', '.join(sorted(ALLOWED_MIME_TYPES))}" + ) + + return detected_mime + + @router.post("/direct/prepare", response_model=DirectUploadPrepareResponse) async def prepare_direct_upload( request: DirectUploadPrepareRequest, @@ -81,7 +119,7 @@ async def prepare_direct_upload( workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), storage_service: OSSStorageService = Depends(get_storage_service), ) -> DirectUploadPrepareResponse: - """创建浏览器直传 OSS 的短期表单签名。""" + """Prepare direct upload to OSS""" settings = get_settings() max_size_bytes = settings.OSS_DIRECT_UPLOAD_MAX_MB * 1024 * 1024 if request.file_size > max_size_bytes: @@ -132,7 +170,7 @@ async def complete_direct_upload( workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), storage_service: OSSStorageService = Depends(get_storage_service), ) -> DirectUploadCompleteResponse: - """确认浏览器直传完成并创建导入任务。""" + """Complete direct upload""" _require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository) _require_project_and_library( request.workspace_id, @@ -159,10 +197,10 @@ async def complete_direct_upload( @router.post("", response_model=UploadAssetResponse) async def upload_asset( - file: UploadFile = File(..., description="要上传的文件(视频、音频、图片等)"), + file: UploadFile = File(..., description="文件上传"), workspace_id: str = Form(..., description="工作空间 ID"), project_id: str = Form(..., description="项目 ID"), - library_id: str = Form(..., description="素材库 ID"), + library_id: str = Form(..., description="资产库 ID"), authenticated_user: AuthenticatedUser = Depends(get_current_user), ingest_job_repository: Any = Depends(get_ingest_job_repository), project_repository: Any = Depends(get_project_repository), @@ -170,17 +208,26 @@ async def upload_asset( workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository), storage_service: OSSStorageService = Depends(get_storage_service), ) -> UploadAssetResponse: - """上传素材文件并触发导入流水线。""" + """Upload asset file""" _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) + # Read file content for validation (P2-5) + file_content = await file.read() + + # P2-5: Server-side file type validation + detected_mime = _validate_file_type(file_content, file.filename) + + # Reset file pointer for upload + await file.seek(0) + file_id = uuid4().hex[:8] storage_key = f"uploads/{file_id}/{file.filename}" file_url = storage_service.upload_file( file.file, storage_key, - content_type=file.content_type or "application/octet-stream", + content_type=file.content_type or detected_mime, ) job = _submit_ingest_job(