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:
Maurice
2026-03-31 21:38:16 +02:00
parent f7160e6dec
commit 10107ecf31
5 changed files with 74 additions and 22 deletions
+12 -6
View File
@@ -7,6 +7,12 @@ import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client' import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types' 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) { function isImage(mimeType) {
if (!mimeType) return false if (!mimeType) return false
return mimeType.startsWith('image/') 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()}> <div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img <img
src={file.url} src={authUrl(file.url)}
alt={file.original_name} alt={file.original_name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }} 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' }}> <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> <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 }}> <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} /> <ExternalLink size={16} />
</a> </a>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}> <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) if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid) 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 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 ( return (
<div key={file.id} style={{ <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 }}> <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> <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 }}> <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' }} 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)'} onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}> onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
@@ -637,13 +643,13 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div> </div>
</div> </div>
<object <object
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`} data={`${authUrl(previewFile.url)}#view=FitH`}
type="application/pdf" type="application/pdf"
style={{ flex: 1, width: '100%', border: 'none' }} style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name} title={previewFile.original_name}
> >
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}> <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> </p>
</object> </object>
</div> </div>
@@ -1,4 +1,10 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' 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 { 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 PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client' import { mapsApi } from '../../api/client'
@@ -581,7 +587,7 @@ export default function PlaceInspector({
{filesExpanded && placeFiles.length > 0 && ( {filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}> <div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => ( {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" />} {(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> <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>} {f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
+2 -2
View File
@@ -184,7 +184,7 @@ export default function AtlasPage(): React.ReactElement {
if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue if (!a2 || a2 === '-99' || typeof a2 !== 'string' || a2.length !== 2) continue
if (seen.has(a2)) continue if (seen.has(a2)) continue
seen.add(a2) 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.push({ code: a2, label })
} }
opts.sort((a, b) => a.label.localeCompare(b.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' }} onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
> >
<span style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}> <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' }}> <span style={{ fontSize: 13, fontWeight: 650, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{r.label} {r.label}
</span> </span>
+10 -11
View File
@@ -125,24 +125,23 @@ import { authenticate } from './middleware/auth';
app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars'))); app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars')));
app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers'))); app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers')));
// Serve uploaded files (UUIDs are unguessable, path traversal protected) // Serve uploaded photos (public — needed for shared trips)
app.get('/uploads/:type/:filename', (req: Request, res: Response) => { app.get('/uploads/photos/:filename', (req: Request, res: Response) => {
const { type, filename } = req.params; const safeName = path.basename(req.params.filename);
const allowedTypes = ['covers', 'files', 'photos']; const filePath = path.join(__dirname, '../uploads/photos', safeName);
if (!allowedTypes.includes(type)) return res.status(404).send('Not found');
// Prevent path traversal
const safeName = path.basename(filename);
const filePath = path.join(__dirname, '../uploads', type, safeName);
const resolved = path.resolve(filePath); const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(__dirname, '../uploads', type))) { if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) {
return res.status(403).send('Forbidden'); return res.status(403).send('Forbidden');
} }
if (!fs.existsSync(resolved)) return res.status(404).send('Not found'); if (!fs.existsSync(resolved)) return res.status(404).send('Not found');
res.sendFile(resolved); res.sendFile(resolved);
}); });
// Block direct access to /uploads/files — served via authenticated /api/trips/:tripId/files/:id/download
app.use('/uploads/files', (_req: Request, res: Response) => {
res.status(401).send('Authentication required');
});
// Routes // Routes
import authRoutes from './routes/auth'; import authRoutes from './routes/auth';
import tripsRoutes from './routes/trips'; import tripsRoutes from './routes/trips';
+43 -2
View File
@@ -3,6 +3,8 @@ import multer from 'multer';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../config';
import { db, canAccessTrip } from '../db/database'; import { db, canAccessTrip } from '../db/database';
import { authenticate, demoUploadBlock } from '../middleware/auth'; import { authenticate, demoUploadBlock } from '../middleware/auth';
import { requireTripAccess } from '../middleware/tripAccess'; import { requireTripAccess } from '../middleware/tripAccess';
@@ -65,13 +67,52 @@ const FILE_SELECT = `
LEFT JOIN users u ON f.uploaded_by = u.id LEFT JOIN users u ON f.uploaded_by = u.id
`; `;
function formatFile(file: TripFile) { function formatFile(file: TripFile & { trip_id?: number }) {
const tripId = file.trip_id;
return { return {
...file, ...file,
url: file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`, url: `/api/trips/${tripId}/files/${file.id}/download`,
}; };
} }
function getPlaceFiles(tripId: string | number, placeId: number) {
return (db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND place_id = ? AND deleted_at IS NULL ORDER BY created_at DESC').all(tripId, placeId) as (TripFile & { trip_id: number })[]).map(formatFile);
}
// Authenticated file download (supports Bearer header or ?token= query param for direct links)
router.get('/:id/download', (req: Request, res: Response) => {
const { tripId, id } = req.params;
// Accept token from Authorization header or query parameter
const authHeader = req.headers['authorization'];
const token = (authHeader && authHeader.split(' ')[1]) || (req.query.token as string);
if (!token) return res.status(401).json({ error: 'Authentication required' });
let userId: number;
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
userId = decoded.id;
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
const trip = verifyTripOwnership(tripId, userId);
if (!trip) return res.status(404).json({ error: 'Trip not found' });
const file = db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined;
if (!file) return res.status(404).json({ error: 'File not found' });
const safeName = path.basename(file.filename);
const filePath = path.join(filesDir, safeName);
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(filesDir))) {
return res.status(403).json({ error: 'Forbidden' });
}
if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'File not found' });
res.sendFile(resolved);
});
// List files (excludes soft-deleted by default) // List files (excludes soft-deleted by default)
interface FileLink { interface FileLink {
file_id: number; file_id: number;