Encrypt file paths in API URLs using Fernet tokens

Raw filesystem paths were exposed in browser URLs, dev tools, and proxy logs.
Now all file-serving endpoints accept an opaque encrypted token (t= param)
derived from the session secret via HKDF, with a 4-hour TTL.

Backend:
- Add core/path_tokens.py with Fernet encrypt/decrypt (HKDF from .session_secret)
- Add file_token to all list/gallery/feed/search responses across 7 routers
- Accept optional t= param on all file-serving endpoints (backward compatible)

Frontend:
- Update 4 URL helpers in api.ts to prefer token when available
- Add 4 new helpers for paid-content/embedded-metadata URLs
- Update all 14 page/component files to pass file_token to URL builders
- Add file_token to all relevant TypeScript interfaces

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Todd
2026-03-30 08:25:22 -04:00
parent 523f91788e
commit 49e72207bf
24 changed files with 295 additions and 65 deletions

View File

@@ -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")

View File

@@ -10,6 +10,7 @@ from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from ..core.dependencies import get_current_user, get_app_state from ..core.dependencies import get_current_user, get_app_state
from ..core.exceptions import handle_exceptions from ..core.exceptions import handle_exceptions
from ..core.path_tokens import encode_path
from modules.universal_logger import get_logger from modules.universal_logger import get_logger
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@@ -76,9 +77,11 @@ async def get_recent_items(
media_items = [] media_items = []
for row in cursor.fetchall(): for row in cursor.fetchall():
fp = row[1]
media_items.append({ media_items.append({
'id': row[0], 'id': row[0],
'file_path': row[1], 'file_path': fp,
'file_token': encode_path(fp) if fp else None,
'filename': row[2], 'filename': row[2],
'source': row[3], 'source': row[3],
'platform': row[4], 'platform': row[4],
@@ -152,9 +155,11 @@ async def get_recent_items(
'matched_person': row[13] 'matched_person': row[13]
} }
fp = row[1]
review_items.append({ review_items.append({
'id': row[0], 'id': row[0],
'file_path': row[1], 'file_path': fp,
'file_token': encode_path(fp) if fp else None,
'filename': row[2], 'filename': row[2],
'source': row[3], 'source': row[3],
'platform': row[4], 'platform': row[4],
@@ -300,5 +305,6 @@ async def set_dismissed_cards(
preference_value = excluded.preference_value, preference_value = excluded.preference_value,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
""", (user_id, json.dumps(data))) """, (user_id, json.dumps(data)))
conn.commit()
return {'status': 'ok'} return {'status': 'ok'}

View File

@@ -20,6 +20,7 @@ from slowapi.util import get_remote_address
from ..core.dependencies import get_current_user, get_app_state from ..core.dependencies import get_current_user, get_app_state
from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError 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 ..core.responses import message_response, id_response, count_response, offset_paginated
from modules.discovery_system import get_discovery_system from modules.discovery_system import get_discovery_system
from modules.universal_logger import get_logger from modules.universal_logger import get_logger
@@ -381,8 +382,10 @@ async def get_smart_folders_stats(
previews = [] previews = []
for row in cursor.fetchall(): for row in cursor.fetchall():
fp = row['file_path']
previews.append({ previews.append({
'file_path': row['file_path'], 'file_path': fp,
'file_token': encode_path(fp) if fp else None,
'content_type': row['content_type'] 'content_type': row['content_type']
}) })
@@ -758,9 +761,11 @@ async def get_recent_activity(
''', (limit,)) ''', (limit,))
for row in cursor.fetchall(): for row in cursor.fetchall():
fp = row['file_path']
activity['recent_downloads'].append({ activity['recent_downloads'].append({
'id': row['id'], 'id': row['id'],
'file_path': row['file_path'], 'file_path': fp,
'file_token': encode_path(fp) if fp else None,
'filename': row['filename'], 'filename': row['filename'],
'platform': row['platform'], 'platform': row['platform'],
'source': row['source'], 'source': row['source'],
@@ -788,9 +793,11 @@ async def get_recent_activity(
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass pass
fp = row['recycle_path']
activity['recent_deleted'].append({ activity['recent_deleted'].append({
'id': row['id'], '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'], 'original_path': row['original_path'],
'filename': row['original_filename'], 'filename': row['original_filename'],
'platform': metadata.get('platform', 'unknown'), 'platform': metadata.get('platform', 'unknown'),
@@ -814,9 +821,11 @@ async def get_recent_activity(
''', (limit,)) ''', (limit,))
for row in cursor.fetchall(): for row in cursor.fetchall():
fp = row['file_path']
activity['recent_moved_to_review'].append({ activity['recent_moved_to_review'].append({
'id': row['id'], 'id': row['id'],
'file_path': row['file_path'], 'file_path': fp,
'file_token': encode_path(fp) if fp else None,
'filename': row['filename'], 'filename': row['filename'],
'platform': row['platform'], 'platform': row['platform'],
'source': row['source'], 'source': row['source'],

View File

@@ -18,6 +18,7 @@ from slowapi import Limiter
from slowapi.util import get_remote_address from slowapi.util import get_remote_address
from ..core.dependencies import get_current_user, get_app_state, require_admin from ..core.dependencies import get_current_user, get_app_state, require_admin
from ..core.path_tokens import encode_path
from ..core.exceptions import ( from ..core.exceptions import (
handle_exceptions, handle_exceptions,
DatabaseError, DatabaseError,
@@ -654,6 +655,7 @@ async def advanced_search_downloads(
"content_type": row[3], "content_type": row[3],
"filename": row[4], "filename": row[4],
"file_path": row[5], "file_path": row[5],
"file_token": encode_path(row[5]) if row[5] else None,
"file_size": row[6], "file_size": row[6],
"download_date": row[7], "download_date": row[7],
"post_date": row[8], "post_date": row[8],

View File

@@ -37,6 +37,7 @@ from ..core.exceptions import (
ValidationError ValidationError
) )
from ..core.responses import now_iso8601 from ..core.responses import now_iso8601
from ..core.path_tokens import encode_path, decode_path
from modules.universal_logger import get_logger from modules.universal_logger import get_logger
from ..core.utils import ( from ..core.utils import (
get_media_dimensions, get_media_dimensions,
@@ -177,6 +178,7 @@ async def get_media_thumbnail(
file_path: str = None, file_path: str = None,
media_type: str = None, media_type: str = None,
token: str = None, token: str = None,
t: str = None,
current_user: Dict = Depends(get_current_user_media) current_user: Dict = Depends(get_current_user_media)
): ):
""" """
@@ -192,7 +194,10 @@ async def get_media_thumbnail(
Args: Args:
file_path: Path to the media file file_path: Path to the media file
media_type: 'image' or 'video' 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) resolved_path = validate_file_path(file_path)
app_state = get_app_state() app_state = get_app_state()
@@ -261,11 +266,14 @@ async def get_media_thumbnail(
@handle_exceptions @handle_exceptions
async def get_media_preview( async def get_media_preview(
request: Request, request: Request,
file_path: str, file_path: str = None,
token: str = None, token: str = None,
t: str = None,
current_user: Dict = Depends(get_current_user_media) current_user: Dict = Depends(get_current_user_media)
): ):
"""Serve a media file for preview.""" """Serve a media file for preview."""
if t:
file_path = decode_path(t)
resolved_path = validate_file_path(file_path) resolved_path = validate_file_path(file_path)
if not resolved_path.exists() or not resolved_path.is_file(): if not resolved_path.exists() or not resolved_path.is_file():
@@ -283,12 +291,17 @@ async def get_media_preview(
@handle_exceptions @handle_exceptions
async def get_media_metadata( async def get_media_metadata(
request: Request, request: Request,
file_path: str, file_path: str = None,
t: str = None,
current_user: Dict = Depends(get_current_user) current_user: Dict = Depends(get_current_user)
): ):
""" """
Get cached metadata for a media file (resolution, duration, etc.). 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) resolved_path = validate_file_path(file_path)
if not resolved_path.exists() or not resolved_path.is_file(): if not resolved_path.exists() or not resolved_path.is_file():
@@ -381,7 +394,8 @@ async def get_media_metadata(
@handle_exceptions @handle_exceptions
async def get_embedded_metadata( async def get_embedded_metadata(
request: Request, request: Request,
file_path: str, file_path: str = None,
t: str = None,
current_user: Dict = Depends(get_current_user) 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). 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) resolved_path = validate_file_path(file_path)
if not resolved_path.exists() or not resolved_path.is_file(): 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 'scan_date': row['face_scan_date'] if has_face_data else None
} }
fp = row['file_path']
item = { item = {
"id": row['id'], "id": row['id'],
"platform": row['platform'], "platform": row['platform'],
"source": row['source'] or 'unknown', "source": row['source'] or 'unknown',
"filename": row['filename'], "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, "file_size": row['file_size'] or 0,
"media_type": row['media_type'] or 'image', "media_type": row['media_type'] or 'image',
"download_date": row['download_date'], "download_date": row['download_date'],

View File

@@ -20,7 +20,7 @@ from threading import Lock
from typing import Dict, List, Optional from typing import Dict, List, Optional
from pathlib import Path 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 fastapi.responses import FileResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from slowapi import Limiter 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.dependencies import get_current_user, get_app_state
from ..core.exceptions import handle_exceptions, NotFoundError, ValidationError 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 ..core.responses import message_response, now_iso8601
from modules.universal_logger import get_logger from modules.universal_logger import get_logger
@@ -2719,6 +2720,12 @@ async def get_feed(
total = db.get_posts_count(**filter_kwargs) total = db.get_posts_count(**filter_kwargs)
total_media = db.get_media_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 { return {
"posts": posts, "posts": posts,
"count": len(posts), "count": len(posts),
@@ -2743,6 +2750,11 @@ async def get_post(
if not post: if not post:
raise NotFoundError(f"Post {post_id} not found") 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 # Mark as viewed
db.mark_post_viewed(post_id) db.mark_post_viewed(post_id)
@@ -3194,6 +3206,11 @@ async def get_notifications(
db = _get_db_adapter() db = _get_db_adapter()
notifications = db.get_notifications(unread_only=unread_only, limit=limit, offset=offset) notifications = db.get_notifications(unread_only=unread_only, limit=limit, offset=offset)
unread_count = db.get_unread_notification_count() 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} return {"notifications": notifications, "count": len(notifications), "unread_count": unread_count}
@@ -4220,10 +4237,15 @@ async def _download_post_background(post_id: int):
@handle_exceptions @handle_exceptions
async def serve_file( async def serve_file(
request: Request, request: Request,
path: str, path: Optional[str] = None,
t: Optional[str] = None,
current_user: Dict = Depends(get_current_user) current_user: Dict = Depends(get_current_user)
): ):
"""Serve a downloaded file with byte-range support for video streaming""" """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) file_path = Path(path)
# Security: ensure path is within allowed directories # Security: ensure path is within allowed directories
@@ -4716,16 +4738,36 @@ async def backfill_thumbnails(
@handle_exceptions @handle_exceptions
async def get_thumbnail_by_path( async def get_thumbnail_by_path(
request: Request, 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)$"), size: str = Query(default="small", regex="^(small|medium|large)$"),
current_user: Dict = Depends(get_current_user) current_user: Dict = Depends(get_current_user)
): ):
"""Get thumbnail for a file by its path (for notifications page)""" """Get thumbnail for a file by its path (for notifications page)"""
from pathlib import Path 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) 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(): if not path.exists():
raise HTTPException(status_code=404, detail="File not found") raise NotFoundError("File not found")
# Determine file type from extension # Determine file type from extension
ext = path.suffix.lower() ext = path.suffix.lower()
@@ -4734,7 +4776,7 @@ async def get_thumbnail_by_path(
elif ext in ['.mp4', '.mov', '.webm', '.avi', '.mkv', '.m4v']: elif ext in ['.mp4', '.mov', '.webm', '.avi', '.mkv', '.m4v']:
file_type = 'video' file_type = 'video'
else: else:
raise HTTPException(status_code=400, detail="Unsupported file type") raise ValidationError("Unsupported file type")
# Size mapping # Size mapping
size_map = {"small": (200, 200), "medium": (400, 400), "large": (800, 800)} size_map = {"small": (200, 200), "medium": (400, 400), "large": (800, 800)}
@@ -4746,7 +4788,7 @@ async def get_thumbnail_by_path(
await scraper.close() await scraper.close()
if not thumbnail_data: if not thumbnail_data:
raise HTTPException(status_code=500, detail="Failed to generate thumbnail") raise ValidationError("Failed to generate thumbnail")
return Response( return Response(
content=thumbnail_data, content=thumbnail_data,
@@ -4760,15 +4802,35 @@ async def get_thumbnail_by_path(
@handle_exceptions @handle_exceptions
async def get_preview_by_path( async def get_preview_by_path(
request: Request, 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) current_user: Dict = Depends(get_current_user)
): ):
"""Serve a file for preview (for notifications lightbox)""" """Serve a file for preview (for notifications lightbox)"""
from pathlib import Path 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) 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(): if not path.exists():
raise HTTPException(status_code=404, detail="File not found") raise NotFoundError("File not found")
# Determine media type from extension # Determine media type from extension
ext = path.suffix.lower() ext = path.suffix.lower()
@@ -6221,6 +6283,10 @@ async def get_gallery_media(
limit=limit, limit=limit,
offset=offset 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 # Only run COUNT on first page — subsequent pages don't need it
total = None total = None
if offset == 0: if offset == 0:

View File

@@ -36,6 +36,7 @@ from ..core.exceptions import (
ValidationError ValidationError
) )
from ..core.responses import now_iso8601 from ..core.responses import now_iso8601
from ..core.path_tokens import encode_path, decode_path
from modules.universal_logger import get_logger from modules.universal_logger import get_logger
from modules.date_utils import DateHandler from modules.date_utils import DateHandler
from ..core.utils import get_media_dimensions, get_media_dimensions_batch from ..core.utils import get_media_dimensions, get_media_dimensions_batch
@@ -244,9 +245,11 @@ async def get_review_queue(
else: else:
width, height = dimensions_cache.get(row[1], (row[7], row[8])) width, height = dimensions_cache.get(row[1], (row[7], row[8]))
fp = row[1]
file_item = { file_item = {
"filename": row[2], "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, "file_size": row[6] if row[6] else 0,
"added_date": row[10] if row[10] else '', "added_date": row[10] if row[10] else '',
"post_date": row[11] if row[11] else '', "post_date": row[11] if row[11] else '',
@@ -718,11 +721,14 @@ async def delete_review_file(
@handle_exceptions @handle_exceptions
async def get_review_file( async def get_review_file(
request: Request, request: Request,
file_path: str, file_path: str = None,
token: str = None, token: str = None,
t: str = None,
current_user: Dict = Depends(get_current_user_media) current_user: Dict = Depends(get_current_user_media)
): ):
"""Serve a file from the review queue.""" """Serve a file from the review queue."""
if t:
file_path = decode_path(t)
requested_file = Path(file_path) requested_file = Path(file_path)
try: try:

View File

@@ -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.dependencies import get_current_user, require_admin, get_app_state
from ..core.exceptions import handle_exceptions, ValidationError from ..core.exceptions import handle_exceptions, ValidationError
from ..core.path_tokens import encode_path
from modules.semantic_search import get_semantic_search from modules.semantic_search import get_semantic_search
from modules.universal_logger import get_logger from modules.universal_logger import get_logger
@@ -93,6 +94,9 @@ async def semantic_search(
source=source, source=source,
threshold=threshold 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} return {"results": results, "count": len(results), "query": query}
@@ -118,6 +122,9 @@ async def find_similar_files(
source=source, source=source,
threshold=threshold 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} return {"results": results, "count": len(results), "source_file_id": file_id}

View File

@@ -145,7 +145,9 @@ export default function EnhancedLightbox({
setEmbeddedMetadataLoading(true) setEmbeddedMetadataLoading(true)
try { try {
const response = await fetch( 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' } { credentials: 'include' }
) )
if (response.ok) { if (response.ok) {

View File

@@ -153,7 +153,9 @@ export default function GalleryLightbox({
setEmbeddedMetadataLoading(true) setEmbeddedMetadataLoading(true)
try { try {
const response = await fetch( 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' } { credentials: 'include' }
) )
if (response.ok) { if (response.ok) {
@@ -532,11 +534,15 @@ export default function GalleryLightbox({
// URL helpers // URL helpers
const getPreviewUrl = (item: MediaGalleryItem) => 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 getThumbnailUrl = (item: MediaGalleryItem) => {
const mediaType = isVideoFile(item) ? 'video' : 'image' 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 // Actions

View File

@@ -669,7 +669,7 @@ export default function BundleLightbox({
// URL helpers // URL helpers
const getPreviewUrl = (item: PaidContentAttachment) => 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) => 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) 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)

View File

@@ -133,8 +133,8 @@ function PostDetailView({
highlighted = false, highlighted = false,
}: PostDetailViewProps) { }: PostDetailViewProps) {
// Default URL generators for paid content // Default URL generators for paid content
const getVideoUrl = customGetVideoUrl || ((att: { id: number; local_path?: string | null }) => const getVideoUrl = customGetVideoUrl || ((att: { id: number; local_path?: string | null; file_token?: string | null }) =>
att.local_path ? `/api/paid-content/files/serve?path=${encodeURIComponent(att.local_path)}` : 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 }) => 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}` `/api/paid-content/files/thumbnail/${att.id}?size=large&${att.file_hash ? `v=${att.file_hash.slice(0, 8)}` : THUMB_CACHE_V}`

View File

@@ -67,6 +67,7 @@ export interface Download {
content_type: string | null content_type: string | null
filename: string | null filename: string | null
file_path: string | null file_path: string | null
file_token?: string
file_size: number | null file_size: number | null
download_date: string download_date: string
status: string status: string
@@ -356,6 +357,7 @@ export interface MediaGalleryItem {
height?: number height?: number
duration?: number | null duration?: number | null
video_id?: string | null video_id?: string | null
file_token?: string
} }
// ============================================================================ // ============================================================================
@@ -603,6 +605,7 @@ export interface PaidContentAttachment {
download_attempts: number download_attempts: number
downloaded_at: string | null downloaded_at: string | null
created_at: string | null created_at: string | null
file_token?: string
} }
export interface PaidContentEmbed { export interface PaidContentEmbed {
@@ -809,6 +812,7 @@ export interface ReviewFile {
height?: number height?: number
video_id?: string | null video_id?: string | null
original_path?: string original_path?: string
file_token?: string
face_recognition?: { face_recognition?: {
scanned: boolean scanned: boolean
matched?: boolean matched?: boolean
@@ -1833,15 +1837,17 @@ class APIClient {
}>(`/media/gallery/date-range${qs ? '?' + qs : ''}`).then(r => r.ranges) }>(`/media/gallery/date-range${qs ? '?' + qs : ''}`).then(r => r.ranges)
} }
getMediaPreviewUrl(filePath: string) { getMediaPreviewUrl(filePath: string, fileToken?: string) {
// Security: Auth via httpOnly cookie only - no token in URL if (fileToken) {
// Tokens in URLs are logged in browser history and server logs return `${API_BASE}/media/preview?t=${encodeURIComponent(fileToken)}`
}
return `${API_BASE}/media/preview?file_path=${encodeURIComponent(filePath)}` return `${API_BASE}/media/preview?file_path=${encodeURIComponent(filePath)}`
} }
getMediaThumbnailUrl(filePath: string, mediaType: 'image' | 'video') { getMediaThumbnailUrl(filePath: string, mediaType: 'image' | 'video', fileToken?: string) {
// Security: Auth via httpOnly cookie only - no token in URL if (fileToken) {
// Tokens in URLs are logged in browser history and server logs return `${API_BASE}/media/thumbnail?t=${encodeURIComponent(fileToken)}&media_type=${mediaType}`
}
return `${API_BASE}/media/thumbnail?file_path=${encodeURIComponent(filePath)}&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()}`) }>(`/monitoring/history?${params.toString()}`)
} }
getReviewThumbnailUrl(filePath: string): string { getReviewThumbnailUrl(filePath: string, fileToken?: string): string {
// Determine media type from file extension // Determine media type from file extension
const isVideo = filePath.match(/\.(mp4|mov|webm|avi|mkv|flv|m4v)$/i) const isVideo = filePath.match(/\.(mp4|mov|webm|avi|mkv|flv|m4v)$/i)
const mediaType = isVideo ? 'video' : 'image' 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}` return `${API_BASE}/media/thumbnail?file_path=${encodeURIComponent(filePath)}&media_type=${mediaType}`
} }
getReviewPreviewUrl(filePath: string): string { getReviewPreviewUrl(filePath: string, fileToken?: string): string {
// Security: Auth via httpOnly cookie only - no token in URL if (fileToken) {
return `${API_BASE}/review/file?t=${encodeURIComponent(fileToken)}`
}
return `${API_BASE}/review/file?file_path=${encodeURIComponent(filePath)}` 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 // Smart Folders Methods
// ============================================================================ // ============================================================================
@@ -2239,6 +2277,7 @@ class APIClient {
count: number count: number
previews: Array<{ previews: Array<{
file_path: string file_path: string
file_token?: string
content_type: string content_type: string
}> }>
}> }>
@@ -2254,6 +2293,7 @@ class APIClient {
recent_downloads: Array<{ recent_downloads: Array<{
id: number id: number
file_path: string file_path: string
file_token?: string
filename: string filename: string
platform: string platform: string
source: string source: string
@@ -2265,6 +2305,7 @@ class APIClient {
recent_deleted: Array<{ recent_deleted: Array<{
id: number id: number
file_path: string file_path: string
file_token?: string
filename: string filename: string
platform: string platform: string
source: string source: string
@@ -2277,6 +2318,7 @@ class APIClient {
recent_moved_to_review: Array<{ recent_moved_to_review: Array<{
id: number id: number
file_path: string file_path: string
file_token?: string
filename: string filename: string
platform: string platform: string
source: string source: string
@@ -2473,6 +2515,7 @@ class APIClient {
results: Array<{ results: Array<{
id: number id: number
file_path: string file_path: string
file_token?: string
filename: string filename: string
platform: string platform: string
source: string source: string

View File

@@ -2129,7 +2129,7 @@ export default function Dashboard() {
<div className="flex items-center space-x-3 min-w-0 flex-1"> <div className="flex items-center space-x-3 min-w-0 flex-1">
{download.file_path ? ( {download.file_path ? (
<img <img
src={api.getMediaThumbnailUrl(download.file_path, mediaType)} src={api.getMediaThumbnailUrl(download.file_path, mediaType, download.file_token)}
alt={download.filename || ''} alt={download.filename || ''}
className="w-12 h-12 object-cover rounded-lg flex-shrink-0 cursor-pointer ring-1 ring-border transition-all duration-200 hover:ring-2 hover:ring-primary hover:scale-105" className="w-12 h-12 object-cover rounded-lg flex-shrink-0 cursor-pointer ring-1 ring-border transition-all duration-200 hover:ring-2 hover:ring-primary hover:scale-105"
loading="lazy" loading="lazy"
@@ -2449,8 +2449,8 @@ export default function Dashboard() {
onNavigate={(index) => { setSelectedMedia(recentDownloads[index]) }} onNavigate={(index) => { setSelectedMedia(recentDownloads[index]) }}
onDelete={handleDelete} onDelete={handleDelete}
onEditDate={handleSingleChangeDate} onEditDate={handleSingleChangeDate}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path)} getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path, item.file_token)}
getThumbnailUrl={(item) => api.getMediaThumbnailUrl(item.file_path, isVideoFile(item.filename) ? 'video' : 'image')} getThumbnailUrl={(item) => api.getMediaThumbnailUrl(item.file_path, isVideoFile(item.filename) ? 'video' : 'image', item.file_token)}
isVideo={(item) => isVideoFile(item.filename)} isVideo={(item) => isVideoFile(item.filename)}
renderActions={(item) => ( renderActions={(item) => (
<> <>

View File

@@ -206,7 +206,7 @@ export default function Discovery() {
// Media action mutations // Media action mutations
const moveToReviewMutation = useMutation({ const moveToReviewMutation = useMutation({
mutationFn: async (filePath: string) => { mutationFn: async (filePath: string) => {
return api.post('/media/move-to-review', { file_path: filePath }) return api.post('/media/move-to-review', { file_paths: [filePath] })
}, },
onSuccess: (_, filePath) => { onSuccess: (_, filePath) => {
notificationManager.success('Moved to Review', 'File moved to review queue') notificationManager.success('Moved to Review', 'File moved to review queue')
@@ -637,7 +637,7 @@ export default function Discovery() {
> >
{/* Thumbnail */} {/* Thumbnail */}
<ThrottledImage <ThrottledImage
src={api.getMediaThumbnailUrl(result.file_path, isVideo ? 'video' : 'image')} src={api.getMediaThumbnailUrl(result.file_path, isVideo ? 'video' : 'image', result.file_token)}
alt={result.filename} alt={result.filename}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@@ -812,7 +812,7 @@ export default function Discovery() {
{previews.map((preview, idx) => ( {previews.map((preview, idx) => (
<div key={idx} className="aspect-square rounded overflow-hidden bg-slate-100 dark:bg-slate-800"> <div key={idx} className="aspect-square rounded overflow-hidden bg-slate-100 dark:bg-slate-800">
<ThrottledImage <ThrottledImage
src={api.getMediaThumbnailUrl(preview.file_path, preview.content_type === 'video' ? 'video' : 'image')} src={api.getMediaThumbnailUrl(preview.file_path, preview.content_type === 'video' ? 'video' : 'image', preview.file_token)}
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@@ -1060,7 +1060,7 @@ export default function Discovery() {
> >
<div className="w-10 h-10 rounded bg-slate-100 dark:bg-slate-700 overflow-hidden flex-shrink-0"> <div className="w-10 h-10 rounded bg-slate-100 dark:bg-slate-700 overflow-hidden flex-shrink-0">
<ThrottledImage <ThrottledImage
src={api.getMediaThumbnailUrl(item.file_path, item.content_type === 'video' ? 'video' : 'image')} src={api.getMediaThumbnailUrl(item.file_path, item.content_type === 'video' ? 'video' : 'image', item.file_token)}
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@@ -1100,7 +1100,7 @@ export default function Discovery() {
> >
<div className="w-10 h-10 rounded bg-slate-100 dark:bg-slate-700 overflow-hidden flex-shrink-0"> <div className="w-10 h-10 rounded bg-slate-100 dark:bg-slate-700 overflow-hidden flex-shrink-0">
<ThrottledImage <ThrottledImage
src={api.getMediaThumbnailUrl(item.file_path, item.content_type === 'video' ? 'video' : 'image')} src={api.getMediaThumbnailUrl(item.file_path, item.content_type === 'video' ? 'video' : 'image', item.file_token)}
alt="" alt=""
className="w-full h-full object-cover opacity-60" className="w-full h-full object-cover opacity-60"
/> />
@@ -1148,7 +1148,7 @@ export default function Discovery() {
title={item.filename} title={item.filename}
> >
<ThrottledImage <ThrottledImage
src={api.getMediaThumbnailUrl(item.file_path, item.content_type === 'video' ? 'video' : 'image')} src={api.getMediaThumbnailUrl(item.file_path, item.content_type === 'video' ? 'video' : 'image', item.file_token)}
alt="" alt=""
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
@@ -1414,6 +1414,7 @@ export default function Discovery() {
<EnhancedLightbox <EnhancedLightbox
items={searchResults.map(r => ({ items={searchResults.map(r => ({
file_path: r.file_path, file_path: r.file_path,
file_token: r.file_token,
filename: r.filename, filename: r.filename,
platform: r.platform, platform: r.platform,
source: r.source, source: r.source,
@@ -1427,8 +1428,8 @@ export default function Discovery() {
onClose={() => setLightboxIndex(-1)} onClose={() => setLightboxIndex(-1)}
onNavigate={(index) => setLightboxIndex(index)} onNavigate={(index) => setLightboxIndex(index)}
onDelete={handleDelete} onDelete={handleDelete}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path)} getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path, item.file_token)}
getThumbnailUrl={(item) => api.getMediaThumbnailUrl(item.file_path, item.media_type as 'image' | 'video')} getThumbnailUrl={(item) => api.getMediaThumbnailUrl(item.file_path, item.media_type as 'image' | 'video', item.file_token)}
isVideo={(item) => item.content_type === 'video' || isVideoFile(item.filename)} isVideo={(item) => item.content_type === 'video' || isVideoFile(item.filename)}
renderActions={(item: any) => ( renderActions={(item: any) => (
<> <>

View File

@@ -28,6 +28,7 @@ interface MediaFile {
deleted_from: string | null deleted_from: string | null
location_type?: 'media' | 'review' | 'recycle' location_type?: 'media' | 'review' | 'recycle'
video_id?: string | null video_id?: string | null
file_token?: string
} }
interface DayGroup { interface DayGroup {
@@ -507,7 +508,7 @@ export default function Downloads() {
if (media.platform === 'youtube' && media.video_id) { if (media.platform === 'youtube' && media.video_id) {
return `/api/video/thumbnail/${media.platform}/${media.video_id}?source=downloads` return `/api/video/thumbnail/${media.platform}/${media.video_id}?source=downloads`
} }
return api.getMediaThumbnailUrl(media.file_path, media.media_type) return api.getMediaThumbnailUrl(media.file_path, media.media_type, media.file_token)
} }
// Get preview URL based on location // Get preview URL based on location
@@ -517,7 +518,7 @@ export default function Downloads() {
// Security: Auth via httpOnly cookie only - no token in URL // Security: Auth via httpOnly cookie only - no token in URL
return `/api/recycle/file/${getNumericId(media)}` return `/api/recycle/file/${getNumericId(media)}`
} }
return api.getMediaPreviewUrl(media.file_path) return api.getMediaPreviewUrl(media.file_path, media.file_token)
} }
const openLightbox = async (mediaFiles: MediaFile[], index: number) => { const openLightbox = async (mediaFiles: MediaFile[], index: number) => {
@@ -537,7 +538,8 @@ export default function Downloads() {
} }
} }
} else { } else {
const response = await api.get(`/media/metadata?file_path=${encodeURIComponent(file.file_path)}`) as { width?: number; height?: number; file_size?: number; duration?: number } const metadataUrl = file.file_token ? `/media/metadata?t=${encodeURIComponent(file.file_token)}` : `/media/metadata?file_path=${encodeURIComponent(file.file_path)}`
const response = await api.get(metadataUrl) as { width?: number; height?: number; file_size?: number; duration?: number }
if (response) { if (response) {
return { return {
...file, ...file,

View File

@@ -115,7 +115,7 @@ const JustifiedSection = memo(function JustifiedSection({
{row.items.map(item => { {row.items.map(item => {
const itemWidth = getAspectRatio(item) * row.height const itemWidth = getAspectRatio(item) * row.height
const isVideo = isVideoItem(item) const isVideo = isVideoItem(item)
const thumbUrl = api.getMediaThumbnailUrl(item.file_path, isVideo ? 'video' : 'image') const thumbUrl = api.getMediaThumbnailUrl(item.file_path, isVideo ? 'video' : 'image', item.file_token)
return ( return (
<button <button
key={item.id} key={item.id}

View File

@@ -46,7 +46,7 @@ export default function Media() {
if (media.platform === 'youtube' && media.video_id) { if (media.platform === 'youtube' && media.video_id) {
return `/api/video/thumbnail/${media.platform}/${media.video_id}?source=downloads` return `/api/video/thumbnail/${media.platform}/${media.video_id}?source=downloads`
} }
return api.getMediaThumbnailUrl(media.file_path, (media.media_type as 'image' | 'video') || 'image') return api.getMediaThumbnailUrl(media.file_path, (media.media_type as 'image' | 'video') || 'image', media.file_token)
} }
const [, setMediaResolution] = useState<string>('') const [, setMediaResolution] = useState<string>('')
@@ -1022,7 +1022,7 @@ export default function Media() {
onNavigate={(index) => { setSelectedMedia(filteredMedia[index]); setMediaResolution('') }} onNavigate={(index) => { setSelectedMedia(filteredMedia[index]); setMediaResolution('') }}
onDelete={handleDelete} onDelete={handleDelete}
onEditDate={handleSingleChangeDate} onEditDate={handleSingleChangeDate}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path)} getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path, item.file_token)}
getThumbnailUrl={(item: MediaGalleryItem) => getMediaThumbnailUrl(item)} getThumbnailUrl={(item: MediaGalleryItem) => getMediaThumbnailUrl(item)}
isVideo={(item) => isVideoFile(item)} isVideo={(item) => isVideoFile(item)}
hideFaceRecognition={true} hideFaceRecognition={true}

View File

@@ -22,6 +22,7 @@ interface MediaFile {
source?: string source?: string
download_date?: string download_date?: string
added_date?: string added_date?: string
file_token?: string
} }
interface Notification { interface Notification {
@@ -356,7 +357,7 @@ export default function Notifications() {
} }
// Use regular thumbnail endpoint with current_path if file was moved // Use regular thumbnail endpoint with current_path if file was moved
return api.getMediaThumbnailUrl(statusInfo?.current_path || media.file_path, media.media_type) return api.getMediaThumbnailUrl(statusInfo?.current_path || media.file_path, media.media_type, media.file_token)
} }
// Helper to get preview URL based on file status // Helper to get preview URL based on file status
@@ -370,7 +371,7 @@ export default function Notifications() {
} }
// Use regular preview endpoint with current_path if file was moved // Use regular preview endpoint with current_path if file was moved
return api.getMediaPreviewUrl(statusInfo?.current_path || media.file_path) return api.getMediaPreviewUrl(statusInfo?.current_path || media.file_path, media.file_token)
} }
const openLightbox = async (mediaFiles: MediaFile[], index: number, notification: Notification) => { const openLightbox = async (mediaFiles: MediaFile[], index: number, notification: Notification) => {
@@ -413,7 +414,8 @@ export default function Notifications() {
} else { } else {
// For regular/review items, use media metadata endpoint // For regular/review items, use media metadata endpoint
const currentPath = statusInfo?.current_path || file.file_path const currentPath = statusInfo?.current_path || file.file_path
const response = await api.get(`/media/metadata?file_path=${encodeURIComponent(currentPath)}`) as any const metadataUrl = file.file_token ? `/media/metadata?t=${encodeURIComponent(file.file_token)}` : `/media/metadata?file_path=${encodeURIComponent(currentPath)}`
const response = await api.get(metadataUrl) as any
if (response) { if (response) {
return { return {
...file, ...file,

View File

@@ -19,7 +19,7 @@ function getReviewThumbnailUrl(file: ReviewFile): string {
if (file.platform === 'youtube' && file.video_id) { if (file.platform === 'youtube' && file.video_id) {
return `/api/video/thumbnail/${file.platform}/${file.video_id}?source=downloads` return `/api/video/thumbnail/${file.platform}/${file.video_id}?source=downloads`
} }
return api.getReviewThumbnailUrl(file.file_path) return api.getReviewThumbnailUrl(file.file_path, file.file_token)
} }
export default function Review() { export default function Review() {
@@ -1147,7 +1147,7 @@ export default function Review() {
onNavigate={(index) => setSelectedImage(filteredFiles[index])} onNavigate={(index) => setSelectedImage(filteredFiles[index])}
onDelete={handleDelete} onDelete={handleDelete}
onEditDate={handleSingleChangeDate} onEditDate={handleSingleChangeDate}
getPreviewUrl={(item) => api.getReviewPreviewUrl(item.file_path)} getPreviewUrl={(item) => api.getReviewPreviewUrl(item.file_path, item.file_token)}
getThumbnailUrl={(item: ReviewFile) => getReviewThumbnailUrl(item)} getThumbnailUrl={(item: ReviewFile) => getReviewThumbnailUrl(item)}
isVideo={(item) => isVideoFile(item.filename)} isVideo={(item) => isVideoFile(item.filename)}
renderActions={(item) => ( renderActions={(item) => (

View File

@@ -1553,8 +1553,8 @@ export default function VideoDownloader() {
handleDeleteVideo(historyItem) handleDeleteVideo(historyItem)
} }
}} }}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path)} getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path, item.file_token)}
getThumbnailUrl={(item) => api.getMediaThumbnailUrl(item.file_path, item.media_type === 'video' ? 'video' : 'image')} getThumbnailUrl={(item) => api.getMediaThumbnailUrl(item.file_path, item.media_type === 'video' ? 'video' : 'image', item.file_token)}
isVideo={(item) => item.media_type === 'video'} isVideo={(item) => item.media_type === 'video'}
renderActions={(item) => ( renderActions={(item) => (
<button <button

View File

@@ -61,7 +61,7 @@ function AttachmentThumbnail({
const isVideo = attachment.file_type === 'video' const isVideo = attachment.file_type === 'video'
const videoUrl = isVideo && attachment.local_path const videoUrl = isVideo && attachment.local_path
? `/api/paid-content/files/serve?path=${encodeURIComponent(attachment.local_path)}` ? attachment.file_token ? `/api/paid-content/files/serve?t=${encodeURIComponent(attachment.file_token)}` : `/api/paid-content/files/serve?path=${encodeURIComponent(attachment.local_path)}`
: null : null
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {

View File

@@ -150,7 +150,7 @@ function AttachmentThumbnail({
const isImage = attachment.file_type === 'image' || isPF const isImage = attachment.file_type === 'image' || isPF
const isVideo = attachment.file_type === 'video' && !isPF const isVideo = attachment.file_type === 'video' && !isPF
const fileUrl = attachment.local_path const fileUrl = attachment.local_path
? `/api/paid-content/files/serve?path=${encodeURIComponent(attachment.local_path)}` ? attachment.file_token ? `/api/paid-content/files/serve?t=${encodeURIComponent(attachment.file_token)}` : `/api/paid-content/files/serve?path=${encodeURIComponent(attachment.local_path)}`
: null : null
const isMissing = attachment.status === 'failed' || attachment.status === 'pending' const isMissing = attachment.status === 'failed' || attachment.status === 'pending'
@@ -938,7 +938,7 @@ function PostCard({
> >
{completedAttachments.filter(a => a.file_type === 'audio').map((audio) => { {completedAttachments.filter(a => a.file_type === 'audio').map((audio) => {
const audioUrl = audio.local_path const audioUrl = audio.local_path
? `/api/paid-content/files/serve?path=${encodeURIComponent(audio.local_path)}` ? audio.file_token ? `/api/paid-content/files/serve?t=${encodeURIComponent(audio.file_token)}` : `/api/paid-content/files/serve?path=${encodeURIComponent(audio.local_path)}`
: null : null
const fileSizeMB = audio.file_size ? (audio.file_size / 1024 / 1024).toFixed(1) : null const fileSizeMB = audio.file_size ? (audio.file_size / 1024 / 1024).toFixed(1) : null
return ( return (

View File

@@ -38,6 +38,7 @@ interface MediaFile {
duration?: number duration?: number
attachment_id?: number attachment_id?: number
downloaded_at?: string downloaded_at?: string
file_token?: string
} }
// Create a minimal post object from notification data for BundleLightbox // Create a minimal post object from notification data for BundleLightbox
@@ -182,7 +183,9 @@ export default function PaidContentNotifications() {
if (media.attachment_id) { if (media.attachment_id) {
return `/api/paid-content/files/thumbnail/${media.attachment_id}?size=large&${THUMB_CACHE_V}` return `/api/paid-content/files/thumbnail/${media.attachment_id}?size=large&${THUMB_CACHE_V}`
} }
return `/api/paid-content/thumbnail?file_path=${encodeURIComponent(media.file_path)}` return media.file_token
? `/api/paid-content/thumbnail?t=${encodeURIComponent(media.file_token)}`
: `/api/paid-content/thumbnail?file_path=${encodeURIComponent(media.file_path)}`
} }
const openLightbox = (notification: PaidContentNotification, mediaFiles: MediaFile[], index: number) => { const openLightbox = (notification: PaidContentNotification, mediaFiles: MediaFile[], index: number) => {