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:
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user