4c3dcfbf0f
Deploy / Deploy Staging (push) Failing after 1s
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Has been cancelled
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m0s
Tests / test (pull_request) Failing after 1m0s
Tests / lint (pull_request) Failing after 30s
246 lines
9.3 KiB
Python
246 lines
9.3 KiB
Python
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from app.auth import AuthenticatedUser, get_current_user
|
|
from app.config import get_settings
|
|
from app.core.celery_app import celery_app
|
|
from app.core.storage import OSSStorageService, get_storage_service
|
|
from app.dependencies import (
|
|
get_asset_library_repository,
|
|
get_ingest_job_repository,
|
|
get_project_repository,
|
|
get_workspace_member_repository,
|
|
)
|
|
from app.schemas.upload import (
|
|
DirectUploadCompleteRequest,
|
|
DirectUploadCompleteResponse,
|
|
DirectUploadPrepareRequest,
|
|
DirectUploadPrepareResponse,
|
|
UploadAssetResponse,
|
|
)
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
|
|
|
from packages.application import GetProjectUseCase, SubmitIngestJobCommand, SubmitIngestJobUseCase
|
|
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,
|
|
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 _require_project_and_library(
|
|
workspace_id: str,
|
|
project_id: str,
|
|
library_id: str,
|
|
project_repository: Any,
|
|
asset_library_repository: Any,
|
|
) -> None:
|
|
project = GetProjectUseCase(project_repository).execute(project_id)
|
|
if project is None or project.workspace_id != workspace_id:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found")
|
|
|
|
libraries = asset_library_repository.list_by_project(project_id)
|
|
if not any(item.id == library_id and item.workspace_id == workspace_id for item in libraries):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset library not found")
|
|
|
|
|
|
def _submit_ingest_job(
|
|
workspace_id: str,
|
|
project_id: str,
|
|
library_id: str,
|
|
storage_key: str,
|
|
ingest_job_repository: Any,
|
|
) -> Any:
|
|
use_case = SubmitIngestJobUseCase(ingest_job_repository)
|
|
job = use_case.execute(
|
|
SubmitIngestJobCommand(
|
|
workspace_id=workspace_id,
|
|
project_id=project_id,
|
|
library_id=library_id,
|
|
storage_key=storage_key,
|
|
)
|
|
)
|
|
celery_app.send_task("worker.ingest_asset", args=[job.id])
|
|
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,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
project_repository: Any = Depends(get_project_repository),
|
|
asset_library_repository: Any = Depends(get_asset_library_repository),
|
|
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
|
storage_service: OSSStorageService = Depends(get_storage_service),
|
|
) -> DirectUploadPrepareResponse:
|
|
"""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:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
|
detail=f"File exceeds upload limit ({settings.OSS_DIRECT_UPLOAD_MAX_MB}MB)",
|
|
)
|
|
|
|
_require_workspace_member(request.workspace_id, authenticated_user, workspace_member_repository)
|
|
_require_project_and_library(
|
|
request.workspace_id,
|
|
request.project_id,
|
|
request.library_id,
|
|
project_repository,
|
|
asset_library_repository,
|
|
)
|
|
|
|
file_id = uuid4().hex[:8]
|
|
safe_filename = request.filename.replace("/", "_").replace("\\", "_")
|
|
storage_key = f"uploads/{file_id}/{safe_filename}"
|
|
try:
|
|
payload = storage_service.create_direct_upload_post(
|
|
storage_key=storage_key,
|
|
content_type=request.content_type or "application/octet-stream",
|
|
max_size_bytes=max_size_bytes,
|
|
expires_seconds=settings.OSS_DIRECT_UPLOAD_EXPIRE_SECONDS,
|
|
)
|
|
except RuntimeError as error:
|
|
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)) from error
|
|
|
|
return DirectUploadPrepareResponse(
|
|
upload_url=str(payload["url"]),
|
|
method=str(payload["method"]),
|
|
storage_key=str(payload["storage_key"]),
|
|
expires_at=str(payload["expires_at"]),
|
|
fields={str(key): str(value) for key, value in dict(payload["fields"]).items()},
|
|
max_size_bytes=max_size_bytes,
|
|
)
|
|
|
|
|
|
@router.post("/direct/complete", response_model=DirectUploadCompleteResponse)
|
|
async def complete_direct_upload(
|
|
request: DirectUploadCompleteRequest,
|
|
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
|
ingest_job_repository: Any = Depends(get_ingest_job_repository),
|
|
project_repository: Any = Depends(get_project_repository),
|
|
asset_library_repository: Any = Depends(get_asset_library_repository),
|
|
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,
|
|
request.project_id,
|
|
request.library_id,
|
|
project_repository,
|
|
asset_library_repository,
|
|
)
|
|
normalized_key = storage_service._normalize_storage_key(request.storage_key)
|
|
if not normalized_key.startswith("uploads/"):
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid upload key")
|
|
if not storage_service.file_exists(normalized_key):
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Uploaded file not found")
|
|
|
|
job = _submit_ingest_job(
|
|
workspace_id=request.workspace_id,
|
|
project_id=request.project_id,
|
|
library_id=request.library_id,
|
|
storage_key=normalized_key,
|
|
ingest_job_repository=ingest_job_repository,
|
|
)
|
|
return DirectUploadCompleteResponse(storage_key=normalized_key, ingest_job_id=job.id)
|
|
|
|
|
|
@router.post("", response_model=UploadAssetResponse)
|
|
async def upload_asset(
|
|
file: UploadFile = File(..., description="文件上传"),
|
|
workspace_id: str = Form(..., description="工作空间 ID"),
|
|
project_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),
|
|
asset_library_repository: Any = Depends(get_asset_library_repository),
|
|
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 detected_mime,
|
|
)
|
|
|
|
job = _submit_ingest_job(
|
|
workspace_id=workspace_id,
|
|
project_id=project_id,
|
|
library_id=library_id,
|
|
storage_key=storage_key,
|
|
ingest_job_repository=ingest_job_repository,
|
|
)
|
|
|
|
return UploadAssetResponse(
|
|
storage_key=storage_key,
|
|
ingest_job_id=job.id,
|
|
url=file_url,
|
|
)
|