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

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/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';

View File

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