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

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