diff --git a/backend/api/assets_serving.py b/backend/api/assets_serving.py index 69a1e4e8..b9ad6037 100644 --- a/backend/api/assets_serving.py +++ b/backend/api/assets_serving.py @@ -3,6 +3,8 @@ Assets Serving Router Serves user-uploaded assets (avatars, voice samples) from workspace storage. Uses authenticated or query-token access for security. +Audio MIME types are set correctly based on file extension so browsers +can play voice clone previews without NotSupportedError. """ import os @@ -12,13 +14,12 @@ from fastapi.responses import FileResponse from loguru import logger from typing import Dict, Any -from middleware.auth_middleware import get_current_user_with_query_token, get_current_user +from middleware.auth_middleware import get_current_user_with_query_token from api.story_writer.utils.auth import require_authenticated_user -from utils.storage_paths import get_repo_root +from utils.storage_paths import get_repo_root, sanitize_user_id router = APIRouter(prefix="/api/assets", tags=["Assets Serving"]) -# MIME type map for common audio/image formats (by file extension) MIME_MAP = { ".wav": "audio/wav", ".mp3": "audio/mpeg", @@ -38,34 +39,21 @@ MIME_MAP = { def _resolve_asset_path(user_id: str, category: str, filename: str) -> Path: - """Resolve an asset file path in the user's workspace. - - Args: - user_id: Clerk user ID (already validated) - category: Subdirectory under assets/ (e.g. 'avatars', 'voice_samples') - filename: The file name (already sanitized) - - Returns: - Resolved absolute Path to the asset file. - """ - from utils.storage_paths import sanitize_user_id - + """Resolve asset path in user workspace with path-traversal protection.""" safe_user_id = sanitize_user_id(user_id) repo_root = get_repo_root() - # Primary path: workspace/workspace_{user_id}/assets/{category}/{filename} - primary = (repo_root / "workspace" / f"workspace_{safe_user_id}" / "assets" / category / filename).resolve() + file_path = (repo_root / "workspace" / f"workspace_{safe_user_id}" / "assets" / category / filename).resolve() - # Security: ensure resolved path doesn't escape the workspace workspace_dir = (repo_root / "workspace" / f"workspace_{safe_user_id}").resolve() - if not str(primary).startswith(str(workspace_dir)): + if not str(file_path).startswith(str(workspace_dir)): raise HTTPException(status_code=403, detail="Access denied") - return primary + return file_path def _get_media_type(filename: str) -> str: - """Determine MIME type from file extension, with a default fallback.""" + """Determine MIME type from file extension, with fallback.""" ext = Path(filename).suffix.lower() return MIME_MAP.get(ext, "application/octet-stream") @@ -76,14 +64,13 @@ async def serve_avatar( filename: str, current_user: Dict[str, Any] = Depends(get_current_user_with_query_token), ): - """Serve avatar images. Supports auth via header or query token for elements.""" + """Serve avatar images. Supports auth via Authorization header or ?token= query param.""" require_authenticated_user(current_user) safe_filename = os.path.basename(filename) file_path = _resolve_asset_path(user_id, "avatars", safe_filename) if not file_path.exists(): - logger.debug(f"[Assets] Avatar not found: {file_path}") raise HTTPException(status_code=404, detail="Asset not found") media_type = _get_media_type(safe_filename) @@ -96,16 +83,21 @@ async def serve_voice_sample( filename: str, current_user: Dict[str, Any] = Depends(get_current_user_with_query_token), ): - """Serve voice sample audio files. Supports auth via header or query token for