fix: voice clone preview audio not playing + avatar upload 500 + asset serving
- Fix voice clone preview saved as .wav regardless of actual format (MP3/WebM content from WaveSpeed was saved with .wav extension causing NotSupportedError) - Add detect_audio_format() and ensure_audio_extension() to media_utils - Fix assets_serving.py: use storage_paths for root resolution, add proper MIME types to FileResponse, add auth via query token for <audio> elements - Fix assets_serving.py: add path traversal security check - Fix step4_asset_routes.py: use get_user_workspace() instead of WORKSPACE_DIR, detect actual audio format before saving preview - Fix get_db() in database.py: raise HTTPException(401) instead of raw Exception, catch engine creation failures with HTTPException(503) - Fix avatar.py: add auth error handling, diagnostic logging for path resolution, graceful DB save degradation
This commit is contained in:
@@ -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 <img> 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 <audio> elements."""
|
||||
require_authenticated_user(current_user)
|
||||
|
||||
safe_filename = os.path.basename(filename)
|
||||
|
||||
# Construct path
|
||||
# workspace/workspace_{user_id}/assets/voice_samples/{filename}
|
||||
file_path = Path(WORKSPACE_DIR) / f"workspace_{safe_user_id}" / "assets" / "voice_samples" / safe_filename
|
||||
|
||||
file_path = _resolve_asset_path(user_id, "voice_samples", safe_filename)
|
||||
|
||||
if not file_path.exists():
|
||||
logger.debug(f"[Assets] Voice sample not found: {file_path}")
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
return FileResponse(file_path)
|
||||
|
||||
media_type = _get_media_type(safe_filename)
|
||||
logger.debug(f"[Assets] Serving voice sample: {file_path} ({media_type}, {file_path.stat().st_size} bytes)")
|
||||
return FileResponse(file_path, media_type=media_type)
|
||||
Reference in New Issue
Block a user