mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df695ee8d8 | |||
| d845057f84 | |||
| e70fe50ae3 | |||
| 2000371844 | |||
| d45d9c2cfa | |||
| d24f0b3ccd | |||
| c1fb745627 |
+1
-1
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
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>`,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Generated
+12
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user