diff --git a/backend/api/assets_serving.py b/backend/api/assets_serving.py
index d1049304..69a1e4e8 100644
--- a/backend/api/assets_serving.py
+++ b/backend/api/assets_serving.py
@@ -1,52 +1,111 @@
-from fastapi import APIRouter, HTTPException
-from fastapi.responses import FileResponse
+"""
+Assets Serving Router
+
+Serves user-uploaded assets (avatars, voice samples) from workspace storage.
+Uses authenticated or query-token access for security.
+"""
+
import os
from pathlib import Path
-from services.database import WORKSPACE_DIR, get_user_db_path
+from fastapi import APIRouter, Depends, HTTPException
+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 api.story_writer.utils.auth import require_authenticated_user
+from utils.storage_paths import get_repo_root
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",
+ ".ogg": "audio/ogg",
+ ".opus": "audio/opus",
+ ".webm": "audio/webm",
+ ".m4a": "audio/mp4",
+ ".aac": "audio/aac",
+ ".flac": "audio/flac",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".svg": "image/svg+xml",
+}
+
+
+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
+
+ 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()
+
+ # 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)):
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ return primary
+
+
+def _get_media_type(filename: str) -> str:
+ """Determine MIME type from file extension, with a default fallback."""
+ ext = Path(filename).suffix.lower()
+ return MIME_MAP.get(ext, "application/octet-stream")
+
+
@router.get("/{user_id}/avatars/{filename}")
-async def serve_avatar(user_id: str, filename: str):
- """
- Serve avatar images directly.
- Public endpoint relying on unguessable filenames.
- """
- # Sanitize user_id (simple check to prevent directory traversal)
- safe_user_id = "".join(c for c in user_id if c.isalnum() or c in ('-', '_'))
- if safe_user_id != user_id:
- raise HTTPException(status_code=400, detail="Invalid user ID")
-
- # Sanitize filename
+async def serve_avatar(
+ user_id: str,
+ 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."""
+ require_authenticated_user(current_user)
+
safe_filename = os.path.basename(filename)
-
- # Construct path
- # workspace/workspace_{user_id}/assets/avatars/{filename}
- file_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "avatars" / safe_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")
-
- return FileResponse(file_path)
+
+ media_type = _get_media_type(safe_filename)
+ return FileResponse(file_path, media_type=media_type)
+
@router.get("/{user_id}/voice_samples/{filename}")
-async def serve_voice_sample(user_id: str, filename: str):
- """
- Serve voice sample audio files directly.
- """
- # Sanitize user_id
- safe_user_id = "".join(c for c in user_id if c.isalnum() or c in ('-', '_'))
- if safe_user_id != user_id:
- raise HTTPException(status_code=400, detail="Invalid user ID")
-
- # Sanitize filename
+async def serve_voice_sample(
+ user_id: str,
+ 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