fix: voice clone preview audio authentication + MIME type fixes
- Restore auth on assets_serving.py using get_current_user_with_query_token (supports ?token= query param for <audio> elements) - Add proper MIME type detection on asset serving (fixes NotSupportedError) - Use storage_paths for path resolution in assets_serving.py - VoiceSelector: append auth token to preview URLs for /api/ endpoints - VoiceAvatarPlaceholder: add authenticatedAudioUrl state with async token resolution so <audio> elements get ?token= query param - TestPersonaModal: same auth token pattern for voice preview audio
This commit is contained in:
@@ -3,6 +3,8 @@ Assets Serving Router
|
|||||||
|
|
||||||
Serves user-uploaded assets (avatars, voice samples) from workspace storage.
|
Serves user-uploaded assets (avatars, voice samples) from workspace storage.
|
||||||
Uses authenticated or query-token access for security.
|
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
|
import os
|
||||||
@@ -12,13 +14,12 @@ from fastapi.responses import FileResponse
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
from typing import Dict, Any
|
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 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"])
|
router = APIRouter(prefix="/api/assets", tags=["Assets Serving"])
|
||||||
|
|
||||||
# MIME type map for common audio/image formats (by file extension)
|
|
||||||
MIME_MAP = {
|
MIME_MAP = {
|
||||||
".wav": "audio/wav",
|
".wav": "audio/wav",
|
||||||
".mp3": "audio/mpeg",
|
".mp3": "audio/mpeg",
|
||||||
@@ -38,34 +39,21 @@ MIME_MAP = {
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_asset_path(user_id: str, category: str, filename: str) -> Path:
|
def _resolve_asset_path(user_id: str, category: str, filename: str) -> Path:
|
||||||
"""Resolve an asset file path in the user's workspace.
|
"""Resolve asset path in user workspace with path-traversal protection."""
|
||||||
|
|
||||||
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)
|
safe_user_id = sanitize_user_id(user_id)
|
||||||
repo_root = get_repo_root()
|
repo_root = get_repo_root()
|
||||||
|
|
||||||
# Primary path: workspace/workspace_{user_id}/assets/{category}/{filename}
|
file_path = (repo_root / "workspace" / f"workspace_{safe_user_id}" / "assets" / category / filename).resolve()
|
||||||
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()
|
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")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
return primary
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
def _get_media_type(filename: str) -> str:
|
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()
|
ext = Path(filename).suffix.lower()
|
||||||
return MIME_MAP.get(ext, "application/octet-stream")
|
return MIME_MAP.get(ext, "application/octet-stream")
|
||||||
|
|
||||||
@@ -76,14 +64,13 @@ async def serve_avatar(
|
|||||||
filename: str,
|
filename: str,
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
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."""
|
"""Serve avatar images. Supports auth via Authorization header or ?token= query param."""
|
||||||
require_authenticated_user(current_user)
|
require_authenticated_user(current_user)
|
||||||
|
|
||||||
safe_filename = os.path.basename(filename)
|
safe_filename = os.path.basename(filename)
|
||||||
file_path = _resolve_asset_path(user_id, "avatars", safe_filename)
|
file_path = _resolve_asset_path(user_id, "avatars", safe_filename)
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
logger.debug(f"[Assets] Avatar not found: {file_path}")
|
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
|
||||||
media_type = _get_media_type(safe_filename)
|
media_type = _get_media_type(safe_filename)
|
||||||
@@ -96,16 +83,21 @@ async def serve_voice_sample(
|
|||||||
filename: str,
|
filename: str,
|
||||||
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
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."""
|
"""Serve voice sample audio files.
|
||||||
|
|
||||||
|
Supports auth via Authorization header or ?token= query param.
|
||||||
|
The ?token= param is essential for <audio> elements and new Audio()
|
||||||
|
which cannot send Authorization headers.
|
||||||
|
"""
|
||||||
require_authenticated_user(current_user)
|
require_authenticated_user(current_user)
|
||||||
|
|
||||||
safe_filename = os.path.basename(filename)
|
safe_filename = os.path.basename(filename)
|
||||||
file_path = _resolve_asset_path(user_id, "voice_samples", safe_filename)
|
file_path = _resolve_asset_path(user_id, "voice_samples", safe_filename)
|
||||||
|
|
||||||
if not file_path.exists():
|
if not file_path.exists():
|
||||||
logger.debug(f"[Assets] Voice sample not found: {file_path}")
|
logger.info(f"[Assets] Voice sample not found: {file_path}")
|
||||||
raise HTTPException(status_code=404, detail="Asset not found")
|
raise HTTPException(status_code=404, detail="Asset not found")
|
||||||
|
|
||||||
media_type = _get_media_type(safe_filename)
|
media_type = _get_media_type(safe_filename)
|
||||||
logger.debug(f"[Assets] Serving voice sample: {file_path} ({media_type}, {file_path.stat().st_size} bytes)")
|
logger.info(f"[Assets] Serving voice sample: {safe_filename} ({media_type}, {file_path.stat().st_size} bytes)")
|
||||||
return FileResponse(file_path, media_type=media_type)
|
return FileResponse(file_path, media_type=media_type)
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { createAvatarVideoAsync } from '../../../../api/videoStudioApi';
|
import { createAvatarVideoAsync } from '../../../../api/videoStudioApi';
|
||||||
import { useVideoGenerationPolling } from '../../../../hooks/usePolling';
|
import { useVideoGenerationPolling } from '../../../../hooks/usePolling';
|
||||||
import { fetchMediaBlobUrl } from '../../../../utils/fetchMediaBlobUrl';
|
import { fetchMediaBlobUrl } from '../../../../utils/fetchMediaBlobUrl';
|
||||||
|
import { getAuthTokenGetter } from '../../../../api/client';
|
||||||
import { VideoCameraFront, SkipNext, PlayArrow, InfoOutlined, Close as CloseIcon, HelpOutline, Refresh, RestartAlt, Undo } from '@mui/icons-material';
|
import { VideoCameraFront, SkipNext, PlayArrow, InfoOutlined, Close as CloseIcon, HelpOutline, Refresh, RestartAlt, Undo } from '@mui/icons-material';
|
||||||
import { VideoGenerationLoader } from '../../../shared/VideoGenerationLoader';
|
import { VideoGenerationLoader } from '../../../shared/VideoGenerationLoader';
|
||||||
import { OperationButton } from '../../../shared/OperationButton';
|
import { OperationButton } from '../../../shared/OperationButton';
|
||||||
@@ -31,8 +32,33 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
|
|||||||
const [model, setModel] = useState<'infinitetalk' | 'hunyuan-avatar'>('infinitetalk');
|
const [model, setModel] = useState<'infinitetalk' | 'hunyuan-avatar'>('infinitetalk');
|
||||||
const [showCapabilities, setShowCapabilities] = useState(false);
|
const [showCapabilities, setShowCapabilities] = useState(false);
|
||||||
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
|
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null);
|
||||||
|
const [authenticatedVoiceUrl, setAuthenticatedVoiceUrl] = useState<string | null>(null);
|
||||||
const STORAGE_KEY = 'test_persona_video_url';
|
const STORAGE_KEY = 'test_persona_video_url';
|
||||||
const STORAGE_BACKUP_KEY = 'test_persona_video_url_backup';
|
const STORAGE_BACKUP_KEY = 'test_persona_video_url_backup';
|
||||||
|
|
||||||
|
// Append auth token to /api/ asset URLs so <audio> can access them
|
||||||
|
useEffect(() => {
|
||||||
|
if (!voiceUrl || !voiceUrl.includes('/api/')) {
|
||||||
|
setAuthenticatedVoiceUrl(voiceUrl || null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const tokenGetter = getAuthTokenGetter();
|
||||||
|
if (tokenGetter) {
|
||||||
|
const token = await tokenGetter();
|
||||||
|
if (token && !cancelled) {
|
||||||
|
const sep = voiceUrl.includes('?') ? '&' : '?';
|
||||||
|
setAuthenticatedVoiceUrl(`${voiceUrl}${sep}token=${encodeURIComponent(token)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* fallback */ }
|
||||||
|
if (!cancelled) setAuthenticatedVoiceUrl(voiceUrl);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [voiceUrl]);
|
||||||
|
|
||||||
const [generatedVideoUrl, setGeneratedVideoUrl] = useState<string | null>(() => {
|
const [generatedVideoUrl, setGeneratedVideoUrl] = useState<string | null>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -527,7 +553,7 @@ export const TestPersonaModal: React.FC<TestPersonaModalProps> = ({
|
|||||||
<Typography variant="caption" fontWeight="bold" sx={{ mb: 1, display: 'block', color: '#64748b' }}>
|
<Typography variant="caption" fontWeight="bold" sx={{ mb: 1, display: 'block', color: '#64748b' }}>
|
||||||
Voice Preview
|
Voice Preview
|
||||||
</Typography>
|
</Typography>
|
||||||
<audio controls src={voiceUrl} style={{ width: '100%', height: 36 }} />
|
<audio controls src={authenticatedVoiceUrl || undefined} style={{ width: '100%', height: 36 }} />
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useMemo, useRef, useState, useEffect } from 'react';
|
import React, { useMemo, useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgress, Slider, FormControlLabel, Checkbox, MenuItem, Tooltip, Chip, Divider, Grid, IconButton, Modal, Fade, Backdrop, LinearProgress } from '@mui/material';
|
import { Box, Typography, Paper, Stack, Button, Alert, TextField, CircularProgress, Slider, FormControlLabel, Checkbox, MenuItem, Tooltip, Chip, Divider, Grid, IconButton, Modal, Fade, Backdrop, LinearProgress } from '@mui/material';
|
||||||
import { keyframes } from '@mui/system';
|
import { keyframes } from '@mui/system';
|
||||||
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo, Headphones, Article, VideoLibrary, TrendingUp, CheckCircle, RecordVoiceOver, Settings } from '@mui/icons-material';
|
import { Mic, GraphicEq, Timer, CloudUpload, Stop, PlayArrow, InfoOutlined, TextFields, HelpOutline, AutoAwesome, Campaign, MicNone, Podcasts, RestartAlt, Undo, Headphones, Article, VideoLibrary, TrendingUp, CheckCircle, RecordVoiceOver, Settings } from '@mui/icons-material';
|
||||||
import { createVoiceClone, createVoiceDesign, getLatestVoiceClone, setBrandVoice } from '../../../../api/brandAssets';
|
import { createVoiceClone, createVoiceDesign, getLatestVoiceClone, setBrandVoice } from '../../../../api/brandAssets';
|
||||||
|
import { getAuthTokenGetter } from '../../../../api/client';
|
||||||
import { OperationButton } from '../../../shared/OperationButton';
|
import { OperationButton } from '../../../shared/OperationButton';
|
||||||
|
|
||||||
const pulse = keyframes`
|
const pulse = keyframes`
|
||||||
@@ -95,6 +96,32 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
|||||||
} catch { return null; }
|
} catch { return null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Append auth token to /api/ asset URLs so <audio> elements can access them
|
||||||
|
const [authenticatedAudioUrl, setAuthenticatedAudioUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!resultAudioUrl || !resultAudioUrl.includes('/api/')) {
|
||||||
|
setAuthenticatedAudioUrl(resultAudioUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const tokenGetter = getAuthTokenGetter();
|
||||||
|
if (tokenGetter) {
|
||||||
|
const token = await tokenGetter();
|
||||||
|
if (token && !cancelled) {
|
||||||
|
const sep = resultAudioUrl.includes('?') ? '&' : '?';
|
||||||
|
setAuthenticatedAudioUrl(`${resultAudioUrl}${sep}token=${encodeURIComponent(token)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* fallback to unauthenticated */ }
|
||||||
|
if (!cancelled) setAuthenticatedAudioUrl(resultAudioUrl);
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [resultAudioUrl]);
|
||||||
|
|
||||||
const [archivedResultAudioUrl, setArchivedResultAudioUrl] = useState<string | null>(() => {
|
const [archivedResultAudioUrl, setArchivedResultAudioUrl] = useState<string | null>(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(STORAGE_BACKUP_KEY);
|
const saved = localStorage.getItem(STORAGE_BACKUP_KEY);
|
||||||
@@ -1090,7 +1117,7 @@ export const VoiceAvatarPlaceholder: React.FC<{ domainName?: string; onVoiceSet?
|
|||||||
<Typography variant="caption" fontWeight="800" sx={{ color: '#EC4899', textTransform: 'uppercase', mb: 0.25, display: 'block', fontSize: '0.65rem' }}>
|
<Typography variant="caption" fontWeight="800" sx={{ color: '#EC4899', textTransform: 'uppercase', mb: 0.25, display: 'block', fontSize: '0.65rem' }}>
|
||||||
Generated AI Voice Preview
|
Generated AI Voice Preview
|
||||||
</Typography>
|
</Typography>
|
||||||
<audio controls src={resultAudioUrl} style={{ width: '100%', height: '28px' }} />
|
<audio controls src={authenticatedAudioUrl || undefined} style={{ width: '100%', height: '28px' }} />
|
||||||
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
Category,
|
Category,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { getLatestVoiceClone, VoiceCloneResponse } from "../../api/brandAssets";
|
import { getLatestVoiceClone, VoiceCloneResponse } from "../../api/brandAssets";
|
||||||
|
import { getAuthTokenGetter } from "../../api/client";
|
||||||
import { VoiceAvatarPlaceholder } from "../OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder";
|
import { VoiceAvatarPlaceholder } from "../OnboardingWizard/PersonalizationStep/components/VoiceAvatarPlaceholder";
|
||||||
|
|
||||||
export type VoiceOption = {
|
export type VoiceOption = {
|
||||||
@@ -235,7 +236,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handlePreview = useCallback((voice: VoiceOption) => {
|
const handlePreview = useCallback(async (voice: VoiceOption) => {
|
||||||
if (!voice.previewUrl) return;
|
if (!voice.previewUrl) return;
|
||||||
|
|
||||||
if (playingPreview === voice.id) {
|
if (playingPreview === voice.id) {
|
||||||
@@ -247,7 +248,22 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||||||
stopCurrentAudio();
|
stopCurrentAudio();
|
||||||
setPlayingPreview(voice.id);
|
setPlayingPreview(voice.id);
|
||||||
|
|
||||||
const audio = new Audio(voice.previewUrl);
|
// Append auth token for endpoints that require it (e.g. /api/assets/)
|
||||||
|
let previewUrl = voice.previewUrl;
|
||||||
|
try {
|
||||||
|
const tokenGetter = getAuthTokenGetter();
|
||||||
|
if (tokenGetter) {
|
||||||
|
const token = await tokenGetter();
|
||||||
|
if (token && previewUrl.includes('/api/')) {
|
||||||
|
const separator = previewUrl.includes('?') ? '&' : '?';
|
||||||
|
previewUrl = `${previewUrl}${separator}token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Token retrieval failed — try URL without token
|
||||||
|
}
|
||||||
|
|
||||||
|
const audio = new Audio(previewUrl);
|
||||||
audioRef.current = audio;
|
audioRef.current = audio;
|
||||||
|
|
||||||
audio.onerror = () => {
|
audio.onerror = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user