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",
"version": "2.5.0",
"version": "2.5.1",
"private": true,
"type": "module",
"scripts": {
+2 -4
View File
@@ -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
+99 -23
View File
@@ -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() {
</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>
)
}
+113 -47
View File
@@ -1,5 +1,5 @@
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'
const texts = {
@@ -9,14 +9,27 @@ const texts = {
resetIn: 'Naechster Reset in',
minutes: 'Minuten',
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
fullVersionTitle: 'In der Vollversion zusaetzlich verfuegbar:',
fullVersionTitle: 'In der Vollversion zusaetzlich:',
features: [
'Datei-Uploads (Fotos, Dokumente, Reise-Cover)',
'API-Schluessel verwalten (Google Maps, Wetter)',
'Benutzer & Rechte verwalten',
'Automatische Backups & Wiederherstellung',
'Datei-Uploads (Fotos, Dokumente, Cover)',
'API-Schluessel (Google Maps, Wetter)',
'Benutzer- & Rechteverwaltung',
'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',
close: 'Verstanden',
},
@@ -26,20 +39,34 @@ const texts = {
resetIn: 'Next reset in',
minutes: 'minutes',
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: [
'File uploads (photos, documents, trip covers)',
'File uploads (photos, documents, covers)',
'API key management (Google Maps, Weather)',
'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',
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() {
const [dismissed, setDismissed] = useState(false)
@@ -57,85 +84,124 @@ export default function DemoBanner() {
return (
<div style={{
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',
padding: 24,
padding: 16, overflow: 'auto',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={() => setDismissed(true)}>
<div style={{
background: 'white', borderRadius: 20, padding: '32px 28px 24px',
maxWidth: 440, width: '100%',
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
maxWidth: 480, width: '100%',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
maxHeight: '90vh', overflow: 'auto',
}} 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={{
width: 36, height: 36, borderRadius: 10,
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
background: '#111827',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<Info size={20} style={{ color: 'white' }} />
<Plane size={18} style={{ color: 'white' }} />
</div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827' }}>
{t.title}
</h2>
</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}
</p>
<div style={{
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 12px',
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '10px 12px',
}}>
<Clock size={15} style={{ flexShrink: 0, color: '#0284c7' }} />
<span style={{ fontSize: 13, color: '#0369a1', fontWeight: 600 }}>
{t.resetIn} {minutesLeft} {t.minutes}
</span>
{/* Timer + Upload note */}
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<div style={{
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
}}>
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
<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>
<p style={{
fontSize: 13, color: '#b45309', lineHeight: 1.5, margin: '0 0 20px',
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '10px 12px',
display: 'flex', alignItems: 'center', gap: 8,
{/* What is NOMAD */}
<div style={{
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
border: '1px solid #e2e8f0',
}}>
<Upload size={15} style={{ flexShrink: 0 }} />
{t.uploadNote}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Map size={14} style={{ color: '#111827' }} />
<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}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
{t.features.map((text, i) => {
const Icon = featureIcons[i]
return (
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: '#4b5563' }}>
<Icon size={15} style={{ flexShrink: 0, color: '#d97706' }} />
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
<span>{text}</span>
</div>
)
})}
</div>
{/* Footer */}
<div style={{
paddingTop: 16, borderTop: '1px solid #e5e7eb',
paddingTop: 14, borderTop: '1px solid #e5e7eb',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}>
<Github size={14} />
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
<Github size={13} />
<span>{t.selfHost}</span>
<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}
</a>
</div>
<button onClick={() => setDismissed(true)} style={{
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',
}}>
{t.close}
+6 -1
View File
@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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;
">
<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>
${badgeHtml}
</div>`,
+2 -14
View File
@@ -421,23 +421,11 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
</div>
</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 > 0 && (
<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-[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')}
</span>
</div>
@@ -446,7 +434,7 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
{tripsThisYear > 0 && (
<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-[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}
</span>
</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 token = params.get('token')
const oidcError = params.get('oidc_error')
if (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)' }}
>
<Plane size={18} />
Demo ausprobieren ohne Registrierung
{language === 'de' ? 'Demo ausprobieren — ohne Registrierung' : 'Try the demo — no registration needed'}
</button>
)}
</div>
+1 -1
View File
@@ -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:
+12 -2
View File
@@ -1,18 +1,19 @@
{
"name": "nomad-server",
"version": "2.4.1",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nomad-server",
"version": "2.4.1",
"version": "2.5.1",
"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",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nomad-server",
"version": "2.5.0",
"version": "2.5.1",
"main": "src/index.js",
"scripts": {
"start": "node --experimental-sqlite src/index.js",
@@ -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",
+1
View File
@@ -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;
},
+6 -10
View File
@@ -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
+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)
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);
+5
View File
@@ -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 {
+1 -1
View File
@@ -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'));
+4 -2
View File
@@ -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'));
}
},
});
+7 -2
View File
@@ -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'));
}
},
});