diff --git a/client/src/api/websocket.js b/client/src/api/websocket.js
index d17a0b10..c0c18710 100644
--- a/client/src/api/websocket.js
+++ b/client/src/api/websocket.js
@@ -29,10 +29,8 @@ function handleMessage(event) {
// Store our socket ID from welcome message
if (parsed.type === 'welcome') {
mySocketId = parsed.socketId
- console.log('[WS] Got socketId:', mySocketId)
return
}
- console.log('[WS] Received:', parsed.type, parsed)
listeners.forEach(fn => {
try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) }
})
@@ -61,14 +59,14 @@ function connectInternal(token, isReconnect = false) {
socket = new WebSocket(url)
socket.onopen = () => {
- console.log('[WS] Connected', isReconnect ? '(reconnect)' : '(initial)')
+ // connection established
reconnectDelay = 1000
// Join active trips on any connect (initial or reconnect)
if (activeTrips.size > 0) {
activeTrips.forEach(tripId => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'join', tripId }))
- console.log('[WS] Joined trip', tripId)
+ // joined trip room
}
})
// Refetch trip data for active trips
diff --git a/client/src/components/Admin/BackupPanel.jsx b/client/src/components/Admin/BackupPanel.jsx
index 47c3f8b0..d50a5d5f 100644
--- a/client/src/components/Admin/BackupPanel.jsx
+++ b/client/src/components/Admin/BackupPanel.jsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react'
import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast'
-import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react'
+import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
const INTERVAL_OPTIONS = [
@@ -29,9 +29,10 @@ export default function BackupPanel() {
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
+ const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null)
const toast = useToast()
- const { t, locale } = useTranslation()
+ const { t, language, locale } = useTranslation()
const loadBackups = async () => {
setIsLoading(true)
@@ -67,32 +68,42 @@ export default function BackupPanel() {
}
}
- const handleRestore = async (filename) => {
- if (!confirm(t('backup.confirm.restore', { name: filename }))) return
- setRestoringFile(filename)
- try {
- await backupApi.restore(filename)
- toast.success(t('backup.toast.restored'))
- setTimeout(() => window.location.reload(), 1500)
- } catch (err) {
- toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
- setRestoringFile(null)
- }
+ const handleRestore = (filename) => {
+ setRestoreConfirm({ type: 'file', filename })
}
- const handleUploadRestore = async (e) => {
+ const handleUploadRestore = (e) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
- if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return
- setIsUploading(true)
- try {
- await backupApi.uploadRestore(file)
- toast.success(t('backup.toast.restored'))
- setTimeout(() => window.location.reload(), 1500)
- } catch (err) {
- toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
- setIsUploading(false)
+ setRestoreConfirm({ type: 'upload', filename: file.name, file })
+ }
+
+ const executeRestore = async () => {
+ if (!restoreConfirm) return
+ const { type, filename, file } = restoreConfirm
+ setRestoreConfirm(null)
+
+ if (type === 'file') {
+ setRestoringFile(filename)
+ try {
+ await backupApi.restore(filename)
+ toast.success(t('backup.toast.restored'))
+ setTimeout(() => window.location.reload(), 1500)
+ } catch (err) {
+ toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
+ setRestoringFile(null)
+ }
+ } else {
+ setIsUploading(true)
+ try {
+ await backupApi.uploadRestore(file)
+ toast.success(t('backup.toast.restored'))
+ setTimeout(() => window.location.reload(), 1500)
+ } catch (err) {
+ toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
+ setIsUploading(false)
+ }
}
}
@@ -357,6 +368,71 @@ export default function BackupPanel() {
+
+ {/* Restore Warning Modal */}
+ {restoreConfirm && (
+
setRestoreConfirm(null)}
+ >
+
e.stopPropagation()}
+ style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
+ className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
+ >
+ {/* Red header */}
+
+
+
+
+ {language === 'de' ? 'Backup wiederherstellen?' : 'Restore Backup?'}
+
+
+ {restoreConfirm.filename}
+
+
+
+
+ {/* Body */}
+
+
+ {language === 'de'
+ ? 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.'
+ : 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.'}
+
+
+
+ {language === 'de'
+ ? 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.'
+ : 'Tip: Create a backup of the current state before restoring.'}
+
+
+
+ {/* Footer */}
+
+ setRestoreConfirm(null)}
+ className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
+ style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
+ >
+ {language === 'de' ? 'Abbrechen' : 'Cancel'}
+
+ e.currentTarget.style.background = '#b91c1c'}
+ onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
+ >
+ {language === 'de' ? 'Ja, wiederherstellen' : 'Yes, restore'}
+
+
+
+
+ )}
)
}
diff --git a/client/src/components/Map/MapView.jsx b/client/src/components/Map/MapView.jsx
index 98b46647..08d22414 100644
--- a/client/src/components/Map/MapView.jsx
+++ b/client/src/components/Map/MapView.jsx
@@ -19,6 +19,11 @@ L.Icon.Default.mergeOptions({
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
+function escAttr(s) {
+ if (!s) return ''
+ return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>')
+}
+
function createPlaceIcon(place, orderNumber, isSelected) {
const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white'
@@ -55,7 +60,7 @@ function createPlaceIcon(place, orderNumber, isSelected) {
cursor:pointer;flex-shrink:0;position:relative;
">
-
+
${badgeHtml}
`,
diff --git a/client/src/pages/LoginPage.jsx b/client/src/pages/LoginPage.jsx
index 9bf346ce..50a8b5f7 100644
--- a/client/src/pages/LoginPage.jsx
+++ b/client/src/pages/LoginPage.jsx
@@ -29,9 +29,11 @@ export default function LoginPage() {
}
})
- // Handle OIDC callback token
+ // Handle OIDC callback token (via URL fragment to avoid logging)
+ const hash = window.location.hash.substring(1)
+ const hashParams = new URLSearchParams(hash)
+ const token = hashParams.get('token')
const params = new URLSearchParams(window.location.search)
- const token = params.get('token')
const oidcError = params.get('oidc_error')
if (token) {
localStorage.setItem('auth_token', token)
diff --git a/docker-compose.yml b/docker-compose.yml
index 4fab5dbb..b311145a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,7 +6,7 @@ services:
- "3000:3000"
environment:
- NODE_ENV=production
- - JWT_SECRET=${JWT_SECRET:-change-me-to-a-long-random-string}
+ - JWT_SECRET=${JWT_SECRET:-}
# - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
- PORT=3000
volumes:
diff --git a/server/package-lock.json b/server/package-lock.json
index d6e8769c..fe49ca1c 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -1,18 +1,19 @@
{
"name": "nomad-server",
- "version": "2.4.1",
+ "version": "2.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-server",
- "version": "2.4.1",
+ "version": "2.5.0",
"dependencies": {
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.1",
"express": "^4.18.3",
+ "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
@@ -995,6 +996,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/helmet": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+ "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
diff --git a/server/package.json b/server/package.json
index bf46111d..7d486767 100644
--- a/server/package.json
+++ b/server/package.json
@@ -12,6 +12,7 @@
"cors": "^2.8.5",
"dotenv": "^16.4.1",
"express": "^4.18.3",
+ "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1",
diff --git a/server/src/db/database.js b/server/src/db/database.js
index fe5f909a..bf212dfd 100644
--- a/server/src/db/database.js
+++ b/server/src/db/database.js
@@ -421,6 +421,7 @@ if (process.env.DEMO_MODE === 'true') {
// without needing a server restart after reinitialize()
const db = new Proxy({}, {
get(_, prop) {
+ if (!_db) throw new Error('Database connection is not available (restore in progress?)');
const val = _db[prop];
return typeof val === 'function' ? val.bind(_db) : val;
},
diff --git a/server/src/index.js b/server/src/index.js
index 0ca3fa38..93493ed2 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -1,6 +1,7 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
+const helmet = require('helmet');
const path = require('path');
const fs = require('fs');
@@ -42,16 +43,11 @@ app.use(cors({
origin: corsOrigin,
credentials: true
}));
-app.use(express.json());
-
-// Security headers
-app.use((req, res, next) => {
- res.setHeader('X-Content-Type-Options', 'nosniff');
- res.setHeader('X-Frame-Options', 'SAMEORIGIN');
- res.setHeader('X-XSS-Protection', '1; mode=block');
- res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
- next();
-});
+app.use(helmet({
+ contentSecurityPolicy: false, // managed by frontend meta tag or reverse proxy
+ crossOriginEmbedderPolicy: false, // allows loading external images (maps, etc.)
+}));
+app.use(express.json({ limit: '100kb' }));
app.use(express.urlencoded({ extended: true }));
// Serve uploaded files
diff --git a/server/src/routes/backup.js b/server/src/routes/backup.js
index 6b7b1575..cc9d87a5 100644
--- a/server/src/routes/backup.js
+++ b/server/src/routes/backup.js
@@ -138,25 +138,37 @@ async function restoreFromZip(zipPath, res) {
// Step 1: close DB connection BEFORE touching the file (required on Windows)
closeDb();
- // Step 2: remove WAL/SHM and overwrite DB file
- const dbDest = path.join(dataDir, 'travel.db');
- for (const ext of ['', '-wal', '-shm']) {
- try { fs.unlinkSync(dbDest + ext); } catch (e) {}
- }
- fs.copyFileSync(extractedDb, dbDest);
+ try {
+ // Step 2: remove WAL/SHM and overwrite DB file
+ const dbDest = path.join(dataDir, 'travel.db');
+ for (const ext of ['', '-wal', '-shm']) {
+ try { fs.unlinkSync(dbDest + ext); } catch (e) {}
+ }
+ fs.copyFileSync(extractedDb, dbDest);
- // Step 3: restore uploads
- const extractedUploads = path.join(extractDir, 'uploads');
- if (fs.existsSync(extractedUploads)) {
- if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true });
- fs.cpSync(extractedUploads, uploadsDir, { recursive: true });
+ // Step 3: restore uploads — overwrite in-place instead of rmSync
+ // (rmSync fails with EBUSY because express.static holds the directory)
+ const extractedUploads = path.join(extractDir, 'uploads');
+ if (fs.existsSync(extractedUploads)) {
+ // Clear contents of each subdirectory without removing the root uploads dir
+ for (const sub of fs.readdirSync(uploadsDir)) {
+ const subPath = path.join(uploadsDir, sub);
+ if (fs.statSync(subPath).isDirectory()) {
+ for (const file of fs.readdirSync(subPath)) {
+ try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {}
+ }
+ }
+ }
+ // Copy restored files over
+ fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
+ }
+ } finally {
+ // Step 4: ALWAYS reopen DB — even if file copy failed, so the server stays functional
+ reinitialize();
}
fs.rmSync(extractDir, { recursive: true, force: true });
- // Step 4: reopen DB with restored data
- reinitialize();
-
res.json({ success: true });
} catch (err) {
console.error('Restore error:', err);
diff --git a/server/src/routes/files.js b/server/src/routes/files.js
index cca09e23..87519efe 100644
--- a/server/src/routes/files.js
+++ b/server/src/routes/files.js
@@ -35,6 +35,11 @@ const upload = multer({
'text/plain',
'text/csv',
];
+ const ext = path.extname(file.originalname).toLowerCase();
+ const blockedExts = ['.svg', '.html', '.htm', '.xml'];
+ if (blockedExts.includes(ext) || file.mimetype.includes('svg')) {
+ return cb(new Error('File type not allowed'));
+ }
if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
diff --git a/server/src/routes/oidc.js b/server/src/routes/oidc.js
index 96b7a4d0..e9ad3408 100644
--- a/server/src/routes/oidc.js
+++ b/server/src/routes/oidc.js
@@ -196,7 +196,7 @@ router.get('/callback', async (req, res) => {
// Generate JWT and redirect to frontend
const token = generateToken(user);
// In dev mode, frontend runs on a different port
- res.redirect(frontendUrl(`/login?token=${token}`));
+ res.redirect(frontendUrl(`/login#token=${token}`));
} catch (err) {
console.error('[OIDC] Callback error:', err);
res.redirect(frontendUrl('/login?oidc_error=server_error'));
diff --git a/server/src/routes/photos.js b/server/src/routes/photos.js
index c9bf568c..420d4049 100644
--- a/server/src/routes/photos.js
+++ b/server/src/routes/photos.js
@@ -25,10 +25,12 @@ const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => {
- if (file.mimetype.startsWith('image/')) {
+ const ext = path.extname(file.originalname).toLowerCase();
+ const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
+ if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
cb(null, true);
} else {
- cb(new Error('Nur Bilddateien sind erlaubt'));
+ cb(new Error('Only jpg, png, gif, webp images allowed'));
}
},
});
diff --git a/server/src/routes/trips.js b/server/src/routes/trips.js
index 87db2fca..95486178 100644
--- a/server/src/routes/trips.js
+++ b/server/src/routes/trips.js
@@ -24,8 +24,13 @@ const uploadCover = multer({
storage: coverStorage,
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (req, file, cb) => {
- if (file.mimetype.startsWith('image/')) cb(null, true);
- else cb(new Error('Nur Bilder erlaubt'));
+ const ext = path.extname(file.originalname).toLowerCase();
+ const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
+ if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) {
+ cb(null, true);
+ } else {
+ cb(new Error('Only jpg, png, gif, webp images allowed'));
+ }
},
});