feat: KoePi mehrere benannte oeffentliche Links statt nur einem, Migration des bestehenden Sina-Links, Name in Logs

This commit is contained in:
2026-07-18 22:47:55 +02:00
parent 7a0b53e894
commit 4c284a257b
6 changed files with 151 additions and 68 deletions

View File

@@ -677,6 +677,34 @@ db.exec(`
const cols = db.pragma('table_info(public_access_log)').map(c => c.name); const cols = db.pragma('table_info(public_access_log)').map(c => c.name);
if (!cols.includes('device')) if (!cols.includes('device'))
db.exec("ALTER TABLE public_access_log ADD COLUMN device TEXT NOT NULL DEFAULT ''"); db.exec("ALTER TABLE public_access_log ADD COLUMN device TEXT NOT NULL DEFAULT ''");
if (!cols.includes('link_name'))
db.exec("ALTER TABLE public_access_log ADD COLUMN link_name TEXT NOT NULL DEFAULT ''");
}
// KöPi: benannte, öffentliche Teilen-Links (mehrere gleichzeitig möglich,
// jeweils mit eigenem Namen wie "Sina") statt nur einem einzigen globalen Link
db.exec(`
CREATE TABLE IF NOT EXISTS koepi_share_links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_at DATETIME
)
`);
// Migration: den bisherigen einzelnen Link (war für Sina gedacht) einmalig in
// die neue Tabelle übernehmen, statt ihn ungültig zu machen
{
const oldToken = db.prepare("SELECT value FROM admin_settings WHERE key='koepi_share_token'").get()?.value;
if (oldToken) {
const already = db.prepare('SELECT 1 FROM koepi_share_links WHERE token=?').get(oldToken);
if (!already) {
db.prepare(`
INSERT INTO koepi_share_links (token, name, created_at)
VALUES (?, 'Sina', datetime('now','localtime'))
`).run(oldToken);
}
db.prepare("DELETE FROM admin_settings WHERE key='koepi_share_token'").run();
}
} }
module.exports = db; module.exports = db;

View File

@@ -57,7 +57,7 @@ function parseDevice(ua) {
// Klicks/Tab-Wechseln hintereinander, spart nebenbei auch unnötige // Klicks/Tab-Wechseln hintereinander, spart nebenbei auch unnötige
// Geolocation-Abfragen). // Geolocation-Abfragen).
const DEDUP_WINDOW_MINUTES = 5; const DEDUP_WINDOW_MINUTES = 5;
function logPublicAccess({ linkType, path, ip, userAgent }) { function logPublicAccess({ linkType, path, ip, userAgent, linkName }) {
try { try {
const recent = db.prepare(` const recent = db.prepare(`
SELECT 1 FROM public_access_log SELECT 1 FROM public_access_log
@@ -73,9 +73,9 @@ function logPublicAccess({ linkType, path, ip, userAgent }) {
lookupLocation(ip).then(location => { lookupLocation(ip).then(location => {
try { try {
db.prepare(` db.prepare(`
INSERT INTO public_access_log (link_type, path, ip, location, device, created_at) INSERT INTO public_access_log (link_type, path, ip, location, device, link_name, created_at)
VALUES (?, ?, ?, ?, ?, datetime('now','localtime')) VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime'))
`).run(linkType || '', path || '', ip || '', location || '', device || ''); `).run(linkType || '', path || '', ip || '', location || '', device || '', linkName || '');
} catch (e) { } catch (e) {
console.error('Public-Access-Log Fehler:', e.message); console.error('Public-Access-Log Fehler:', e.message);
} }

View File

@@ -549,7 +549,7 @@ router.get('/logs', authenticate, requireAdmin, (req, res) => {
})); }));
const accessRows = db.prepare(` const accessRows = db.prepare(`
SELECT id, link_type, path, ip, location, device, created_at SELECT id, link_type, path, ip, location, device, link_name, created_at
FROM public_access_log FROM public_access_log
ORDER BY id DESC ORDER BY id DESC
LIMIT ? LIMIT ?
@@ -562,6 +562,7 @@ router.get('/logs', authenticate, requireAdmin, (req, res) => {
ip: r.ip, ip: r.ip,
location: r.location, location: r.location,
device: r.device, device: r.device,
linkName: r.link_name,
})); }));
const combined = [...pushRows, ...accessRows] const combined = [...pushRows, ...accessRows]

View File

@@ -829,16 +829,17 @@ router.post('/run-daily-check', authenticate, async (req, res) => {
} catch(e) { res.status(500).json({ error: e.message }); } } catch(e) { res.status(500).json({ error: e.message }); }
}); });
// ── Öffentlicher Teilen-Link (kein Login nötig) ───────────────────────────── // ── Öffentliche Teilen-Links (kein Login nötig) ─────────────────────────────
// Sicherheitsprinzip: die öffentlichen Routen unten liefern AUSSCHLIESSLICH // Sicherheitsprinzip: die öffentlichen Routen unten liefern AUSSCHLIESSLICH
// bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein // bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein
// Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP // Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP
// ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten // ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten
// marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten. // marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten.
const KOEPI_SHARE_TOKEN_KEY = 'koepi_share_token'; //
// Mehrere benannte Links gleichzeitig möglich (z.B. einer pro Person), damit
function getShareToken() { // in den Logs nachvollziehbar ist, WER welchen Link benutzt hat.
return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SHARE_TOKEN_KEY)?.value || null; function getShareLinkByToken(token) {
return db.prepare('SELECT id, name FROM koepi_share_links WHERE token=?').get(token) || null;
} }
// Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst // Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst
@@ -863,26 +864,40 @@ setInterval(() => {
} }
}, 5 * 60 * 1000); }, 5 * 60 * 1000);
// GET /share-link aktuellen Link anzeigen (Admin) // GET /share-links alle benannten Links auflisten (Admin)
router.get('/share-link', authenticate, (req, res) => { router.get('/share-links', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const token = getShareToken(); const links = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links ORDER BY created_at DESC').all()
res.json({ token, url: token ? `${req.protocol}://${req.get('host')}/kp/${token}` : null }); .map(l => ({ ...l, url: `${req.protocol}://${req.get('host')}/kp/${l.token}` }));
res.json({ links });
}); });
// POST /share-link neuen Link erzeugen (macht einen evtl. vorhandenen alten // POST /share-links neuen benannten Link erstellen (Admin)
// automatisch ungültig, da nur ein Token gleichzeitig gespeichert wird) router.post('/share-links', authenticate, (req, res) => {
router.post('/share-link', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
const name = (req.body?.name || '').trim();
if (!name) return res.status(400).json({ error: 'Name fehlt' });
const token = crypto.randomBytes(20).toString('hex'); const token = crypto.randomBytes(20).toString('hex');
db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(KOEPI_SHARE_TOKEN_KEY, token); db.prepare(`INSERT INTO koepi_share_links (token, name, created_at) VALUES (?, ?, datetime('now','localtime'))`).run(token, name);
res.json({ token, url: `${req.protocol}://${req.get('host')}/kp/${token}` }); const link = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links WHERE token=?').get(token);
res.json({ ...link, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
}); });
// POST /share-link/disable Link deaktivieren // POST /share-links/:id/reset Token dieses Links neu erzeugen (Name bleibt,
router.post('/share-link/disable', authenticate, (req, res) => { // alter Link wird ungültig)
router.post('/share-links/:id/reset', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
db.prepare('DELETE FROM admin_settings WHERE key=?').run(KOEPI_SHARE_TOKEN_KEY); const existing = db.prepare('SELECT * FROM koepi_share_links WHERE id=?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'Nicht gefunden' });
const token = crypto.randomBytes(20).toString('hex');
db.prepare('UPDATE koepi_share_links SET token=? WHERE id=?').run(token, req.params.id);
res.json({ id: existing.id, token, name: existing.name, url: `${req.protocol}://${req.get('host')}/kp/${token}` });
});
// DELETE /share-links/:id Link löschen (Admin)
router.delete('/share-links/:id', authenticate, (req, res) => {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' });
db.prepare('DELETE FROM koepi_share_links WHERE id=?').run(req.params.id);
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -891,8 +906,8 @@ router.post('/share-link/disable', authenticate, (req, res) => {
// damit die installierte App direkt wieder auf der öffentlichen Seite landet // damit die installierte App direkt wieder auf der öffentlichen Seite landet
// statt auf dem normalen Login der Haupt-App. // statt auf dem normalen Login der Haupt-App.
router.get('/public/:token/manifest.json', (req, res) => { router.get('/public/:token/manifest.json', (req, res) => {
const active = getShareToken(); const link = getShareLinkByToken(req.params.token);
if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' }); if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
res.setHeader('Content-Type', 'application/manifest+json'); res.setHeader('Content-Type', 'application/manifest+json');
res.json({ res.json({
name: 'Dickens Prospekte', name: 'Dickens Prospekte',
@@ -914,8 +929,8 @@ router.get('/public/:token/manifest.json', (req, res) => {
// GET /public/:token/offers öffentlich, nur Cache, kein Login // GET /public/:token/offers öffentlich, nur Cache, kein Login
router.get('/public/:token/offers', publicRateLimit, async (req, res) => { router.get('/public/:token/offers', publicRateLimit, async (req, res) => {
const active = getShareToken(); const link = getShareLinkByToken(req.params.token);
if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' }); if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
try { try {
const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route
res.json(result); res.json(result);
@@ -924,9 +939,9 @@ router.get('/public/:token/offers', publicRateLimit, async (req, res) => {
// GET /public/:token/prospekte öffentlich, nur Cache, kein Login // GET /public/:token/prospekte öffentlich, nur Cache, kein Login
router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => { router.get('/public/:token/prospekte', publicRateLimit, async (req, res) => {
const active = getShareToken(); const link = getShareLinkByToken(req.params.token);
if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' }); if (!link) return res.status(404).json({ error: 'Nicht gefunden' });
logPublicAccess({ linkType: 'koepi_share', path: `/kp/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'] }); logPublicAccess({ linkType: 'koepi_share', path: `/kp/${req.params.token}`, ip: req.ip, userAgent: req.headers['user-agent'], linkName: link.name });
try { try {
const result = await scrapeProspekte(); const result = await scrapeProspekte();
res.json(result); res.json(result);

View File

@@ -3312,6 +3312,7 @@ function PushLogs({ toast }) {
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', gap:8, marginBottom:3, flexWrap:'wrap' }}> <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 }}> <span style={{ color:'#4ecdc4', fontFamily:"'Space Mono',monospace", fontSize:11, fontWeight:700 }}>
{PUBLIC_LINK_LABELS[l.linkType] || '🔗 Öffentlicher Link besucht'} {PUBLIC_LINK_LABELS[l.linkType] || '🔗 Öffentlicher Link besucht'}
{l.linkName && <span style={{ color:'#ffe66d' }}> {l.linkName}</span>}
</span> </span>
<span style={{ display:'flex', alignItems:'center', gap:8 }}> <span style={{ display:'flex', alignItems:'center', gap:8 }}>
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}> <span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>

View File

@@ -262,35 +262,58 @@ export default function Koepi({ toast }) {
finally { setResolvingStores(false); } finally { setResolvingStores(false); }
}; };
const [shareInfo, setShareInfo] = useState(null); // { token, url } | null const [shareLinks, setShareLinks] = useState([]);
const [shareOpen, setShareOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false);
const [shareBusy, setShareBusy] = useState(false); const [shareBusy, setShareBusy] = useState(false);
useEffect(() => { const [newLinkName, setNewLinkName] = useState('');
if (!isAdmin) return;
api('/tools/koepi/share-link').then(setShareInfo).catch(()=>{}); const loadShareLinks = () => {
}, [isAdmin]); api('/tools/koepi/share-links').then(r => setShareLinks(r.links || [])).catch(()=>{});
};
useEffect(() => { if (isAdmin) loadShareLinks(); }, [isAdmin]);
const createShareLink = async () => { const createShareLink = async () => {
const name = newLinkName.trim();
if (!name) { toast?.('Bitte einen Namen eingeben','error'); return; }
setShareBusy(true); setShareBusy(true);
try { try {
const r = await api('/tools/koepi/share-link', { method:'POST', body:{} }); await api('/tools/koepi/share-links', { method:'POST', body:{ name } });
setShareInfo(r); setNewLinkName('');
toast('🔗 Neuer Link erzeugt — alter Link ist damit ungültig'); toast(`🔗 Link für "${name}" erstellt`);
loadShareLinks();
} catch(e) { toast?.(e.message||'Fehler','error'); } } catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setShareBusy(false); } finally { setShareBusy(false); }
}; };
const disableShareLink = async () => { const resetShareLink = async (id, name) => {
if (!window.confirm(`Link für "${name}" wirklich zurücksetzen? Der alte Link funktioniert danach nicht mehr.`)) return;
setShareBusy(true); setShareBusy(true);
try { try {
await api('/tools/koepi/share-link/disable', { method:'POST', body:{} }); await api(`/tools/koepi/share-links/${id}/reset`, { method:'POST', body:{} });
setShareInfo({ token:null, url:null }); toast(`🔄 Link für "${name}" zurückgesetzt`);
toast('🔗 Link deaktiviert'); loadShareLinks();
} catch(e) { toast?.(e.message||'Fehler','error'); } } catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setShareBusy(false); } finally { setShareBusy(false); }
}; };
const copyShareLink = async () => { const deleteShareLink = async (id, name) => {
if (!shareInfo?.url) return; if (!window.confirm(`Link für "${name}" wirklich löschen?`)) return;
try { await navigator.clipboard.writeText(shareInfo.url); toast('Link kopiert'); } setShareBusy(true);
catch { toast?.('Kopieren nicht möglich — bitte manuell markieren','error'); } try {
await api(`/tools/koepi/share-links/${id}`, { method:'DELETE' });
toast(`🗑 Link für "${name}" gelöscht`);
loadShareLinks();
} catch(e) { toast?.(e.message||'Fehler','error'); }
finally { setShareBusy(false); }
};
const copyLink = async (url) => {
try { await navigator.clipboard.writeText(url); toast('Link kopiert'); return; } catch {}
try {
const ta = document.createElement('textarea');
ta.value = url; ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta); ta.focus(); ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
toast?.(ok ? 'Link kopiert' : 'Kopieren nicht möglich', ok ? 'success' : 'error');
} catch { toast?.('Kopieren nicht möglich','error'); }
}; };
return ( return (
@@ -310,37 +333,52 @@ export default function Koepi({ toast }) {
{resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'} {resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'}
</button> </button>
<button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>🗑 Cache leeren</button> <button onClick={clearCache} style={{ ...S.btn('#666666',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>🗑 Cache leeren</button>
<button onClick={()=>setShareOpen(v=>!v)} style={{ ...S.btn(shareInfo?.token?'#ffe66d':'#888888',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}> <button onClick={()=>setShareOpen(v=>!v)} style={{ ...S.btn(shareLinks.length?'#ffe66d':'#888888',true), fontSize:10, flexShrink:0, whiteSpace:'nowrap' }}>
🔗 Teilen{shareInfo?.token ? ' (aktiv)' : ''} 🔗 Teilen{shareLinks.length ? ` (${shareLinks.length})` : ''}
</button> </button>
</div> </div>
)} )}
{isAdmin && shareOpen && ( {isAdmin && shareOpen && (
<div style={{ ...S.card, marginTop:10 }}> <div style={{ ...S.card, marginTop:10 }}>
<div style={{ ...S.sub, marginBottom:10 }}> <div style={{ ...S.sub, marginBottom:10 }}>
Öffentlicher, schreibgeschützter Link ohne Login zeigt zuerst die Prospekte, man kann dort Öffentliche, schreibgeschützte Links ohne Login zeigen zuerst die Prospekte, man kann dort
auch zu den Angeboten wechseln. Keine Admin-Funktionen sichtbar/erreichbar über diesen Link. auch zu den Angeboten wechseln. Keine Admin-Funktionen sichtbar/erreichbar über diese Links.
Du kannst mehrere Links gleichzeitig anlegen (z.B. einen pro Person) in den Logs siehst du
dann, wer welchen Link benutzt hat.
</div> </div>
{shareInfo?.token ? (
<> {shareLinks.length > 0 && (
<div style={{ display:'flex', gap:8, marginBottom:10, flexWrap:'wrap' }}> <div style={{ display:'flex', flexDirection:'column', gap:8, marginBottom:14 }}>
<input readOnly value={shareInfo.url} onClick={e=>e.target.select()} style={{ ...S.inp, flex:1, minWidth:200, fontSize:11 }}/> {shareLinks.map(l => (
<button onClick={copyShareLink} style={{ ...S.btn('#4ecdc4') }}>📋 Kopieren</button> <div key={l.id} style={{ border:'1px solid rgba(255,255,255,0.08)', borderRadius:8, padding:'8px 10px' }}>
</div> <div style={{ color:'#ffe66d', fontFamily:"'Space Mono',monospace", fontSize:12, fontWeight:700, marginBottom:4 }}>
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}> {l.name}
<button onClick={createShareLink} disabled={shareBusy} style={{ ...S.btn('#f59e0b'), opacity:shareBusy?0.5:1 }}> </div>
{shareBusy?'…':'🔄 Neuen Link erzeugen (alter wird ungültig)'} <div style={{ display:'flex', gap:8, marginBottom:6, flexWrap:'wrap' }}>
</button> <input readOnly value={l.url} onClick={e=>e.target.select()} style={{ ...S.inp, flex:1, minWidth:180, fontSize:10 }}/>
<button onClick={disableShareLink} disabled={shareBusy} style={{ ...S.btn('#f87171'), opacity:shareBusy?0.5:1 }}> <button onClick={()=>copyLink(l.url)} style={{ ...S.btn('#4ecdc4'), fontSize:11 }}>📋</button>
{shareBusy?'…':'🚫 Link deaktivieren'} </div>
</button> <div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
</div> <button onClick={()=>resetShareLink(l.id, l.name)} disabled={shareBusy} style={{ ...S.btn('#f59e0b'), fontSize:10, opacity:shareBusy?0.5:1 }}>
</> 🔄 Zurücksetzen
) : ( </button>
<button onClick={createShareLink} disabled={shareBusy} style={{ ...S.btn('#4ecdc4'), opacity:shareBusy?0.5:1 }}> <button onClick={()=>deleteShareLink(l.id, l.name)} disabled={shareBusy} style={{ ...S.btn('#f87171'), fontSize:10, opacity:shareBusy?0.5:1 }}>
{shareBusy?'…':'🔗 Link erzeugen'} 🗑 Löschen
</button> </button>
</div>
</div>
))}
</div>
)} )}
<div style={{ display:'flex', gap:8, flexWrap:'wrap' }}>
<input value={newLinkName} onChange={e=>setNewLinkName(e.target.value)}
onKeyDown={e=>e.key==='Enter' && createShareLink()}
placeholder="Name (z.B. Sina)" style={{ ...S.inp, flex:1, minWidth:140 }}/>
<button onClick={createShareLink} disabled={shareBusy || !newLinkName.trim()} style={{ ...S.btn('#4ecdc4'), opacity:(shareBusy || !newLinkName.trim())?0.5:1 }}>
{shareBusy?'…':' Neuen Link erstellen'}
</button>
</div>
</div> </div>
)} )}
</div> </div>