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