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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, '<').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;
|
||||
">
|
||||
<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>`,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user