diff --git a/backend/src/db.js b/backend/src/db.js index 64a0b8a..3f0fd31 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -661,4 +661,17 @@ db.exec(` ) `); +// Besuche öffentlicher, loginfreier Links (KöPi-Teilen-Link, ggf. künftig +// weitere) — für die Admin-Log-Übersicht +db.exec(` + CREATE TABLE IF NOT EXISTS public_access_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + link_type TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + ip TEXT NOT NULL DEFAULT '', + location TEXT NOT NULL DEFAULT '', + created_at DATETIME + ) +`); + module.exports = db; diff --git a/backend/src/publicAccessLog.js b/backend/src/publicAccessLog.js new file mode 100644 index 0000000..d04096f --- /dev/null +++ b/backend/src/publicAccessLog.js @@ -0,0 +1,41 @@ +const db = require('./db'); + +// Sehr einfache, kostenlose IP-Geolocation ohne API-Key (ip-api.com, ~45 +// Anfragen/Minute im Free-Tier — für dieses Nutzungsszenario völlig ausreichend). +// Liefert bei privaten/lokalen IPs oder Fehlern bewusst leeren String zurück, +// statt den eigentlichen Log-Eintrag zu blockieren. +async function lookupLocation(ip) { + if (!ip) return ''; + const clean = ip.replace('::ffff:', ''); // IPv4-mapped IPv6-Adressen bereinigen + if (!clean || clean === '::1' || clean.startsWith('127.') || clean.startsWith('192.168.') || clean.startsWith('10.')) { + return ''; // lokale/private Adresse, Geolocation ergibt keinen Sinn + } + try { + const res = await fetch(`http://ip-api.com/json/${encodeURIComponent(clean)}?fields=status,country,city`); + if (!res.ok) return ''; + const data = await res.json(); + if (data.status !== 'success') return ''; + return [data.city, data.country].filter(Boolean).join(', '); + } catch { + return ''; + } +} + +// Wird von öffentlichen (loginfreien) Routen aufgerufen, sobald sie besucht +// werden. linkType z.B. 'koepi_share'. Läuft bewusst asynchron/"fire and +// forget" im Hintergrund — ein Fehler hier darf niemals die eigentliche +// öffentliche Anfrage blockieren oder verlangsamen. +function logPublicAccess({ linkType, path, ip }) { + lookupLocation(ip).then(location => { + try { + db.prepare(` + INSERT INTO public_access_log (link_type, path, ip, location, created_at) + VALUES (?, ?, ?, ?, datetime('now','localtime')) + `).run(linkType || '', path || '', ip || '', location || ''); + } catch (e) { + console.error('Public-Access-Log Fehler:', e.message); + } + }).catch(() => {}); +} + +module.exports = { logPublicAccess }; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index d2b8167..f9f9de8 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -524,4 +524,50 @@ router.get('/push-logs', authenticate, requireAdmin, (req, res) => { res.json(logs); }); +// GET /logs – kombinierte Übersicht: Pushover-Nachrichten + Besuche +// öffentlicher Links, für den Logs-Bereich mit Filter-Dropdown (Admin) +router.get('/logs', authenticate, requireAdmin, (req, res) => { + const limit = Math.min(parseInt(req.query.limit) || 200, 1000); + + const pushRows = db.prepare(` + SELECT pl.id, pl.user_id, u.username, pl.title, pl.message, pl.priority, + pl.source, pl.success, pl.created_at + FROM push_log pl + LEFT JOIN users u ON u.id = pl.user_id + ORDER BY pl.id DESC + LIMIT ? + `).all(limit).map(r => ({ + logType: 'pushover', + id: `push-${r.id}`, + created_at: r.created_at, + username: r.username, + title: r.title, + message: r.message, + priority: r.priority, + source: r.source, + success: !!r.success, + })); + + const accessRows = db.prepare(` + SELECT id, link_type, path, ip, location, created_at + FROM public_access_log + ORDER BY id DESC + LIMIT ? + `).all(limit).map(r => ({ + logType: 'public_access', + id: `access-${r.id}`, + created_at: r.created_at, + linkType: r.link_type, + path: r.path, + ip: r.ip, + location: r.location, + })); + + const combined = [...pushRows, ...accessRows] + .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || '')) + .slice(0, limit); + + res.json(combined); +}); + module.exports = router; diff --git a/backend/src/tools/dateien/public-share-public.js b/backend/src/tools/dateien/public-share-public.js index aab71a4..01e2025 100644 --- a/backend/src/tools/dateien/public-share-public.js +++ b/backend/src/tools/dateien/public-share-public.js @@ -4,6 +4,7 @@ const bcrypt = require('bcryptjs'); const path = require('path'); const fs = require('fs'); const db = require('../../db'); +const { logPublicAccess } = require('../../publicAccessLog'); const router = express.Router(); const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads'; @@ -29,6 +30,7 @@ router.get('/:token', (req, res) => { const s = getShare(req.params.token); if (!s) return res.status(404).json({ error: 'Link nicht gefunden' }); if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' }); + logPublicAccess({ linkType: 'file_share', path: `/s/${req.params.token}`, ip: req.ip }); res.json({ label: s.label, file_name: s.file_name, diff --git a/backend/src/tools/dateien/upload-share.js b/backend/src/tools/dateien/upload-share.js index c1d1e95..c987d9c 100644 --- a/backend/src/tools/dateien/upload-share.js +++ b/backend/src/tools/dateien/upload-share.js @@ -5,6 +5,7 @@ const multer = require('multer'); const path = require('path'); const fs = require('fs'); const db = require('../../db'); +const { logPublicAccess } = require('../../publicAccessLog'); const { authenticate, requireAdmin } = require('../../middleware/auth'); const router = express.Router(); @@ -114,6 +115,7 @@ router.get('/public/:token', (req, res) => { const s = db.prepare("SELECT id,expires_at,max_size_mb,is_active,failed_attempts,deactivated_reason FROM upload_shares WHERE token=?").get(req.params.token); if (!s || !s.is_active) return res.status(404).json({ error: s?.deactivated_reason==='too_many_attempts' ? 'Link wegen zu vieler Fehlversuche gesperrt' : 'Link ungültig oder deaktiviert' }); if (new Date(s.expires_at) < new Date()) return res.status(410).json({ error:'Link abgelaufen' }); + logPublicAccess({ linkType: 'upload_share', path: `/u/${req.params.token}`, ip: req.ip }); res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at }); }); diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js index e168cd9..df7cc33 100644 --- a/backend/src/tools/koepi/routes.js +++ b/backend/src/tools/koepi/routes.js @@ -4,6 +4,7 @@ const crypto = require('crypto'); const db = require('../../db'); const mqttClient = require('../../mqtt'); const { logPush } = require('../../pushLog'); +const { logPublicAccess } = require('../../publicAccessLog'); const { authenticate } = require('../../middleware/auth'); const router = express.Router(); @@ -869,6 +870,7 @@ router.get('/public/:token/offers', publicRateLimit, async (req, res) => { router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => { const active = getShareToken(); if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' }); + logPublicAccess({ linkType: 'koepi_share', path: `/kp/${req.params.token}`, ip: req.ip }); try { const result = await scrapeProspekte(); res.json(result); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c84589e..e96f32f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3189,14 +3189,28 @@ function WebDavSettings({ toast }) { const PRIORITY_LABELS = { '-2':'Silent', '-1':'Leise', '0':'Normal', '1':'Hoch', '2':'Emergency' }; const PRIORITY_COLORS = { '-2':'#888888', '-1':'#4ecdc4', '0':'rgba(255,255,255,0.6)', '1':'#ffe66d', '2':'#ff6b9d' }; +const LOG_TYPE_OPTIONS = [ + ['alle', 'Alles'], + ['pushover', 'Pushover-Nachrichten'], + ['koepi_share', 'Öffentlicher Prospektlink-Zugriff'], + ['upload_share', 'Öffentlicher Upload-Link-Zugriff'], + ['file_share', 'Öffentlicher Datei-Teilen-Link-Zugriff'], +]; +const PUBLIC_LINK_LABELS = { + koepi_share: '🔗 Prospektlink besucht', + upload_share: '📤 Upload-Link besucht', + file_share: '📁 Datei-Teilen-Link besucht', +}; + function PushLogs({ toast }) { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState(''); + const [typeFilter, setTypeFilter] = useState('alle'); const load = () => { setLoading(true); - api('/admin/push-logs?limit=300').then(setLogs).catch(e=>toast(e.message,'error')).finally(()=>setLoading(false)); + api('/admin/logs?limit=300').then(setLogs).catch(e=>toast(e.message,'error')).finally(()=>setLoading(false)); }; useEffect(() => { load(); }, []); @@ -3207,19 +3221,33 @@ function PushLogs({ toast }) { return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' }); }; + const byType = typeFilter === 'alle' ? logs + : typeFilter === 'pushover' ? logs.filter(l => l.logType === 'pushover') + : logs.filter(l => l.logType === 'public_access' && l.linkType === typeFilter); const filtered = filter.trim() - ? logs.filter(l => (l.username||'').toLowerCase().includes(filter.toLowerCase()) - || (l.title||'').toLowerCase().includes(filter.toLowerCase()) - || (l.message||'').toLowerCase().includes(filter.toLowerCase()) - || (l.source||'').toLowerCase().includes(filter.toLowerCase())) - : logs; + ? byType.filter(l => { + const q = filter.toLowerCase(); + if (l.logType === 'pushover') { + return (l.username||'').toLowerCase().includes(q) || (l.title||'').toLowerCase().includes(q) + || (l.message||'').toLowerCase().includes(q) || (l.source||'').toLowerCase().includes(q); + } + return (l.path||'').toLowerCase().includes(q) || (l.ip||'').toLowerCase().includes(q) + || (l.location||'').toLowerCase().includes(q) || (l.linkType||'').toLowerCase().includes(q); + }) + : byType; return (