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

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

View File

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

View File

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

View File

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

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

View File

@@ -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) => (
<>

View File

@@ -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) => (
<>

View File

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

View 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}

View File

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

View File

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

View 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) => (

View File

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

View File

@@ -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(() => {

View File

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

View File

@@ -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) => {