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 ..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'}

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.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'],

View File

@@ -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],

View File

@@ -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'],

View File

@@ -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:

View File

@@ -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:

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.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}

View File

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

View File

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

View File

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

View File

@@ -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}`

View File

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

View File

@@ -2129,7 +2129,7 @@ export default function Dashboard() {
<div className="flex items-center space-x-3 min-w-0 flex-1">
{download.file_path ? (
<img
src={api.getMediaThumbnailUrl(download.file_path, mediaType)}
src={api.getMediaThumbnailUrl(download.file_path, mediaType, download.file_token)}
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"
loading="lazy"
@@ -2449,8 +2449,8 @@ export default function Dashboard() {
onNavigate={(index) => { setSelectedMedia(recentDownloads[index]) }}
onDelete={handleDelete}
onEditDate={handleSingleChangeDate}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path)}
getThumbnailUrl={(item) => api.getMediaThumbnailUrl(item.file_path, isVideoFile(item.filename) ? 'video' : 'image')}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path, item.file_token)}
getThumbnailUrl={(item) => api.getMediaThumbnailUrl(item.file_path, isVideoFile(item.filename) ? 'video' : 'image', item.file_token)}
isVideo={(item) => isVideoFile(item.filename)}
renderActions={(item) => (
<>

View File

@@ -206,7 +206,7 @@ export default function Discovery() {
// Media action mutations
const moveToReviewMutation = useMutation({
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) => {
notificationManager.success('Moved to Review', 'File moved to review queue')
@@ -637,7 +637,7 @@ export default function Discovery() {
>
{/* Thumbnail */}
<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}
className="w-full h-full object-cover"
/>
@@ -812,7 +812,7 @@ export default function Discovery() {
{previews.map((preview, idx) => (
<div key={idx} className="aspect-square rounded overflow-hidden bg-slate-100 dark:bg-slate-800">
<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=""
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">
<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=""
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">
<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=""
className="w-full h-full object-cover opacity-60"
/>
@@ -1148,7 +1148,7 @@ export default function Discovery() {
title={item.filename}
>
<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=""
className="w-full h-full object-cover"
/>
@@ -1414,6 +1414,7 @@ export default function Discovery() {
<EnhancedLightbox
items={searchResults.map(r => ({
file_path: r.file_path,
file_token: r.file_token,
filename: r.filename,
platform: r.platform,
source: r.source,
@@ -1427,8 +1428,8 @@ export default function Discovery() {
onClose={() => setLightboxIndex(-1)}
onNavigate={(index) => setLightboxIndex(index)}
onDelete={handleDelete}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path)}
getThumbnailUrl={(item) => api.getMediaThumbnailUrl(item.file_path, item.media_type as 'image' | 'video')}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path, item.file_token)}
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)}
renderActions={(item: any) => (
<>

View File

@@ -28,6 +28,7 @@ interface MediaFile {
deleted_from: string | null
location_type?: 'media' | 'review' | 'recycle'
video_id?: string | null
file_token?: string
}
interface DayGroup {
@@ -507,7 +508,7 @@ export default function Downloads() {
if (media.platform === 'youtube' && media.video_id) {
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
@@ -517,7 +518,7 @@ export default function Downloads() {
// Security: Auth via httpOnly cookie only - no token in URL
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) => {
@@ -537,7 +538,8 @@ export default function Downloads() {
}
}
} 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) {
return {
...file,

View File

@@ -115,7 +115,7 @@ const JustifiedSection = memo(function JustifiedSection({
{row.items.map(item => {
const itemWidth = getAspectRatio(item) * row.height
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 (
<button
key={item.id}

View File

@@ -46,7 +46,7 @@ export default function Media() {
if (media.platform === 'youtube' && media.video_id) {
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>('')
@@ -1022,7 +1022,7 @@ export default function Media() {
onNavigate={(index) => { setSelectedMedia(filteredMedia[index]); setMediaResolution('') }}
onDelete={handleDelete}
onEditDate={handleSingleChangeDate}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path)}
getPreviewUrl={(item) => api.getMediaPreviewUrl(item.file_path, item.file_token)}
getThumbnailUrl={(item: MediaGalleryItem) => getMediaThumbnailUrl(item)}
isVideo={(item) => isVideoFile(item)}
hideFaceRecognition={true}

View File

@@ -22,6 +22,7 @@ interface MediaFile {
source?: string
download_date?: string
added_date?: string
file_token?: string
}
interface Notification {
@@ -356,7 +357,7 @@ export default function Notifications() {
}
// 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
@@ -370,7 +371,7 @@ export default function Notifications() {
}
// 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) => {
@@ -413,7 +414,8 @@ export default function Notifications() {
} else {
// For regular/review items, use media metadata endpoint
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) {
return {
...file,

View File

@@ -19,7 +19,7 @@ function getReviewThumbnailUrl(file: ReviewFile): string {
if (file.platform === 'youtube' && file.video_id) {
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() {
@@ -1147,7 +1147,7 @@ export default function Review() {
onNavigate={(index) => setSelectedImage(filteredFiles[index])}
onDelete={handleDelete}
onEditDate={handleSingleChangeDate}
getPreviewUrl={(item) => api.getReviewPreviewUrl(item.file_path)}
getPreviewUrl={(item) => api.getReviewPreviewUrl(item.file_path, item.file_token)}
getThumbnailUrl={(item: ReviewFile) => getReviewThumbnailUrl(item)}
isVideo={(item) => isVideoFile(item.filename)}
renderActions={(item) => (

View File

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

View File

@@ -61,7 +61,7 @@ function AttachmentThumbnail({
const isVideo = attachment.file_type === 'video'
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
const handleMouseEnter = useCallback(() => {

View File

@@ -150,7 +150,7 @@ function AttachmentThumbnail({
const isImage = attachment.file_type === 'image' || isPF
const isVideo = attachment.file_type === 'video' && !isPF
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
const isMissing = attachment.status === 'failed' || attachment.status === 'pending'
@@ -938,7 +938,7 @@ function PostCard({
>
{completedAttachments.filter(a => a.file_type === 'audio').map((audio) => {
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
const fileSizeMB = audio.file_size ? (audio.file_size / 1024 / 1024).toFixed(1) : null
return (

View File

@@ -38,6 +38,7 @@ interface MediaFile {
duration?: number
attachment_id?: number
downloaded_at?: string
file_token?: string
}
// Create a minimal post object from notification data for BundleLightbox
@@ -182,7 +183,9 @@ export default function PaidContentNotifications() {
if (media.attachment_id) {
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) => {