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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user