diff --git a/web/backend/core/path_tokens.py b/web/backend/core/path_tokens.py new file mode 100644 index 0000000..abb44fb --- /dev/null +++ b/web/backend/core/path_tokens.py @@ -0,0 +1,57 @@ +""" +Path Token Encryption + +Encrypts file paths into opaque, time-limited Fernet tokens so raw filesystem +paths are never exposed in API URLs. + + encode_path("/opt/immich/paid/creator/file.mp4") + # => "gAAAAABn..." (URL-safe, 4-hour TTL) + + decode_path("gAAAAABn...") + # => "/opt/immich/paid/creator/file.mp4" +""" + +import base64 +from pathlib import Path + +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives import hashes +from fastapi import HTTPException + + +def _get_fernet() -> Fernet: + """Derive a Fernet key from .session_secret via HKDF (cached after first call).""" + if not hasattr(_get_fernet, "_instance"): + from ..core.config import settings + secret_path = settings.PROJECT_ROOT / ".session_secret" + secret = secret_path.read_text().strip().encode() + + derived = HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=b"path-token-v1", + info=b"fernet-key", + ).derive(secret) + + key = base64.urlsafe_b64encode(derived) + _get_fernet._instance = Fernet(key) + return _get_fernet._instance + + +def encode_path(file_path: str) -> str: + """Encrypt a file path into a URL-safe Fernet token (4-hour TTL).""" + f = _get_fernet() + return f.encrypt(file_path.encode()).decode() + + +def decode_path(token: str) -> str: + """Decrypt a Fernet token back to a file path. + + Raises HTTP 400 on invalid or expired tokens (TTL = 4 hours). + """ + f = _get_fernet() + try: + return f.decrypt(token.encode(), ttl=14400).decode() + except InvalidToken: + raise HTTPException(status_code=400, detail="Invalid or expired file token") diff --git a/web/backend/routers/dashboard.py b/web/backend/routers/dashboard.py index 4bb9cc3..d95aabd 100644 --- a/web/backend/routers/dashboard.py +++ b/web/backend/routers/dashboard.py @@ -10,6 +10,7 @@ from slowapi import Limiter from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, get_app_state from ..core.exceptions import handle_exceptions +from ..core.path_tokens import encode_path from modules.universal_logger import get_logger router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) @@ -76,9 +77,11 @@ async def get_recent_items( media_items = [] for row in cursor.fetchall(): + fp = row[1] media_items.append({ 'id': row[0], - 'file_path': row[1], + 'file_path': fp, + 'file_token': encode_path(fp) if fp else None, 'filename': row[2], 'source': row[3], 'platform': row[4], @@ -152,9 +155,11 @@ async def get_recent_items( 'matched_person': row[13] } + fp = row[1] review_items.append({ 'id': row[0], - 'file_path': row[1], + 'file_path': fp, + 'file_token': encode_path(fp) if fp else None, 'filename': row[2], 'source': row[3], 'platform': row[4], @@ -300,5 +305,6 @@ async def set_dismissed_cards( preference_value = excluded.preference_value, updated_at = CURRENT_TIMESTAMP """, (user_id, json.dumps(data))) + conn.commit() return {'status': 'ok'} diff --git a/web/backend/routers/discovery.py b/web/backend/routers/discovery.py index d626a5e..50136df 100644 --- a/web/backend/routers/discovery.py +++ b/web/backend/routers/discovery.py @@ -20,6 +20,7 @@ from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, get_app_state from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError +from ..core.path_tokens import encode_path from ..core.responses import message_response, id_response, count_response, offset_paginated from modules.discovery_system import get_discovery_system from modules.universal_logger import get_logger @@ -381,8 +382,10 @@ async def get_smart_folders_stats( previews = [] for row in cursor.fetchall(): + fp = row['file_path'] previews.append({ - 'file_path': row['file_path'], + 'file_path': fp, + 'file_token': encode_path(fp) if fp else None, 'content_type': row['content_type'] }) @@ -758,9 +761,11 @@ async def get_recent_activity( ''', (limit,)) for row in cursor.fetchall(): + fp = row['file_path'] activity['recent_downloads'].append({ 'id': row['id'], - 'file_path': row['file_path'], + 'file_path': fp, + 'file_token': encode_path(fp) if fp else None, 'filename': row['filename'], 'platform': row['platform'], 'source': row['source'], @@ -788,9 +793,11 @@ async def get_recent_activity( except (json.JSONDecodeError, TypeError): pass + fp = row['recycle_path'] activity['recent_deleted'].append({ 'id': row['id'], - 'file_path': row['recycle_path'], + 'file_path': fp, + 'file_token': encode_path(fp) if fp else None, 'original_path': row['original_path'], 'filename': row['original_filename'], 'platform': metadata.get('platform', 'unknown'), @@ -814,9 +821,11 @@ async def get_recent_activity( ''', (limit,)) for row in cursor.fetchall(): + fp = row['file_path'] activity['recent_moved_to_review'].append({ 'id': row['id'], - 'file_path': row['file_path'], + 'file_path': fp, + 'file_token': encode_path(fp) if fp else None, 'filename': row['filename'], 'platform': row['platform'], 'source': row['source'], diff --git a/web/backend/routers/downloads.py b/web/backend/routers/downloads.py index b956b2d..2c8a927 100644 --- a/web/backend/routers/downloads.py +++ b/web/backend/routers/downloads.py @@ -18,6 +18,7 @@ from slowapi import Limiter from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, get_app_state, require_admin +from ..core.path_tokens import encode_path from ..core.exceptions import ( handle_exceptions, DatabaseError, @@ -654,6 +655,7 @@ async def advanced_search_downloads( "content_type": row[3], "filename": row[4], "file_path": row[5], + "file_token": encode_path(row[5]) if row[5] else None, "file_size": row[6], "download_date": row[7], "post_date": row[8], diff --git a/web/backend/routers/media.py b/web/backend/routers/media.py index d604035..8c7f0ee 100644 --- a/web/backend/routers/media.py +++ b/web/backend/routers/media.py @@ -37,6 +37,7 @@ from ..core.exceptions import ( ValidationError ) from ..core.responses import now_iso8601 +from ..core.path_tokens import encode_path, decode_path from modules.universal_logger import get_logger from ..core.utils import ( get_media_dimensions, @@ -177,6 +178,7 @@ async def get_media_thumbnail( file_path: str = None, media_type: str = None, token: str = None, + t: str = None, current_user: Dict = Depends(get_current_user_media) ): """ @@ -192,7 +194,10 @@ async def get_media_thumbnail( Args: file_path: Path to the media file media_type: 'image' or 'video' + t: Encrypted file token (alternative to file_path) """ + if t: + file_path = decode_path(t) resolved_path = validate_file_path(file_path) app_state = get_app_state() @@ -261,11 +266,14 @@ async def get_media_thumbnail( @handle_exceptions async def get_media_preview( request: Request, - file_path: str, + file_path: str = None, token: str = None, + t: str = None, current_user: Dict = Depends(get_current_user_media) ): """Serve a media file for preview.""" + if t: + file_path = decode_path(t) resolved_path = validate_file_path(file_path) if not resolved_path.exists() or not resolved_path.is_file(): @@ -283,12 +291,17 @@ async def get_media_preview( @handle_exceptions async def get_media_metadata( request: Request, - file_path: str, + file_path: str = None, + t: str = None, current_user: Dict = Depends(get_current_user) ): """ Get cached metadata for a media file (resolution, duration, etc.). """ + if t: + file_path = decode_path(t) + elif not file_path: + raise ValidationError("Either 't' or 'file_path' is required") resolved_path = validate_file_path(file_path) if not resolved_path.exists() or not resolved_path.is_file(): @@ -381,7 +394,8 @@ async def get_media_metadata( @handle_exceptions async def get_embedded_metadata( request: Request, - file_path: str, + file_path: str = None, + t: str = None, current_user: Dict = Depends(get_current_user) ): """ @@ -392,6 +406,8 @@ async def get_embedded_metadata( This is different from /metadata which returns technical info (resolution, duration). """ + if t: + file_path = decode_path(t) resolved_path = validate_file_path(file_path) if not resolved_path.exists() or not resolved_path.is_file(): @@ -1332,12 +1348,14 @@ async def get_media_gallery( 'scan_date': row['face_scan_date'] if has_face_data else None } + fp = row['file_path'] item = { "id": row['id'], "platform": row['platform'], "source": row['source'] or 'unknown', "filename": row['filename'], - "file_path": row['file_path'], + "file_path": fp, + "file_token": encode_path(fp) if fp else None, "file_size": row['file_size'] or 0, "media_type": row['media_type'] or 'image', "download_date": row['download_date'], diff --git a/web/backend/routers/paid_content.py b/web/backend/routers/paid_content.py index 99b6776..3ed9c29 100644 --- a/web/backend/routers/paid_content.py +++ b/web/backend/routers/paid_content.py @@ -20,7 +20,7 @@ from threading import Lock from typing import Dict, List, Optional from pathlib import Path -from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request, Response, UploadFile, File, Form +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response, UploadFile, File, Form from fastapi.responses import FileResponse from pydantic import BaseModel, ConfigDict from slowapi import Limiter @@ -28,6 +28,7 @@ from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, get_app_state from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError +from ..core.path_tokens import encode_path, decode_path from ..core.responses import message_response, now_iso8601 from modules.universal_logger import get_logger @@ -2719,6 +2720,12 @@ async def get_feed( total = db.get_posts_count(**filter_kwargs) total_media = db.get_media_count(**filter_kwargs) + # Add encrypted file tokens to attachment local_paths + for post in posts: + for att in post.get('attachments', []): + lp = att.get('local_path') + att['file_token'] = encode_path(lp) if lp else None + return { "posts": posts, "count": len(posts), @@ -2743,6 +2750,11 @@ async def get_post( if not post: raise NotFoundError(f"Post {post_id} not found") + # Add encrypted file tokens to attachment local_paths + for att in post.get('attachments', []): + lp = att.get('local_path') + att['file_token'] = encode_path(lp) if lp else None + # Mark as viewed db.mark_post_viewed(post_id) @@ -3194,6 +3206,11 @@ async def get_notifications( db = _get_db_adapter() notifications = db.get_notifications(unread_only=unread_only, limit=limit, offset=offset) unread_count = db.get_unread_notification_count() + # Add encrypted file tokens to media_files in metadata + for notif in notifications: + for media in notif.get('metadata', {}).get('media_files', []): + fp = media.get('file_path') + media['file_token'] = encode_path(fp) if fp else None return {"notifications": notifications, "count": len(notifications), "unread_count": unread_count} @@ -4220,10 +4237,15 @@ async def _download_post_background(post_id: int): @handle_exceptions async def serve_file( request: Request, - path: str, + path: Optional[str] = None, + t: Optional[str] = None, current_user: Dict = Depends(get_current_user) ): """Serve a downloaded file with byte-range support for video streaming""" + if t: + path = decode_path(t) + elif not path: + raise ValidationError("Either 't' or 'path' is required") file_path = Path(path) # Security: ensure path is within allowed directories @@ -4716,16 +4738,36 @@ async def backfill_thumbnails( @handle_exceptions async def get_thumbnail_by_path( request: Request, - file_path: str = Query(..., description="Full path to the file"), + file_path: Optional[str] = Query(None, description="Full path to the file"), + t: Optional[str] = None, size: str = Query(default="small", regex="^(small|medium|large)$"), current_user: Dict = Depends(get_current_user) ): """Get thumbnail for a file by its path (for notifications page)""" from pathlib import Path + if t: + file_path = decode_path(t) + elif not file_path: + raise ValidationError("Either 't' or 'file_path' is required") path = Path(file_path) + + # Security: ensure path is within allowed directories + db = _get_db_adapter() + config = db.get_config() + base_path = Path(config.get('base_download_path', '/paid-content')) + try: + resolved_path = path.resolve() + resolved_base = base_path.resolve() + if not resolved_path.is_relative_to(resolved_base): + raise ValidationError("Access denied: path outside allowed directory") + except ValidationError: + raise + except Exception: + raise ValidationError("Invalid path") + if not path.exists(): - raise HTTPException(status_code=404, detail="File not found") + raise NotFoundError("File not found") # Determine file type from extension ext = path.suffix.lower() @@ -4734,7 +4776,7 @@ async def get_thumbnail_by_path( elif ext in ['.mp4', '.mov', '.webm', '.avi', '.mkv', '.m4v']: file_type = 'video' else: - raise HTTPException(status_code=400, detail="Unsupported file type") + raise ValidationError("Unsupported file type") # Size mapping size_map = {"small": (200, 200), "medium": (400, 400), "large": (800, 800)} @@ -4746,7 +4788,7 @@ async def get_thumbnail_by_path( await scraper.close() if not thumbnail_data: - raise HTTPException(status_code=500, detail="Failed to generate thumbnail") + raise ValidationError("Failed to generate thumbnail") return Response( content=thumbnail_data, @@ -4760,15 +4802,35 @@ async def get_thumbnail_by_path( @handle_exceptions async def get_preview_by_path( request: Request, - file_path: str = Query(..., description="Full path to the file"), + file_path: Optional[str] = Query(None, description="Full path to the file"), + t: Optional[str] = None, current_user: Dict = Depends(get_current_user) ): """Serve a file for preview (for notifications lightbox)""" from pathlib import Path + if t: + file_path = decode_path(t) + elif not file_path: + raise ValidationError("Either 't' or 'file_path' is required") path = Path(file_path) + + # Security: ensure path is within allowed directories + db = _get_db_adapter() + config = db.get_config() + base_path = Path(config.get('base_download_path', '/paid-content')) + try: + resolved_path = path.resolve() + resolved_base = base_path.resolve() + if not resolved_path.is_relative_to(resolved_base): + raise ValidationError("Access denied: path outside allowed directory") + except ValidationError: + raise + except Exception: + raise ValidationError("Invalid path") + if not path.exists(): - raise HTTPException(status_code=404, detail="File not found") + raise NotFoundError("File not found") # Determine media type from extension ext = path.suffix.lower() @@ -6221,6 +6283,10 @@ async def get_gallery_media( limit=limit, offset=offset ) + # Add encrypted file tokens so raw paths aren't exposed in URLs + for item in items: + lp = item.get('local_path') + item['file_token'] = encode_path(lp) if lp else None # Only run COUNT on first page — subsequent pages don't need it total = None if offset == 0: diff --git a/web/backend/routers/review.py b/web/backend/routers/review.py index 35eb07f..a4bfbbf 100644 --- a/web/backend/routers/review.py +++ b/web/backend/routers/review.py @@ -36,6 +36,7 @@ from ..core.exceptions import ( ValidationError ) from ..core.responses import now_iso8601 +from ..core.path_tokens import encode_path, decode_path from modules.universal_logger import get_logger from modules.date_utils import DateHandler from ..core.utils import get_media_dimensions, get_media_dimensions_batch @@ -244,9 +245,11 @@ async def get_review_queue( else: width, height = dimensions_cache.get(row[1], (row[7], row[8])) + fp = row[1] file_item = { "filename": row[2], - "file_path": row[1], + "file_path": fp, + "file_token": encode_path(fp) if fp else None, "file_size": row[6] if row[6] else 0, "added_date": row[10] if row[10] else '', "post_date": row[11] if row[11] else '', @@ -718,11 +721,14 @@ async def delete_review_file( @handle_exceptions async def get_review_file( request: Request, - file_path: str, + file_path: str = None, token: str = None, + t: str = None, current_user: Dict = Depends(get_current_user_media) ): """Serve a file from the review queue.""" + if t: + file_path = decode_path(t) requested_file = Path(file_path) try: diff --git a/web/backend/routers/semantic.py b/web/backend/routers/semantic.py index 9bb43db..814a713 100644 --- a/web/backend/routers/semantic.py +++ b/web/backend/routers/semantic.py @@ -19,6 +19,7 @@ from slowapi.util import get_remote_address from ..core.dependencies import get_current_user, require_admin, get_app_state from ..core.exceptions import handle_exceptions, ValidationError +from ..core.path_tokens import encode_path from modules.semantic_search import get_semantic_search from modules.universal_logger import get_logger @@ -93,6 +94,9 @@ async def semantic_search( source=source, threshold=threshold ) + for r in results: + fp = r.get('file_path') + r['file_token'] = encode_path(fp) if fp else None return {"results": results, "count": len(results), "query": query} @@ -118,6 +122,9 @@ async def find_similar_files( source=source, threshold=threshold ) + for r in results: + fp = r.get('file_path') + r['file_token'] = encode_path(fp) if fp else None return {"results": results, "count": len(results), "source_file_id": file_id} diff --git a/web/frontend/src/components/EnhancedLightbox.tsx b/web/frontend/src/components/EnhancedLightbox.tsx index f3c8ee5..94b0cf5 100644 --- a/web/frontend/src/components/EnhancedLightbox.tsx +++ b/web/frontend/src/components/EnhancedLightbox.tsx @@ -145,7 +145,9 @@ export default function EnhancedLightbox({ setEmbeddedMetadataLoading(true) try { const response = await fetch( - `/api/media/embedded-metadata?file_path=${encodeURIComponent(currentItem.file_path)}`, + currentItem.file_token + ? `/api/media/embedded-metadata?t=${encodeURIComponent(currentItem.file_token)}` + : `/api/media/embedded-metadata?file_path=${encodeURIComponent(currentItem.file_path)}`, { credentials: 'include' } ) if (response.ok) { diff --git a/web/frontend/src/components/GalleryLightbox.tsx b/web/frontend/src/components/GalleryLightbox.tsx index b877be5..cc92775 100644 --- a/web/frontend/src/components/GalleryLightbox.tsx +++ b/web/frontend/src/components/GalleryLightbox.tsx @@ -153,7 +153,9 @@ export default function GalleryLightbox({ setEmbeddedMetadataLoading(true) try { const response = await fetch( - `/api/media/embedded-metadata?file_path=${encodeURIComponent(currentItem.file_path)}`, + currentItem.file_token + ? `/api/media/embedded-metadata?t=${encodeURIComponent(currentItem.file_token)}` + : `/api/media/embedded-metadata?file_path=${encodeURIComponent(currentItem.file_path)}`, { credentials: 'include' } ) if (response.ok) { @@ -532,11 +534,15 @@ export default function GalleryLightbox({ // URL helpers const getPreviewUrl = (item: MediaGalleryItem) => - `/api/media/preview?file_path=${encodeURIComponent(item.file_path)}` + item.file_token + ? `/api/media/preview?t=${encodeURIComponent(item.file_token)}` + : `/api/media/preview?file_path=${encodeURIComponent(item.file_path)}` const getThumbnailUrl = (item: MediaGalleryItem) => { const mediaType = isVideoFile(item) ? 'video' : 'image' - return `/api/media/thumbnail?file_path=${encodeURIComponent(item.file_path)}&media_type=${mediaType}` + return item.file_token + ? `/api/media/thumbnail?t=${encodeURIComponent(item.file_token)}&media_type=${mediaType}` + : `/api/media/thumbnail?file_path=${encodeURIComponent(item.file_path)}&media_type=${mediaType}` } // Actions diff --git a/web/frontend/src/components/paid-content/BundleLightbox.tsx b/web/frontend/src/components/paid-content/BundleLightbox.tsx index 5b70977..ded026e 100644 --- a/web/frontend/src/components/paid-content/BundleLightbox.tsx +++ b/web/frontend/src/components/paid-content/BundleLightbox.tsx @@ -669,7 +669,7 @@ export default function BundleLightbox({ // URL helpers const getPreviewUrl = (item: PaidContentAttachment) => - item.local_path ? `/api/paid-content/files/serve?path=${encodeURIComponent(item.local_path)}` : '' + item.local_path ? (item.file_token ? `/api/paid-content/files/serve?t=${encodeURIComponent(item.file_token)}` : `/api/paid-content/files/serve?path=${encodeURIComponent(item.local_path)}`) : '' const getThumbnailUrl = (item: PaidContentAttachment) => item.id ? `/api/paid-content/files/thumbnail/${item.id}?size=medium&${item.file_hash ? `v=${item.file_hash.slice(0, 8)}` : THUMB_CACHE_V}` : getPreviewUrl(item) diff --git a/web/frontend/src/components/paid-content/PostDetailView.tsx b/web/frontend/src/components/paid-content/PostDetailView.tsx index 708c461..553437c 100644 --- a/web/frontend/src/components/paid-content/PostDetailView.tsx +++ b/web/frontend/src/components/paid-content/PostDetailView.tsx @@ -133,8 +133,8 @@ function PostDetailView({ highlighted = false, }: PostDetailViewProps) { // Default URL generators for paid content - const getVideoUrl = customGetVideoUrl || ((att: { id: number; local_path?: string | null }) => - att.local_path ? `/api/paid-content/files/serve?path=${encodeURIComponent(att.local_path)}` : null + const getVideoUrl = customGetVideoUrl || ((att: { id: number; local_path?: string | null; file_token?: string | null }) => + att.local_path ? (att.file_token ? `/api/paid-content/files/serve?t=${encodeURIComponent(att.file_token)}` : `/api/paid-content/files/serve?path=${encodeURIComponent(att.local_path)}`) : null ) const getThumbnailUrl = customGetThumbnailUrl || ((att: { id: number; file_hash?: string | null }) => `/api/paid-content/files/thumbnail/${att.id}?size=large&${att.file_hash ? `v=${att.file_hash.slice(0, 8)}` : THUMB_CACHE_V}` diff --git a/web/frontend/src/lib/api.ts b/web/frontend/src/lib/api.ts index b88b95d..2025641 100644 --- a/web/frontend/src/lib/api.ts +++ b/web/frontend/src/lib/api.ts @@ -67,6 +67,7 @@ export interface Download { content_type: string | null filename: string | null file_path: string | null + file_token?: string file_size: number | null download_date: string status: string @@ -356,6 +357,7 @@ export interface MediaGalleryItem { height?: number duration?: number | null video_id?: string | null + file_token?: string } // ============================================================================ @@ -603,6 +605,7 @@ export interface PaidContentAttachment { download_attempts: number downloaded_at: string | null created_at: string | null + file_token?: string } export interface PaidContentEmbed { @@ -809,6 +812,7 @@ export interface ReviewFile { height?: number video_id?: string | null original_path?: string + file_token?: string face_recognition?: { scanned: boolean matched?: boolean @@ -1833,15 +1837,17 @@ class APIClient { }>(`/media/gallery/date-range${qs ? '?' + qs : ''}`).then(r => r.ranges) } - getMediaPreviewUrl(filePath: string) { - // Security: Auth via httpOnly cookie only - no token in URL - // Tokens in URLs are logged in browser history and server logs + getMediaPreviewUrl(filePath: string, fileToken?: string) { + if (fileToken) { + return `${API_BASE}/media/preview?t=${encodeURIComponent(fileToken)}` + } return `${API_BASE}/media/preview?file_path=${encodeURIComponent(filePath)}` } - getMediaThumbnailUrl(filePath: string, mediaType: 'image' | 'video') { - // Security: Auth via httpOnly cookie only - no token in URL - // Tokens in URLs are logged in browser history and server logs + getMediaThumbnailUrl(filePath: string, mediaType: 'image' | 'video', fileToken?: string) { + if (fileToken) { + return `${API_BASE}/media/thumbnail?t=${encodeURIComponent(fileToken)}&media_type=${mediaType}` + } return `${API_BASE}/media/thumbnail?file_path=${encodeURIComponent(filePath)}&media_type=${mediaType}` } @@ -2216,19 +2222,51 @@ class APIClient { }>(`/monitoring/history?${params.toString()}`) } - getReviewThumbnailUrl(filePath: string): string { + getReviewThumbnailUrl(filePath: string, fileToken?: string): string { // Determine media type from file extension const isVideo = filePath.match(/\.(mp4|mov|webm|avi|mkv|flv|m4v)$/i) const mediaType = isVideo ? 'video' : 'image' - // Security: Auth via httpOnly cookie only - no token in URL + if (fileToken) { + return `${API_BASE}/media/thumbnail?t=${encodeURIComponent(fileToken)}&media_type=${mediaType}` + } return `${API_BASE}/media/thumbnail?file_path=${encodeURIComponent(filePath)}&media_type=${mediaType}` } - getReviewPreviewUrl(filePath: string): string { - // Security: Auth via httpOnly cookie only - no token in URL + getReviewPreviewUrl(filePath: string, fileToken?: string): string { + if (fileToken) { + return `${API_BASE}/review/file?t=${encodeURIComponent(fileToken)}` + } return `${API_BASE}/review/file?file_path=${encodeURIComponent(filePath)}` } + getMediaEmbeddedMetadataUrl(filePath: string, fileToken?: string): string { + if (fileToken) { + return `${API_BASE}/media/embedded-metadata?t=${encodeURIComponent(fileToken)}` + } + return `${API_BASE}/media/embedded-metadata?file_path=${encodeURIComponent(filePath)}` + } + + getPaidContentServeUrl(localPath: string, fileToken?: string): string { + if (fileToken) { + return `${API_BASE}/paid-content/files/serve?t=${encodeURIComponent(fileToken)}` + } + return `${API_BASE}/paid-content/files/serve?path=${encodeURIComponent(localPath)}` + } + + getPaidContentThumbnailUrl(filePath: string, fileToken?: string): string { + if (fileToken) { + return `${API_BASE}/paid-content/thumbnail?t=${encodeURIComponent(fileToken)}` + } + return `${API_BASE}/paid-content/thumbnail?file_path=${encodeURIComponent(filePath)}` + } + + getPaidContentPreviewUrl(filePath: string, fileToken?: string): string { + if (fileToken) { + return `${API_BASE}/paid-content/preview?t=${encodeURIComponent(fileToken)}` + } + return `${API_BASE}/paid-content/preview?file_path=${encodeURIComponent(filePath)}` + } + // ============================================================================ // Smart Folders Methods // ============================================================================ @@ -2239,6 +2277,7 @@ class APIClient { count: number previews: Array<{ file_path: string + file_token?: string content_type: string }> }> @@ -2254,6 +2293,7 @@ class APIClient { recent_downloads: Array<{ id: number file_path: string + file_token?: string filename: string platform: string source: string @@ -2265,6 +2305,7 @@ class APIClient { recent_deleted: Array<{ id: number file_path: string + file_token?: string filename: string platform: string source: string @@ -2277,6 +2318,7 @@ class APIClient { recent_moved_to_review: Array<{ id: number file_path: string + file_token?: string filename: string platform: string source: string @@ -2473,6 +2515,7 @@ class APIClient { results: Array<{ id: number file_path: string + file_token?: string filename: string platform: string source: string diff --git a/web/frontend/src/pages/Dashboard.tsx b/web/frontend/src/pages/Dashboard.tsx index af5d0bf..01c0b6b 100644 --- a/web/frontend/src/pages/Dashboard.tsx +++ b/web/frontend/src/pages/Dashboard.tsx @@ -2129,7 +2129,7 @@ export default function Dashboard() {