From 10107ecf31be513d9f50406a0e4f35b03c7527be Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 31 Mar 2026 21:38:16 +0200 Subject: [PATCH] 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 --- client/src/components/Files/FileManager.tsx | 18 +++++--- .../src/components/Planner/PlaceInspector.tsx | 8 +++- client/src/pages/AtlasPage.tsx | 4 +- server/src/index.ts | 21 +++++---- server/src/routes/files.ts | 45 ++++++++++++++++++- 5 files changed, 74 insertions(+), 22 deletions(-) diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index 2f62c1bb..920deddb 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -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) { >
e.stopPropagation()}> {file.original_name}
{file.original_name}
- +
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index e8ae383d..ec4aaa24 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -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 && (
{placeFiles.map(f => ( - + {(f.mime_type || '').startsWith('image/') ? : } {f.original_name} {f.file_size && {formatFileSize(f.file_size)}} diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx index 7c77eb66..8106238a 100644 --- a/client/src/pages/AtlasPage.tsx +++ b/client/src/pages/AtlasPage.tsx @@ -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' }} > - {countryCodeToFlag(r.code)} + {r.code} {r.label} diff --git a/server/src/index.ts b/server/src/index.ts index 1031482d..6bd68c8c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -125,24 +125,23 @@ import { authenticate } from './middleware/auth'; app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars'))); app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers'))); -// Serve uploaded files (UUIDs are unguessable, path traversal protected) -app.get('/uploads/:type/:filename', (req: Request, res: Response) => { - const { type, filename } = req.params; - const allowedTypes = ['covers', 'files', 'photos']; - 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); +// Serve uploaded photos (public — needed for shared trips) +app.get('/uploads/photos/:filename', (req: Request, res: Response) => { + const safeName = path.basename(req.params.filename); + const filePath = path.join(__dirname, '../uploads/photos', safeName); 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'); } - if (!fs.existsSync(resolved)) return res.status(404).send('Not found'); 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 import authRoutes from './routes/auth'; import tripsRoutes from './routes/trips'; diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 5429d458..1658b266 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -3,6 +3,8 @@ import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; +import jwt from 'jsonwebtoken'; +import { JWT_SECRET } from '../config'; import { db, canAccessTrip } from '../db/database'; import { authenticate, demoUploadBlock } from '../middleware/auth'; import { requireTripAccess } from '../middleware/tripAccess'; @@ -65,13 +67,52 @@ const FILE_SELECT = ` 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 { ...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) interface FileLink { file_id: number;