diff --git a/client/src/components/Files/FileManager.tsx b/client/src/components/Files/FileManager.tsx index c36f8432..e1979d9b 100644 --- a/client/src/components/Files/FileManager.tsx +++ b/client/src/components/Files/FileManager.tsx @@ -10,7 +10,7 @@ import { useCanDo } from '../../store/permissionsStore' import { useTripStore } from '../../store/tripStore' import { getAuthUrl } from '../../api/authUrl' -import { downloadFile, openFile } from '../../utils/fileDownload' +import { downloadFile, openFile as openFileUrl } from '../../utils/fileDownload' function isImage(mimeType) { if (!mimeType) return false @@ -113,7 +113,7 @@ function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxProps) {
+

diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index 4b46de46..4e8698bf 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -1222,6 +1222,8 @@ const ar: Record = { 'files.title': 'الملفات', 'files.pageTitle': 'الملفات والمستندات', 'files.subtitle': '{count} ملف لـ {trip}', + 'files.download': 'تنزيل', + 'files.openError': 'تعذر فتح الملف', 'files.downloadPdf': 'تنزيل PDF', 'files.count': '{count} ملفات', 'files.countSingular': 'ملف واحد', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 846d3b62..556e91a0 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -1191,6 +1191,8 @@ const br: Record = { 'files.title': 'Arquivos', 'files.pageTitle': 'Arquivos e documentos', 'files.subtitle': '{count} arquivos para {trip}', + 'files.download': 'Baixar', + 'files.openError': 'Não foi possível abrir o arquivo', 'files.downloadPdf': 'Baixar PDF', 'files.count': '{count} arquivos', 'files.countSingular': '1 arquivo', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 6bfbae08..a552959b 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -1220,6 +1220,8 @@ const cs: Record = { 'files.title': 'Soubory', 'files.pageTitle': 'Soubory a dokumenty', 'files.subtitle': '{count} souborů pro {trip}', + 'files.download': 'Stáhnout', + 'files.openError': 'Soubor nelze otevřít', 'files.downloadPdf': 'Stáhnout PDF', 'files.count': '{count} souborů', 'files.countSingular': '1 soubor', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 1b75a3c2..b4fdc04c 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -1224,6 +1224,8 @@ const de: Record = { 'files.title': 'Dateien', 'files.pageTitle': 'Dateien & Dokumente', 'files.subtitle': '{count} Dateien für {trip}', + 'files.download': 'Herunterladen', + 'files.openError': 'Datei konnte nicht geöffnet werden', 'files.downloadPdf': 'PDF herunterladen', 'files.count': '{count} Dateien', 'files.countSingular': '1 Datei', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 05f07a7c..e444d957 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1281,6 +1281,8 @@ const en: Record = { 'files.title': 'Files', 'files.pageTitle': 'Files & Documents', 'files.subtitle': '{count} files for {trip}', + 'files.download': 'Download', + 'files.openError': 'Could not open file', 'files.downloadPdf': 'Download PDF', 'files.count': '{count} files', 'files.countSingular': '1 file', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 4369a26c..d9193900 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -1168,6 +1168,8 @@ const es: Record = { 'files.title': 'Archivos', 'files.pageTitle': 'Archivos y documentos', 'files.subtitle': '{count} archivos para {trip}', + 'files.download': 'Descargar', + 'files.openError': 'No se pudo abrir el archivo', 'files.downloadPdf': 'Descargar PDF', 'files.count': '{count} archivos', 'files.countSingular': '1 archivo', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index 90c6f7e2..46efe930 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -1218,6 +1218,8 @@ const fr: Record = { 'files.title': 'Fichiers', 'files.pageTitle': 'Fichiers et documents', 'files.subtitle': '{count} fichiers pour {trip}', + 'files.download': 'Télécharger', + 'files.openError': "Impossible d'ouvrir le fichier", 'files.downloadPdf': 'Télécharger le PDF', 'files.count': '{count} fichiers', 'files.countSingular': '1 fichier', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index e4a70e8f..68a99569 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -1219,6 +1219,8 @@ const hu: Record = { 'files.title': 'Fájlok', 'files.pageTitle': 'Fájlok és dokumentumok', 'files.subtitle': '{count} fájl a következőhöz: {trip}', + 'files.download': 'Letöltés', + 'files.openError': 'A fájl megnyitása sikertelen', 'files.downloadPdf': 'PDF letöltése', 'files.count': '{count} fájl', 'files.countSingular': '1 fájl', diff --git a/client/src/i18n/translations/id.ts b/client/src/i18n/translations/id.ts index dea1b7fe..2975aaa6 100644 --- a/client/src/i18n/translations/id.ts +++ b/client/src/i18n/translations/id.ts @@ -1279,6 +1279,8 @@ const id: Record = { 'files.title': 'File', 'files.pageTitle': 'File & Dokumen', 'files.subtitle': '{count} file untuk {trip}', + 'files.download': 'Unduh', + 'files.openError': 'Tidak dapat membuka file', 'files.downloadPdf': 'Unduh PDF', 'files.count': '{count} file', 'files.countSingular': '1 berkas', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 5b52b143..3d959da4 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -1219,6 +1219,8 @@ const it: Record = { 'files.title': 'File', 'files.pageTitle': 'File e documenti', 'files.subtitle': '{count} file per {trip}', + 'files.download': 'Scarica', + 'files.openError': 'Impossibile aprire il file', 'files.downloadPdf': 'Scarica PDF', 'files.count': '{count} file', 'files.countSingular': '1 documento', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index ee7819ec..a27e3438 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -1218,6 +1218,8 @@ const nl: Record = { 'files.title': 'Bestanden', 'files.pageTitle': 'Bestanden en documenten', 'files.subtitle': '{count} bestanden voor {trip}', + 'files.download': 'Downloaden', + 'files.openError': 'Bestand kon niet worden geopend', 'files.downloadPdf': 'PDF downloaden', 'files.count': '{count} bestanden', 'files.countSingular': '1 bestand', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index db234542..713b2ebc 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -1170,6 +1170,8 @@ const pl: Record = { 'files.title': 'Pliki', 'files.pageTitle': 'Pliki i dokumenty', 'files.subtitle': '{count} plików dla {trip}', + 'files.download': 'Pobierz', + 'files.openError': 'Nie można otworzyć pliku', 'files.downloadPdf': 'Pobierz PDF', 'files.count': '{count} plików', 'files.countSingular': '1 plik', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index db5b8b44..b726b091 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -1218,6 +1218,8 @@ const ru: Record = { 'files.title': 'Файлы', 'files.pageTitle': 'Файлы и документы', 'files.subtitle': '{count} файлов для {trip}', + 'files.download': 'Скачать', + 'files.openError': 'Не удалось открыть файл', 'files.downloadPdf': 'Скачать PDF', 'files.count': '{count} файлов', 'files.countSingular': '1 файл', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 15109322..1aea5575 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -1218,6 +1218,8 @@ const zh: Record = { 'files.title': '文件', 'files.pageTitle': '文件与文档', 'files.subtitle': '{trip} 的 {count} 个文件', + 'files.download': '下载', + 'files.openError': '无法打开文件', 'files.downloadPdf': '下载 PDF', 'files.count': '{count} 个文件', 'files.countSingular': '1 个文件', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index f9af554e..7167022b 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -1278,6 +1278,8 @@ const zhTw: Record = { 'files.title': '檔案', 'files.pageTitle': '檔案與文件', 'files.subtitle': '{trip} 的 {count} 個檔案', + 'files.download': '下載', + 'files.openError': '無法開啟檔案', 'files.downloadPdf': '下載 PDF', 'files.count': '{count} 個檔案', 'files.countSingular': '1 個檔案', diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 661f0cc7..65e3d250 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -77,15 +77,11 @@ const upload = multer({ // Routes // --------------------------------------------------------------------------- -// Authenticated file download (supports Bearer header or ?token= query param) +// Authenticated file download (supports cookie, Bearer header, or ?token= query param) router.get('/:id/download', (req: Request, res: Response) => { const { tripId, id } = req.params; - const authHeader = req.headers['authorization']; - const bearerToken = authHeader && authHeader.split(' ')[1]; - const queryToken = req.query.token as string | undefined; - - const auth = authenticateDownload(bearerToken, queryToken); + const auth = authenticateDownload(req); if ('error' in auth) return res.status(auth.status).json({ error: auth.error }); const trip = verifyTripAccess(tripId, auth.userId); diff --git a/server/src/services/backupService.ts b/server/src/services/backupService.ts index 0fc3409d..b52ed2f0 100644 --- a/server/src/services/backupService.ts +++ b/server/src/services/backupService.ts @@ -62,7 +62,7 @@ export function parseAutoBackupBody(body: Record): { } export function isValidBackupFilename(filename: string): boolean { - return /^backup-[\w\-]+\.zip$/.test(filename); + return /^(?:auto-)?backup-[\w-]+\.zip$/.test(filename); } export function backupFilePath(filename: string): string { diff --git a/server/src/services/fileService.ts b/server/src/services/fileService.ts index 58c5fd26..f5ad5478 100644 --- a/server/src/services/fileService.ts +++ b/server/src/services/fileService.ts @@ -1,5 +1,6 @@ import path from 'path'; import fs from 'fs'; +import type { Request } from 'express'; import { db, canAccessTrip } from '../db/database'; import { consumeEphemeralToken } from './ephemeralTokens'; import { verifyJwtAndLoadUser } from '../middleware/auth'; @@ -72,23 +73,30 @@ export function resolveFilePath(filename: string): { resolved: string; safe: boo // Token-based download auth // --------------------------------------------------------------------------- -export function authenticateDownload(bearerToken: string | undefined, queryToken: string | undefined): { userId: number } | { error: string; status: number } { - if (!bearerToken && !queryToken) { - return { error: 'Authentication required', status: 401 }; - } +export function authenticateDownload(req: Request): { userId: number } | { error: string; status: number } { + const cookieToken = (req as any).cookies?.trek_session as string | undefined; + const authHeader = req.headers['authorization']; + const bearerToken = authHeader ? (authHeader.split(' ')[1] || undefined) : undefined; + const queryToken = req.query.token as string | undefined; - if (bearerToken) { + // Cookie and Bearer both carry a full JWT — try them first (cookie wins). + const jwtToken = cookieToken || bearerToken; + if (jwtToken) { // Use the shared helper so the password_version gate applies here too; // previously this bypassed the check and stolen download tokens stayed // valid across a password reset. - const user = verifyJwtAndLoadUser(bearerToken); + const user = verifyJwtAndLoadUser(jwtToken); if (!user) return { error: 'Invalid or expired token', status: 401 }; return { userId: user.id }; } - const uid = consumeEphemeralToken(queryToken!, 'download'); - if (!uid) return { error: 'Invalid or expired token', status: 401 }; - return { userId: uid }; + if (queryToken) { + const uid = consumeEphemeralToken(queryToken, 'download'); + if (!uid) return { error: 'Invalid or expired token', status: 401 }; + return { userId: uid }; + } + + return { error: 'Authentication required', status: 401 }; } // --------------------------------------------------------------------------- diff --git a/server/tests/integration/files.test.ts b/server/tests/integration/files.test.ts index 155fba91..8d35b48c 100644 --- a/server/tests/integration/files.test.ts +++ b/server/tests/integration/files.test.ts @@ -365,13 +365,12 @@ describe('File download', () => { expect(res.status).toBe(401); }); - it('FILE-008 — GET /:id/download with Bearer JWT downloads or 404s (no physical file in tests)', async () => { + it('FILE-008 — GET /:id/download with Bearer JWT downloads file', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF); const fileId = upload.body.file.id; - // authenticateDownload accepts a signed JWT as Bearer token const token = generateToken(user.id); const dl = await request(app) @@ -380,4 +379,18 @@ describe('File download', () => { // multer stores the file to disk during uploadFile — physical file exists expect(dl.status).toBe(200); }); + + it('FILE-011 — GET /:id/download with trek_session cookie downloads file', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const upload = await uploadFile(trip.id, user.id, FIXTURE_PDF); + const fileId = upload.body.file.id; + + const token = generateToken(user.id); + + const dl = await request(app) + .get(`/api/trips/${trip.id}/files/${fileId}/download`) + .set('Cookie', `trek_session=${token}`); + expect(dl.status).toBe(200); + }); }); diff --git a/server/tests/unit/services/backupService.test.ts b/server/tests/unit/services/backupService.test.ts index 081f27aa..120e21c6 100644 --- a/server/tests/unit/services/backupService.test.ts +++ b/server/tests/unit/services/backupService.test.ts @@ -234,6 +234,22 @@ describe('BACKUP-034 isValidBackupFilename', () => { it('accepts filename with hyphens and underscores', () => { expect(isValidBackupFilename('backup-my_trek-2026.zip')).toBe(true); }); + + it('accepts auto-backup filename', () => { + expect(isValidBackupFilename('auto-backup-2026-04-21T00-00-00.zip')).toBe(true); + }); + + it('rejects auto-backup with empty body', () => { + expect(isValidBackupFilename('auto-backup-.zip')).toBe(false); + }); + + it('rejects backup with empty body', () => { + expect(isValidBackupFilename('backup-.zip')).toBe(false); + }); + + it('rejects arbitrary auto- prefix that is not auto-backup', () => { + expect(isValidBackupFilename('auto-notbackup-2026.zip')).toBe(false); + }); }); // ---------------------------------------------------------------------------