fix: require auth for file downloads, localize atlas search, use flag images
- Block direct access to /uploads/files (401), serve via authenticated /api/trips/:tripId/files/:id/download with JWT verification - Client passes auth token as query parameter for direct links - Atlas country search now uses Intl.DisplayNames (user language) instead of English GeoJSON names - Atlas search results use flagcdn.com flag images instead of emoji
This commit is contained in:
@@ -7,6 +7,12 @@ import { useTranslation } from '../../i18n'
|
||||
import { filesApi } from '../../api/client'
|
||||
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
function authUrl(url: string): string {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (!token || !url || url.includes('token=')) return url
|
||||
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
|
||||
}
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
return mimeType.startsWith('image/')
|
||||
@@ -48,14 +54,14 @@ function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||
>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||
<img
|
||||
src={file.url}
|
||||
src={authUrl(file.url)}
|
||||
alt={file.original_name}
|
||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
||||
<a href={authUrl(file.url)} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
||||
@@ -311,7 +317,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||
const fileUrl = authUrl(file.url)
|
||||
|
||||
return (
|
||||
<div key={file.id} style={{
|
||||
@@ -622,7 +628,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
||||
<a href={authUrl(previewFile.url)} target="_blank" rel="noreferrer"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||
@@ -637,13 +643,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
</div>
|
||||
</div>
|
||||
<object
|
||||
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
||||
data={`${authUrl(previewFile.url)}#view=FitH`}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||
title={previewFile.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
||||
<a href={authUrl(previewFile.url)} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
||||
</p>
|
||||
</object>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
|
||||
function authUrl(url: string): string {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (!token || !url) return url
|
||||
return `${url}${url.includes('?') ? '&' : '?'}token=${token}`
|
||||
}
|
||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
@@ -581,7 +587,7 @@ export default function PlaceInspector({
|
||||
{filesExpanded && placeFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{placeFiles.map(f => (
|
||||
<a key={f.id} href={`/uploads/files/${f.filename}`} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<a key={f.id} href={authUrl(f.url)} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
||||
|
||||
@@ -184,7 +184,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue
|
||||
if (seen.has(a2)) continue
|
||||
seen.add(a2)
|
||||
const label = String(f?.properties?.NAME || f?.properties?.ADMIN || resolveName(a2) || a2)
|
||||
const label = String(resolveName(a2) || f?.properties?.NAME || f?.properties?.ADMIN || a2)
|
||||
opts.push({ code: a2, label })
|
||||
}
|
||||
opts.sort((a, b) => a.label.localeCompare(b.label))
|
||||
@@ -644,7 +644,7 @@ export default function AtlasPage(): React.ReactElement {
|
||||
onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 16 }}>{countryCodeToFlag(r.code)}</span>
|
||||
<img src={`https://flagcdn.com/w40/${r.code.toLowerCase()}.png`} alt={r.code} style={{ width: 28, height: 20, borderRadius: 4, objectFit: 'cover' }} />
|
||||
<span style={{ fontSize: 13, fontWeight: 650, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{r.label}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user