feat: Logs zeigen jetzt auch Besuche oeffentlicher Links (IP+Ort), Dropdown-Filter ergaenzt, Logs erfassen jetzt auch Upload-Freigabe und Datei-Teilen Besuche, Filter-Dropdown erweitert
This commit is contained in:
@@ -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;
|
module.exports = db;
|
||||||
|
|||||||
41
backend/src/publicAccessLog.js
Normal file
41
backend/src/publicAccessLog.js
Normal file
@@ -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 };
|
||||||
@@ -524,4 +524,50 @@ router.get('/push-logs', authenticate, requireAdmin, (req, res) => {
|
|||||||
res.json(logs);
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const bcrypt = require('bcryptjs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const db = require('../../db');
|
const db = require('../../db');
|
||||||
|
const { logPublicAccess } = require('../../publicAccessLog');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
|
||||||
@@ -29,6 +30,7 @@ router.get('/:token', (req, res) => {
|
|||||||
const s = getShare(req.params.token);
|
const s = getShare(req.params.token);
|
||||||
if (!s) return res.status(404).json({ error: 'Link nicht gefunden' });
|
if (!s) return res.status(404).json({ error: 'Link nicht gefunden' });
|
||||||
if (isExpired(s)) return res.status(410).json({ error: 'Link abgelaufen' });
|
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({
|
res.json({
|
||||||
label: s.label,
|
label: s.label,
|
||||||
file_name: s.file_name,
|
file_name: s.file_name,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const multer = require('multer');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const db = require('../../db');
|
const db = require('../../db');
|
||||||
|
const { logPublicAccess } = require('../../publicAccessLog');
|
||||||
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
const { authenticate, requireAdmin } = require('../../middleware/auth');
|
||||||
const router = express.Router();
|
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);
|
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 (!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' });
|
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 });
|
res.json({ ok:true, max_size_mb:s.max_size_mb, expires_at:s.expires_at });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const crypto = require('crypto');
|
|||||||
const db = require('../../db');
|
const db = require('../../db');
|
||||||
const mqttClient = require('../../mqtt');
|
const mqttClient = require('../../mqtt');
|
||||||
const { logPush } = require('../../pushLog');
|
const { logPush } = require('../../pushLog');
|
||||||
|
const { logPublicAccess } = require('../../publicAccessLog');
|
||||||
const { authenticate } = require('../../middleware/auth');
|
const { authenticate } = require('../../middleware/auth');
|
||||||
const router = express.Router();
|
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) => {
|
router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => {
|
||||||
const active = getShareToken();
|
const active = getShareToken();
|
||||||
if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' });
|
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 {
|
try {
|
||||||
const result = await scrapeProspekte();
|
const result = await scrapeProspekte();
|
||||||
res.json(result);
|
res.json(result);
|
||||||
|
|||||||
@@ -3189,14 +3189,28 @@ function WebDavSettings({ toast }) {
|
|||||||
const PRIORITY_LABELS = { '-2':'Silent', '-1':'Leise', '0':'Normal', '1':'Hoch', '2':'Emergency' };
|
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 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 }) {
|
function PushLogs({ toast }) {
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('alle');
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
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(); }, []);
|
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' });
|
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()
|
const filtered = filter.trim()
|
||||||
? logs.filter(l => (l.username||'').toLowerCase().includes(filter.toLowerCase())
|
? byType.filter(l => {
|
||||||
|| (l.title||'').toLowerCase().includes(filter.toLowerCase())
|
const q = filter.toLowerCase();
|
||||||
|| (l.message||'').toLowerCase().includes(filter.toLowerCase())
|
if (l.logType === 'pushover') {
|
||||||
|| (l.source||'').toLowerCase().includes(filter.toLowerCase()))
|
return (l.username||'').toLowerCase().includes(q) || (l.title||'').toLowerCase().includes(q)
|
||||||
: logs;
|
|| (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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Sec title={`PUSHOVER-VERLAUF (${filtered.length})`}>
|
<Sec title={`LOGS (${filtered.length})`}>
|
||||||
|
<div style={{ display:'flex', gap:8, marginBottom:8, flexWrap:'wrap' }}>
|
||||||
|
<select value={typeFilter} onChange={e=>setTypeFilter(e.target.value)}
|
||||||
|
style={{ ...S.inp, flex:'1 1 200px' }}>
|
||||||
|
{LOG_TYPE_OPTIONS.map(([id,label]) => <option key={id} value={id}>{label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div style={{ display:'flex', gap:8, marginBottom:12 }}>
|
<div style={{ display:'flex', gap:8, marginBottom:12 }}>
|
||||||
<input value={filter} onChange={e=>setFilter(e.target.value)}
|
<input value={filter} onChange={e=>setFilter(e.target.value)}
|
||||||
placeholder="Filtern nach Benutzer, Text, Quelle…"
|
placeholder="Filtern nach Benutzer, Text, IP, Ort…"
|
||||||
style={{ ...S.inp, flex:1 }} />
|
style={{ ...S.inp, flex:1 }} />
|
||||||
<button onClick={load} style={{ ...S.btn('#4ecdc4'), flexShrink:0 }}>↻</button>
|
<button onClick={load} style={{ ...S.btn('#4ecdc4'), flexShrink:0 }}>↻</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3230,7 +3258,32 @@ function PushLogs({ toast }) {
|
|||||||
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12 }}>Keine Einträge.</div>
|
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12 }}>Keine Einträge.</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display:'flex', flexDirection:'column', gap:6, maxHeight:'65vh', overflowY:'auto' }}>
|
<div style={{ display:'flex', flexDirection:'column', gap:6, maxHeight:'65vh', overflowY:'auto' }}>
|
||||||
{filtered.map(l => (
|
{filtered.map(l => l.logType === 'public_access' ? (
|
||||||
|
<div key={l.id} style={{
|
||||||
|
border:'1px solid rgba(78,205,196,0.2)', borderRadius:8, padding:'8px 10px',
|
||||||
|
background:'rgba(78,205,196,0.04)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', gap:8, marginBottom:3, flexWrap:'wrap' }}>
|
||||||
|
<span style={{ color:'#4ecdc4', fontFamily:"'Space Mono',monospace", fontSize:11, fontWeight:700 }}>
|
||||||
|
{PUBLIC_LINK_LABELS[l.linkType] || '🔗 Öffentlicher Link besucht'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>
|
||||||
|
{fmtDate(l.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ color:'rgba(255,255,255,0.85)', fontFamily:'monospace', fontSize:12, marginBottom:4 }}>
|
||||||
|
{l.path}
|
||||||
|
</div>
|
||||||
|
<div style={{ display:'flex', gap:12, flexWrap:'wrap' }}>
|
||||||
|
<span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>
|
||||||
|
IP: {l.ip || 'unbekannt'}
|
||||||
|
</span>
|
||||||
|
<span style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:10 }}>
|
||||||
|
📍 {l.location || 'unbekannt'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div key={l.id} style={{
|
<div key={l.id} style={{
|
||||||
border:'1px solid rgba(255,255,255,0.08)', borderRadius:8, padding:'8px 10px',
|
border:'1px solid rgba(255,255,255,0.08)', borderRadius:8, padding:'8px 10px',
|
||||||
background: l.success ? 'transparent' : 'rgba(248,113,113,0.06)',
|
background: l.success ? 'transparent' : 'rgba(248,113,113,0.06)',
|
||||||
|
|||||||
Reference in New Issue
Block a user