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

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