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:
57
web/backend/core/path_tokens.py
Normal file
57
web/backend/core/path_tokens.py
Normal 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")
|
||||
@@ -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'}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
<>
|
||||
|
||||
@@ -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) => (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user