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) { >
{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;