Files
media-downloader/web/backend/routers/dashboard.py
Todd 49e72207bf 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>
2026-03-30 08:25:22 -04:00

311 lines
12 KiB
Python

"""
Dashboard API Router
Provides endpoints for dashboard-specific data like recent items across different locations.
"""
from fastapi import APIRouter, Depends, Request
from typing import Dict, Any, Optional
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"])
logger = get_logger('API')
limiter = Limiter(key_func=get_remote_address)
@router.get("/recent-items")
@limiter.limit("60/minute")
@handle_exceptions
async def get_recent_items(
request: Request,
limit: int = 20,
since_id: Optional[int] = None,
current_user=Depends(get_current_user)
) -> Dict[str, Any]:
"""
Get NEW items from Media, Review, and Internet Discovery for dashboard cards.
Uses file_inventory.id for ordering since it monotonically increases with
insertion order. download_date from the downloads table is included for
display but not used for ordering (batch downloads can interleave timestamps).
Args:
limit: Max items per category
since_id: Optional file_inventory ID - only return items with id > this value
Returns up to `limit` items from each location, sorted by most recently added first.
"""
app_state = get_app_state()
with app_state.db.get_connection() as conn:
cursor = conn.cursor()
# Media items (location='final')
# ORDER BY fi.id DESC — id is monotonically increasing and reflects insertion order.
# download_date is included for display but NOT used for ordering.
if since_id:
cursor.execute("""
SELECT fi.id, fi.file_path, fi.filename, fi.source, fi.platform, fi.content_type,
fi.file_size, COALESCE(d.download_date, fi.created_date) as added_at,
fi.width, fi.height
FROM file_inventory fi
LEFT JOIN downloads d ON d.filename = fi.filename
WHERE fi.location = 'final'
AND fi.id > ?
AND (fi.moved_from_review IS NULL OR fi.moved_from_review = 0)
AND (fi.from_discovery IS NULL OR fi.from_discovery = 0)
ORDER BY fi.id DESC
LIMIT ?
""", (since_id, limit))
else:
cursor.execute("""
SELECT fi.id, fi.file_path, fi.filename, fi.source, fi.platform, fi.content_type,
fi.file_size, COALESCE(d.download_date, fi.created_date) as added_at,
fi.width, fi.height
FROM file_inventory fi
LEFT JOIN downloads d ON d.filename = fi.filename
WHERE fi.location = 'final'
AND (fi.moved_from_review IS NULL OR fi.moved_from_review = 0)
AND (fi.from_discovery IS NULL OR fi.from_discovery = 0)
ORDER BY fi.id DESC
LIMIT ?
""", (limit,))
media_items = []
for row in cursor.fetchall():
fp = row[1]
media_items.append({
'id': row[0],
'file_path': fp,
'file_token': encode_path(fp) if fp else None,
'filename': row[2],
'source': row[3],
'platform': row[4],
'media_type': row[5],
'file_size': row[6],
'added_at': row[7],
'width': row[8],
'height': row[9]
})
# Get total count for new media items
if since_id:
cursor.execute("""
SELECT COUNT(*)
FROM file_inventory
WHERE location = 'final'
AND id > ?
AND (moved_from_review IS NULL OR moved_from_review = 0)
AND (from_discovery IS NULL OR from_discovery = 0)
""", (since_id,))
else:
cursor.execute("""
SELECT COUNT(*) FROM file_inventory
WHERE location = 'final'
AND (moved_from_review IS NULL OR moved_from_review = 0)
AND (from_discovery IS NULL OR from_discovery = 0)
""")
media_count = cursor.fetchone()[0]
# Review items (location='review')
if since_id:
cursor.execute("""
SELECT f.id, f.file_path, f.filename, f.source, f.platform, f.content_type,
f.file_size, COALESCE(d.download_date, f.created_date) as added_at,
f.width, f.height,
CASE WHEN fr.id IS NOT NULL THEN 1 ELSE 0 END as face_scanned,
fr.has_match as face_matched, fr.confidence as face_confidence, fr.matched_person
FROM file_inventory f
LEFT JOIN downloads d ON d.filename = f.filename
LEFT JOIN face_recognition_scans fr ON f.file_path = fr.file_path
WHERE f.location = 'review'
AND f.id > ?
AND (f.moved_from_media IS NULL OR f.moved_from_media = 0)
ORDER BY f.id DESC
LIMIT ?
""", (since_id, limit))
else:
cursor.execute("""
SELECT f.id, f.file_path, f.filename, f.source, f.platform, f.content_type,
f.file_size, COALESCE(d.download_date, f.created_date) as added_at,
f.width, f.height,
CASE WHEN fr.id IS NOT NULL THEN 1 ELSE 0 END as face_scanned,
fr.has_match as face_matched, fr.confidence as face_confidence, fr.matched_person
FROM file_inventory f
LEFT JOIN downloads d ON d.filename = f.filename
LEFT JOIN face_recognition_scans fr ON f.file_path = fr.file_path
WHERE f.location = 'review'
AND (f.moved_from_media IS NULL OR f.moved_from_media = 0)
ORDER BY f.id DESC
LIMIT ?
""", (limit,))
review_items = []
for row in cursor.fetchall():
face_recognition = None
if row[10]: # face_scanned
face_recognition = {
'scanned': True,
'matched': bool(row[11]) if row[11] is not None else False,
'confidence': row[12],
'matched_person': row[13]
}
fp = row[1]
review_items.append({
'id': row[0],
'file_path': fp,
'file_token': encode_path(fp) if fp else None,
'filename': row[2],
'source': row[3],
'platform': row[4],
'media_type': row[5],
'file_size': row[6],
'added_at': row[7],
'width': row[8],
'height': row[9],
'face_recognition': face_recognition
})
# Get total count for new review items
if since_id:
cursor.execute("""
SELECT COUNT(*)
FROM file_inventory
WHERE location = 'review'
AND id > ?
AND (moved_from_media IS NULL OR moved_from_media = 0)
""", (since_id,))
else:
cursor.execute("""
SELECT COUNT(*) FROM file_inventory
WHERE location = 'review'
AND (moved_from_media IS NULL OR moved_from_media = 0)
""")
review_count = cursor.fetchone()[0]
# Internet Discovery items (celebrity_discovered_videos with status='new')
internet_discovery_items = []
internet_discovery_count = 0
try:
cursor.execute("""
SELECT
v.id,
v.video_id,
v.title,
v.thumbnail,
v.channel_name,
v.platform,
v.duration,
v.max_resolution,
v.status,
v.discovered_at,
v.url,
v.view_count,
v.upload_date,
c.name as celebrity_name
FROM celebrity_discovered_videos v
LEFT JOIN celebrity_profiles c ON v.celebrity_id = c.id
WHERE v.status = 'new'
ORDER BY v.id DESC
LIMIT ?
""", (limit,))
for row in cursor.fetchall():
internet_discovery_items.append({
'id': row[0],
'video_id': row[1],
'title': row[2],
'thumbnail': row[3],
'channel_name': row[4],
'platform': row[5],
'duration': row[6],
'max_resolution': row[7],
'status': row[8],
'discovered_at': row[9],
'url': row[10],
'view_count': row[11],
'upload_date': row[12],
'celebrity_name': row[13]
})
# Get total count for internet discovery
cursor.execute("SELECT COUNT(*) FROM celebrity_discovered_videos WHERE status = 'new'")
internet_discovery_count = cursor.fetchone()[0]
except Exception as e:
# Table might not exist if celebrity feature not used
logger.warning(f"Could not fetch internet discovery items: {e}", module="Dashboard")
return {
'media': {
'count': media_count,
'items': media_items
},
'review': {
'count': review_count,
'items': review_items
},
'internet_discovery': {
'count': internet_discovery_count,
'items': internet_discovery_items
}
}
@router.get("/dismissed-cards")
@limiter.limit("60/minute")
@handle_exceptions
async def get_dismissed_cards(
request: Request,
user=Depends(get_current_user)
) -> Dict[str, Any]:
"""Get the user's dismissed card IDs."""
app_state = get_app_state()
user_id = user.get('username', 'default')
with app_state.db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT preference_value FROM user_preferences
WHERE user_id = ? AND preference_key = 'dashboard_dismissed_cards'
""", (user_id,))
row = cursor.fetchone()
if row and row[0]:
import json
return json.loads(row[0])
return {'media': None, 'review': None, 'internet_discovery': None}
@router.post("/dismissed-cards")
@limiter.limit("30/minute")
@handle_exceptions
async def set_dismissed_cards(
request: Request,
data: Dict[str, Any],
user=Depends(get_current_user)
) -> Dict[str, str]:
"""Save the user's dismissed card IDs."""
import json
app_state = get_app_state()
user_id = user.get('username', 'default')
with app_state.db.get_connection(for_write=True) as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO user_preferences (user_id, preference_key, preference_value, updated_at)
VALUES (?, 'dashboard_dismissed_cards', ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, preference_key) DO UPDATE SET
preference_value = excluded.preference_value,
updated_at = CURRENT_TIMESTAMP
""", (user_id, json.dumps(data)))
conn.commit()
return {'status': 'ok'}