- 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
439 lines
19 KiB
JavaScript
439 lines
19 KiB
JavaScript
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, AlertTriangle } from 'lucide-react'
|
|
import { useTranslation } from '../../i18n'
|
|
|
|
const INTERVAL_OPTIONS = [
|
|
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
|
|
{ value: 'daily', labelKey: 'backup.interval.daily' },
|
|
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
|
|
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
|
|
]
|
|
|
|
const KEEP_OPTIONS = [
|
|
{ value: 1, labelKey: 'backup.keep.1day' },
|
|
{ value: 3, labelKey: 'backup.keep.3days' },
|
|
{ value: 7, labelKey: 'backup.keep.7days' },
|
|
{ value: 14, labelKey: 'backup.keep.14days' },
|
|
{ value: 30, labelKey: 'backup.keep.30days' },
|
|
{ value: 0, labelKey: 'backup.keep.forever' },
|
|
]
|
|
|
|
export default function BackupPanel() {
|
|
const [backups, setBackups] = useState([])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [isCreating, setIsCreating] = useState(false)
|
|
const [restoringFile, setRestoringFile] = useState(null)
|
|
const [isUploading, setIsUploading] = useState(false)
|
|
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, language, locale } = useTranslation()
|
|
|
|
const loadBackups = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const data = await backupApi.list()
|
|
setBackups(data.backups || [])
|
|
} catch {
|
|
toast.error(t('backup.toast.loadError'))
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const loadAutoSettings = async () => {
|
|
try {
|
|
const data = await backupApi.getAutoSettings()
|
|
setAutoSettings(data.settings)
|
|
} catch {}
|
|
}
|
|
|
|
useEffect(() => { loadBackups(); loadAutoSettings() }, [])
|
|
|
|
const handleCreate = async () => {
|
|
setIsCreating(true)
|
|
try {
|
|
await backupApi.create()
|
|
toast.success(t('backup.toast.created'))
|
|
await loadBackups()
|
|
} catch {
|
|
toast.error(t('backup.toast.createError'))
|
|
} finally {
|
|
setIsCreating(false)
|
|
}
|
|
}
|
|
|
|
const handleRestore = (filename) => {
|
|
setRestoreConfirm({ type: 'file', filename })
|
|
}
|
|
|
|
const handleUploadRestore = (e) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
e.target.value = ''
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (filename) => {
|
|
if (!confirm(t('backup.confirm.delete', { name: filename }))) return
|
|
try {
|
|
await backupApi.delete(filename)
|
|
toast.success(t('backup.toast.deleted'))
|
|
setBackups(prev => prev.filter(b => b.filename !== filename))
|
|
} catch {
|
|
toast.error(t('backup.toast.deleteError'))
|
|
}
|
|
}
|
|
|
|
const handleAutoSettingsChange = (key, value) => {
|
|
setAutoSettings(prev => ({ ...prev, [key]: value }))
|
|
setAutoSettingsDirty(true)
|
|
}
|
|
|
|
const handleSaveAutoSettings = async () => {
|
|
setAutoSettingsSaving(true)
|
|
try {
|
|
const data = await backupApi.setAutoSettings(autoSettings)
|
|
setAutoSettings(data.settings)
|
|
setAutoSettingsDirty(false)
|
|
toast.success(t('backup.toast.settingsSaved'))
|
|
} catch {
|
|
toast.error(t('backup.toast.settingsError'))
|
|
} finally {
|
|
setAutoSettingsSaving(false)
|
|
}
|
|
}
|
|
|
|
const formatSize = (bytes) => {
|
|
if (!bytes) return '-'
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
}
|
|
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '-'
|
|
try {
|
|
return new Date(dateStr).toLocaleString(locale, {
|
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})
|
|
} catch { return dateStr }
|
|
}
|
|
|
|
const isAuto = (filename) => filename.startsWith('auto-backup-')
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
|
|
{/* Manual Backups */}
|
|
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<HardDrive className="w-5 h-5 text-gray-400" />
|
|
<div>
|
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
|
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={loadBackups}
|
|
disabled={isLoading}
|
|
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
|
title={t('backup.refresh')}
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
|
|
{/* Upload & Restore */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".zip"
|
|
className="hidden"
|
|
onChange={handleUploadRestore}
|
|
/>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
disabled={isUploading}
|
|
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
|
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
|
>
|
|
{isUploading ? (
|
|
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
|
) : (
|
|
<Upload className="w-4 h-4" />
|
|
)}
|
|
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleCreate}
|
|
disabled={isCreating}
|
|
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
|
title={isCreating ? t('backup.creating') : t('backup.create')}
|
|
>
|
|
{isCreating ? (
|
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
) : (
|
|
<Plus className="w-4 h-4" />
|
|
)}
|
|
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{isLoading && backups.length === 0 ? (
|
|
<div className="flex items-center justify-center py-12 text-gray-400">
|
|
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" />
|
|
{t('common.loading')}
|
|
</div>
|
|
) : backups.length === 0 ? (
|
|
<div className="text-center py-12 text-gray-400">
|
|
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
|
|
<p className="text-sm">{t('backup.empty')}</p>
|
|
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
|
{t('backup.createFirst')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100">
|
|
{backups.map(backup => (
|
|
<div key={backup.filename} className="flex items-center gap-4 py-3">
|
|
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
|
|
{isAuto(backup.filename)
|
|
? <RefreshCw className="w-4 h-4 text-blue-500" />
|
|
: <HardDrive className="w-4 h-4 text-gray-500" />
|
|
}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p>
|
|
{isAuto(backup.filename) && (
|
|
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 mt-0.5">
|
|
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
|
|
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
<button
|
|
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
{t('backup.download')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleRestore(backup.filename)}
|
|
disabled={restoringFile === backup.filename}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
|
|
>
|
|
{restoringFile === backup.filename
|
|
? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
|
|
: <RotateCcw className="w-3.5 h-3.5" />
|
|
}
|
|
{t('backup.restore')}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(backup.filename)}
|
|
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Auto-Backup Settings */}
|
|
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<Clock className="w-5 h-5 text-gray-400" />
|
|
<div>
|
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
|
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-5">
|
|
{/* Enable toggle */}
|
|
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
|
<div className="min-w-0">
|
|
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
|
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
|
</div>
|
|
<button
|
|
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
|
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
|
|
>
|
|
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
</button>
|
|
</label>
|
|
|
|
{autoSettings.enabled && (
|
|
<>
|
|
{/* Interval */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{INTERVAL_OPTIONS.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
|
autoSettings.interval === opt.value
|
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
{t(opt.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Keep duration */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{KEEP_OPTIONS.map(opt => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
|
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
|
autoSettings.keep_days === opt.value
|
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
{t(opt.labelKey)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Save button */}
|
|
<div className="flex justify-end pt-2 border-t border-gray-100">
|
|
<button
|
|
onClick={handleSaveAutoSettings}
|
|
disabled={autoSettingsSaving || !autoSettingsDirty}
|
|
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
|
>
|
|
{autoSettingsSaving
|
|
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
: <Check className="w-4 h-4" />
|
|
}
|
|
{autoSettingsSaving ? t('common.saving') : t('common.save')}
|
|
</button>
|
|
</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>
|
|
)
|
|
}
|