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:
2026-07-18 15:10:34 +02:00
parent e8130a35b3
commit ff04886986
7 changed files with 168 additions and 9 deletions

View File

@@ -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;

View 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 };

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 });
});

View File

@@ -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);