Compare commits

..

7 Commits

Author SHA1 Message Date
Maurice df695ee8d8 v2.5.1 — Security hardening, backup restore fix & restore warning modal 2026-03-21 15:13:10 +01:00
Maurice d845057f84 Security hardening, backup restore fix & restore warning modal
- Fix backup restore: try/finally ensures DB always reopens after closeDb
- Fix EBUSY on uploads during restore (in-place overwrite instead of rmSync)
- Add DB proxy null guard for clearer errors during restore window
- Add red warning modal before backup restore (DE/EN, dark mode support)
- JWT secret: empty docker-compose default so auto-generation kicks in
- OIDC: pass token via URL fragment instead of query param (no server logs)
- Block SVG uploads on photos, files and covers (stored XSS prevention)
- Add helmet for security headers (HSTS, X-Frame, nosniff, etc.)
- Explicit express.json body size limit (100kb)
- Fix XSS in Leaflet map markers (escape image_url in HTML)
- Remove verbose WebSocket debug logging from client
2026-03-21 15:09:41 +01:00
Maurice e70fe50ae3 Fix demo banner: i18n for demo button, icon alignment, add addon mgmt & OIDC to full version features 2026-03-21 11:14:53 +01:00
mauriceboe 2000371844 Update README.md 2026-03-20 23:42:49 +01:00
Maurice d45d9c2cfa Fix Atlas labels, update Demo Banner with addons & NOMAD intro (v2.5.0)
- Remove Next Trip from Atlas bottom panel
- Fix label wrapping with whitespace-nowrap on streak/year labels
- Redesign Demo Banner: add addon showcase (Vacay, Atlas, Packing, Budget, Documents, Widgets), "What is NOMAD?" section, 2-column grid layout, compact design
2026-03-20 23:39:12 +01:00
Maurice d24f0b3ccd Fix Atlas: remove Next Trip, fix label wrapping (v2.5.0) 2026-03-20 23:39:12 +01:00
mauriceboe c1fb745627 Update README.md 2026-03-20 23:20:34 +01:00
17 changed files with 293 additions and 126 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nomad-client", "name": "nomad-client",
"version": "2.5.0", "version": "2.5.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -4
View File
@@ -29,10 +29,8 @@ function handleMessage(event) {
// Store our socket ID from welcome message // Store our socket ID from welcome message
if (parsed.type === 'welcome') { if (parsed.type === 'welcome') {
mySocketId = parsed.socketId mySocketId = parsed.socketId
console.log('[WS] Got socketId:', mySocketId)
return return
} }
console.log('[WS] Received:', parsed.type, parsed)
listeners.forEach(fn => { listeners.forEach(fn => {
try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) } 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 = new WebSocket(url)
socket.onopen = () => { socket.onopen = () => {
console.log('[WS] Connected', isReconnect ? '(reconnect)' : '(initial)') // connection established
reconnectDelay = 1000 reconnectDelay = 1000
// Join active trips on any connect (initial or reconnect) // Join active trips on any connect (initial or reconnect)
if (activeTrips.size > 0) { if (activeTrips.size > 0) {
activeTrips.forEach(tripId => { activeTrips.forEach(tripId => {
if (socket && socket.readyState === WebSocket.OPEN) { if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'join', tripId })) socket.send(JSON.stringify({ type: 'join', tripId }))
console.log('[WS] Joined trip', tripId) // joined trip room
} }
}) })
// Refetch trip data for active trips // Refetch trip data for active trips
+99 -23
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { backupApi } from '../../api/client' import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast' 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' import { useTranslation } from '../../i18n'
const INTERVAL_OPTIONS = [ const INTERVAL_OPTIONS = [
@@ -29,9 +29,10 @@ export default function BackupPanel() {
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 }) const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false) const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false) const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const toast = useToast() const toast = useToast()
const { t, locale } = useTranslation() const { t, language, locale } = useTranslation()
const loadBackups = async () => { const loadBackups = async () => {
setIsLoading(true) setIsLoading(true)
@@ -67,32 +68,42 @@ export default function BackupPanel() {
} }
} }
const handleRestore = async (filename) => { const handleRestore = (filename) => {
if (!confirm(t('backup.confirm.restore', { name: filename }))) return setRestoreConfirm({ type: 'file', filename })
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 handleUploadRestore = async (e) => { const handleUploadRestore = (e) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
e.target.value = '' e.target.value = ''
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return setRestoreConfirm({ type: 'upload', filename: file.name, file })
setIsUploading(true) }
try {
await backupApi.uploadRestore(file) const executeRestore = async () => {
toast.success(t('backup.toast.restored')) if (!restoreConfirm) return
setTimeout(() => window.location.reload(), 1500) const { type, filename, file } = restoreConfirm
} catch (err) { setRestoreConfirm(null)
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
setIsUploading(false) 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() {
</div> </div>
</div> </div>
</div> </div>
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setRestoreConfirm(null)}
>
<div
onClick={e => 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 */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
{language === 'de' ? 'Backup wiederherstellen?' : 'Restore Backup?'}
</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
{restoreConfirm.filename}
</p>
</div>
</div>
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{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.'}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
{language === 'de'
? 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.'
: 'Tip: Create a backup of the current state before restoring.'}
</div>
</div>
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => 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'}
</button>
<button
onClick={executeRestore}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
>
{language === 'de' ? 'Ja, wiederherstellen' : 'Yes, restore'}
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }
+113 -47
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { Info, Github, Shield, Key, Users, Database, Upload, Clock } from 'lucide-react' import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
const texts = { const texts = {
@@ -9,14 +9,27 @@ const texts = {
resetIn: 'Naechster Reset in', resetIn: 'Naechster Reset in',
minutes: 'Minuten', minutes: 'Minuten',
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.', uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
fullVersionTitle: 'In der Vollversion zusaetzlich verfuegbar:', fullVersionTitle: 'In der Vollversion zusaetzlich:',
features: [ features: [
'Datei-Uploads (Fotos, Dokumente, Reise-Cover)', 'Datei-Uploads (Fotos, Dokumente, Cover)',
'API-Schluessel verwalten (Google Maps, Wetter)', 'API-Schluessel (Google Maps, Wetter)',
'Benutzer & Rechte verwalten', 'Benutzer- & Rechteverwaltung',
'Automatische Backups & Wiederherstellung', 'Automatische Backups',
'Addon-Verwaltung (aktivieren/deaktivieren)',
'OIDC / SSO Single Sign-On',
], ],
selfHost: 'NOMAD ist Open Source — ', addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
addons: [
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
['Packliste', 'Checklisten pro Reise'],
['Budget', 'Kostenplanung mit Splitting'],
['Dokumente', 'Dateien an Reisen anhaengen'],
['Widgets', 'Waehrungsrechner & Zeitzonen'],
],
whatIs: 'Was ist NOMAD?',
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
selfHost: 'Open Source — ',
selfHostLink: 'selbst hosten', selfHostLink: 'selbst hosten',
close: 'Verstanden', close: 'Verstanden',
}, },
@@ -26,20 +39,34 @@ const texts = {
resetIn: 'Next reset in', resetIn: 'Next reset in',
minutes: 'minutes', minutes: 'minutes',
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.', uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
fullVersionTitle: 'Additionally available in the full version:', fullVersionTitle: 'Additionally in the full version:',
features: [ features: [
'File uploads (photos, documents, trip covers)', 'File uploads (photos, documents, covers)',
'API key management (Google Maps, Weather)', 'API key management (Google Maps, Weather)',
'User & permission management', 'User & permission management',
'Automatic backups & restore', 'Automatic backups',
'Addon management (enable/disable)',
'OIDC / SSO single sign-on',
], ],
selfHost: 'NOMAD is open source — ', addonsTitle: 'Modular Addons (can be deactivated in full version)',
addons: [
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
['Atlas', 'World map with visited countries & travel stats'],
['Packing', 'Checklists per trip'],
['Budget', 'Expense tracking with splitting'],
['Documents', 'Attach files to trips'],
['Widgets', 'Currency converter & timezones'],
],
whatIs: 'What is NOMAD?',
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
selfHost: 'Open source — ',
selfHostLink: 'self-host it', selfHostLink: 'self-host it',
close: 'Got it', close: 'Got it',
}, },
} }
const featureIcons = [Upload, Key, Users, Database] const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
export default function DemoBanner() { export default function DemoBanner() {
const [dismissed, setDismissed] = useState(false) const [dismissed, setDismissed] = useState(false)
@@ -57,85 +84,124 @@ export default function DemoBanner() {
return ( return (
<div style={{ <div style={{
position: 'fixed', inset: 0, zIndex: 9999, position: 'fixed', inset: 0, zIndex: 9999,
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 24, padding: 16, overflow: 'auto',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={() => setDismissed(true)}> }} onClick={() => setDismissed(true)}>
<div style={{ <div style={{
background: 'white', borderRadius: 20, padding: '32px 28px 24px', background: 'white', borderRadius: 20, padding: '28px 24px 20px',
maxWidth: 440, width: '100%', maxWidth: 480, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: '90vh', overflow: 'auto',
}} onClick={e => e.stopPropagation()}> }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}> {/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<div style={{ <div style={{
width: 36, height: 36, borderRadius: 10, width: 36, height: 36, borderRadius: 10,
background: 'linear-gradient(135deg, #f59e0b, #d97706)', background: '#111827',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
<Info size={20} style={{ color: 'white' }} /> <Plane size={18} style={{ color: 'white' }} />
</div> </div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}> <h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827' }}>
{t.title} {t.title}
</h2> </h2>
</div> </div>
<p style={{ fontSize: 14, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}> <p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
{t.description} {t.description}
</p> </p>
<div style={{ {/* Timer + Upload note */}
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 12px', <div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '10px 12px', <div style={{
}}> flex: 1, display: 'flex', alignItems: 'center', gap: 6,
<Clock size={15} style={{ flexShrink: 0, color: '#0284c7' }} /> background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
<span style={{ fontSize: 13, color: '#0369a1', fontWeight: 600 }}> }}>
{t.resetIn} {minutesLeft} {t.minutes} <Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
</span> <span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
{t.resetIn} {minutesLeft} {t.minutes}
</span>
</div>
<div style={{
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
}}>
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
<span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
</div>
</div> </div>
<p style={{ {/* What is NOMAD */}
fontSize: 13, color: '#b45309', lineHeight: 1.5, margin: '0 0 20px', <div style={{
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '10px 12px', background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
display: 'flex', alignItems: 'center', gap: 8, border: '1px solid #e2e8f0',
}}> }}>
<Upload size={15} style={{ flexShrink: 0 }} /> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
{t.uploadNote} <Map size={14} style={{ color: '#111827' }} />
</p> <span style={{ fontSize: 12, fontWeight: 700, color: '#111827' }}>{t.whatIs}</span>
</div>
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
</div>
<p style={{ fontSize: 12, fontWeight: 700, color: '#374151', margin: '0 0 10px', textTransform: 'uppercase', letterSpacing: '0.05em' }}> {/* Addons */}
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<Puzzle size={12} />
{t.addonsTitle}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.addons.map(([name, desc], i) => {
const Icon = addonIcons[i]
return (
<div key={name} style={{
background: '#f8fafc', borderRadius: 10, padding: '8px 10px',
border: '1px solid #f1f5f9',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
<span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
</div>
<p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
</div>
)
})}
</div>
{/* Full version features */}
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
<Shield size={12} />
{t.fullVersionTitle} {t.fullVersionTitle}
</p> </p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
{t.features.map((text, i) => { {t.features.map((text, i) => {
const Icon = featureIcons[i] const Icon = featureIcons[i]
return ( return (
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: '#4b5563' }}> <div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
<Icon size={15} style={{ flexShrink: 0, color: '#d97706' }} /> <Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
<span>{text}</span> <span>{text}</span>
</div> </div>
) )
})} })}
</div> </div>
{/* Footer */}
<div style={{ <div style={{
paddingTop: 16, borderTop: '1px solid #e5e7eb', paddingTop: 14, borderTop: '1px solid #e5e7eb',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={14} /> <Github size={13} />
<span>{t.selfHost}</span> <span>{t.selfHost}</span>
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer" <a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
style={{ color: '#d97706', fontWeight: 600, textDecoration: 'none' }}> style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
{t.selfHostLink} {t.selfHostLink}
</a> </a>
</div> </div>
<button onClick={() => setDismissed(true)} style={{ <button onClick={() => setDismissed(true)} style={{
background: '#111827', color: 'white', border: 'none', background: '#111827', color: 'white', border: 'none',
borderRadius: 10, padding: '8px 20px', fontSize: 13, borderRadius: 10, padding: '8px 20px', fontSize: 12,
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
}}> }}>
{t.close} {t.close}
+6 -1
View File
@@ -19,6 +19,11 @@ L.Icon.Default.mergeOptions({
* Create a round photo-circle marker. * Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle. * Shows image_url if available, otherwise category icon in colored circle.
*/ */
function escAttr(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function createPlaceIcon(place, orderNumber, isSelected) { function createPlaceIcon(place, orderNumber, isSelected) {
const size = isSelected ? 44 : 36 const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white' const borderColor = isSelected ? '#111827' : 'white'
@@ -55,7 +60,7 @@ function createPlaceIcon(place, orderNumber, isSelected) {
cursor:pointer;flex-shrink:0;position:relative; cursor:pointer;flex-shrink:0;position:relative;
"> ">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;"> <div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" /> <img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
</div> </div>
${badgeHtml} ${badgeHtml}
</div>`, </div>`,
+2 -14
View File
@@ -421,23 +421,11 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
</div> </div>
</button> </button>
)} )}
{/* Next trip */}
{nextTrip && (
<button onClick={() => onTripClick(nextTrip.id)} className="flex items-center gap-2.5 text-left transition-opacity hover:opacity-75">
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'rgba(129,140,248,0.12)' }}>
<Calendar size={16} style={{ color: accent }} />
</div>
<div>
<p className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: tf }}>{t('atlas.nextTrip')}</p>
<p className="text-[13px] font-black" style={{ color: accent }}>{nextTrip.daysUntil} {t('atlas.daysLeft')}</p>
</div>
</button>
)}
{/* Streak */} {/* Streak */}
{streak > 0 && ( {streak > 0 && (
<div className="flex flex-col items-center justify-center px-3"> <div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span> <span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{streak}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight" style={{ color: tf }}> <span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')} {streak === 1 ? t('atlas.yearInRow') : t('atlas.yearsInRow')}
</span> </span>
</div> </div>
@@ -446,7 +434,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{tripsThisYear > 0 && ( {tripsThisYear > 0 && (
<div className="flex flex-col items-center justify-center px-3"> <div className="flex flex-col items-center justify-center px-3">
<span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span> <span className="text-2xl font-black tabular-nums leading-none" style={{ color: tp }}>{tripsThisYear}</span>
<span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight" style={{ color: tf }}> <span className="text-[9px] font-semibold mt-1.5 uppercase tracking-wide text-center leading-tight whitespace-nowrap" style={{ color: tf }}>
{tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear} {tripsThisYear === 1 ? t('atlas.tripIn') : t('atlas.tripsIn')} {thisYear}
</span> </span>
</div> </div>
+5 -3
View File
@@ -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 params = new URLSearchParams(window.location.search)
const token = params.get('token')
const oidcError = params.get('oidc_error') const oidcError = params.get('oidc_error')
if (token) { if (token) {
localStorage.setItem('auth_token', token) localStorage.setItem('auth_token', token)
@@ -404,7 +406,7 @@ export default function LoginPage() {
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }} onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
> >
<Plane size={18} /> <Plane size={18} />
Demo ausprobieren ohne Registrierung {language === 'de' ? 'Demo ausprobieren — ohne Registrierung' : 'Try the demo — no registration needed'}
</button> </button>
)} )}
</div> </div>
+1 -1
View File
@@ -6,7 +6,7 @@ services:
- "3000:3000" - "3000:3000"
environment: environment:
- NODE_ENV=production - 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 # - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins
- PORT=3000 - PORT=3000
volumes: volumes:
+12 -2
View File
@@ -1,18 +1,19 @@
{ {
"name": "nomad-server", "name": "nomad-server",
"version": "2.4.1", "version": "2.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nomad-server", "name": "nomad-server",
"version": "2.4.1", "version": "2.5.1",
"dependencies": { "dependencies": {
"archiver": "^6.0.1", "archiver": "^6.0.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"express": "^4.18.3", "express": "^4.18.3",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
@@ -995,6 +996,15 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nomad-server", "name": "nomad-server",
"version": "2.5.0", "version": "2.5.1",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node --experimental-sqlite src/index.js", "start": "node --experimental-sqlite src/index.js",
@@ -12,6 +12,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"express": "^4.18.3", "express": "^4.18.3",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
+1
View File
@@ -421,6 +421,7 @@ if (process.env.DEMO_MODE === 'true') {
// without needing a server restart after reinitialize() // without needing a server restart after reinitialize()
const db = new Proxy({}, { const db = new Proxy({}, {
get(_, prop) { get(_, prop) {
if (!_db) throw new Error('Database connection is not available (restore in progress?)');
const val = _db[prop]; const val = _db[prop];
return typeof val === 'function' ? val.bind(_db) : val; return typeof val === 'function' ? val.bind(_db) : val;
}, },
+6 -10
View File
@@ -1,6 +1,7 @@
require('dotenv').config(); require('dotenv').config();
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@@ -42,16 +43,11 @@ app.use(cors({
origin: corsOrigin, origin: corsOrigin,
credentials: true credentials: true
})); }));
app.use(express.json()); app.use(helmet({
contentSecurityPolicy: false, // managed by frontend meta tag or reverse proxy
// Security headers crossOriginEmbedderPolicy: false, // allows loading external images (maps, etc.)
app.use((req, res, next) => { }));
res.setHeader('X-Content-Type-Options', 'nosniff'); app.use(express.json({ limit: '100kb' }));
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(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
// Serve uploaded files // Serve uploaded files
+26 -14
View File
@@ -138,25 +138,37 @@ async function restoreFromZip(zipPath, res) {
// Step 1: close DB connection BEFORE touching the file (required on Windows) // Step 1: close DB connection BEFORE touching the file (required on Windows)
closeDb(); closeDb();
// Step 2: remove WAL/SHM and overwrite DB file try {
const dbDest = path.join(dataDir, 'travel.db'); // Step 2: remove WAL/SHM and overwrite DB file
for (const ext of ['', '-wal', '-shm']) { const dbDest = path.join(dataDir, 'travel.db');
try { fs.unlinkSync(dbDest + ext); } catch (e) {} for (const ext of ['', '-wal', '-shm']) {
} try { fs.unlinkSync(dbDest + ext); } catch (e) {}
fs.copyFileSync(extractedDb, dbDest); }
fs.copyFileSync(extractedDb, dbDest);
// Step 3: restore uploads // Step 3: restore uploads — overwrite in-place instead of rmSync
const extractedUploads = path.join(extractDir, 'uploads'); // (rmSync fails with EBUSY because express.static holds the directory)
if (fs.existsSync(extractedUploads)) { const extractedUploads = path.join(extractDir, 'uploads');
if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true }); if (fs.existsSync(extractedUploads)) {
fs.cpSync(extractedUploads, uploadsDir, { recursive: true }); // 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 }); fs.rmSync(extractDir, { recursive: true, force: true });
// Step 4: reopen DB with restored data
reinitialize();
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
console.error('Restore error:', err); console.error('Restore error:', err);
+5
View File
@@ -35,6 +35,11 @@ const upload = multer({
'text/plain', 'text/plain',
'text/csv', '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/')) { if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) {
cb(null, true); cb(null, true);
} else { } else {
+1 -1
View File
@@ -196,7 +196,7 @@ router.get('/callback', async (req, res) => {
// Generate JWT and redirect to frontend // Generate JWT and redirect to frontend
const token = generateToken(user); const token = generateToken(user);
// In dev mode, frontend runs on a different port // In dev mode, frontend runs on a different port
res.redirect(frontendUrl(`/login?token=${token}`)); res.redirect(frontendUrl(`/login#token=${token}`));
} catch (err) { } catch (err) {
console.error('[OIDC] Callback error:', err); console.error('[OIDC] Callback error:', err);
res.redirect(frontendUrl('/login?oidc_error=server_error')); res.redirect(frontendUrl('/login?oidc_error=server_error'));
+4 -2
View File
@@ -25,10 +25,12 @@ const upload = multer({
storage, storage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
fileFilter: (req, file, cb) => { 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); cb(null, true);
} else { } else {
cb(new Error('Nur Bilddateien sind erlaubt')); cb(new Error('Only jpg, png, gif, webp images allowed'));
} }
}, },
}); });
+7 -2
View File
@@ -24,8 +24,13 @@ const uploadCover = multer({
storage: coverStorage, storage: coverStorage,
limits: { fileSize: 20 * 1024 * 1024 }, limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true); const ext = path.extname(file.originalname).toLowerCase();
else cb(new Error('Nur Bilder erlaubt')); 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'));
}
}, },
}); });