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