diff --git a/backend/src/db.js b/backend/src/db.js index e554b36..856b492 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -677,6 +677,34 @@ db.exec(` const cols = db.pragma('table_info(public_access_log)').map(c => c.name); if (!cols.includes('device')) 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; diff --git a/backend/src/publicAccessLog.js b/backend/src/publicAccessLog.js index de30b95..e57d775 100644 --- a/backend/src/publicAccessLog.js +++ b/backend/src/publicAccessLog.js @@ -57,7 +57,7 @@ function parseDevice(ua) { // Klicks/Tab-Wechseln hintereinander, spart nebenbei auch unnötige // Geolocation-Abfragen). const DEDUP_WINDOW_MINUTES = 5; -function logPublicAccess({ linkType, path, ip, userAgent }) { +function logPublicAccess({ linkType, path, ip, userAgent, linkName }) { try { const recent = db.prepare(` SELECT 1 FROM public_access_log @@ -73,9 +73,9 @@ function logPublicAccess({ linkType, path, ip, userAgent }) { lookupLocation(ip).then(location => { try { db.prepare(` - INSERT INTO public_access_log (link_type, path, ip, location, device, created_at) - VALUES (?, ?, ?, ?, ?, datetime('now','localtime')) - `).run(linkType || '', path || '', ip || '', location || '', device || ''); + INSERT INTO public_access_log (link_type, path, ip, location, device, link_name, created_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime')) + `).run(linkType || '', path || '', ip || '', location || '', device || '', linkName || ''); } catch (e) { console.error('Public-Access-Log Fehler:', e.message); } diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 004a216..83e0b0c 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -549,7 +549,7 @@ router.get('/logs', authenticate, requireAdmin, (req, res) => { })); 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 ORDER BY id DESC LIMIT ? @@ -562,6 +562,7 @@ router.get('/logs', authenticate, requireAdmin, (req, res) => { ip: r.ip, location: r.location, device: r.device, + linkName: r.link_name, })); const combined = [...pushRows, ...accessRows] diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js index 0179d12..7b516d4 100644 --- a/backend/src/tools/koepi/routes.js +++ b/backend/src/tools/koepi/routes.js @@ -829,16 +829,17 @@ router.post('/run-daily-check', authenticate, async (req, res) => { } 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 // bereits gecachte Daten aus (kein forceRefresh, kein Puppeteer-Trigger, kein // Zugriff auf irgendeine Admin-Funktion) und sind zusätzlich pro IP // ratenbegrenzt — ein gefundener/erratener Link kann also weder einen echten // marktguru-Abruf erzwingen noch den Server sonst irgendwie belasten. -const KOEPI_SHARE_TOKEN_KEY = 'koepi_share_token'; - -function getShareToken() { - return db.prepare('SELECT value FROM admin_settings WHERE key=?').get(KOEPI_SHARE_TOKEN_KEY)?.value || null; +// +// Mehrere benannte Links gleichzeitig möglich (z.B. einer pro Person), damit +// in den Logs nachvollziehbar ist, WER welchen Link benutzt hat. +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 @@ -863,26 +864,40 @@ setInterval(() => { } }, 5 * 60 * 1000); -// GET /share-link – aktuellen Link anzeigen (Admin) -router.get('/share-link', authenticate, (req, res) => { +// GET /share-links – alle benannten Links auflisten (Admin) +router.get('/share-links', authenticate, (req, res) => { if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); - const token = getShareToken(); - res.json({ token, url: token ? `${req.protocol}://${req.get('host')}/kp/${token}` : null }); + const links = db.prepare('SELECT id, token, name, created_at FROM koepi_share_links ORDER BY created_at DESC').all() + .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 -// automatisch ungültig, da nur ein Token gleichzeitig gespeichert wird) -router.post('/share-link', authenticate, (req, res) => { +// POST /share-links – neuen benannten Link erstellen (Admin) +router.post('/share-links', authenticate, (req, res) => { 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'); - db.prepare('INSERT OR REPLACE INTO admin_settings (key, value) VALUES (?, ?)').run(KOEPI_SHARE_TOKEN_KEY, token); - res.json({ token, url: `${req.protocol}://${req.get('host')}/kp/${token}` }); + db.prepare(`INSERT INTO koepi_share_links (token, name, created_at) VALUES (?, ?, datetime('now','localtime'))`).run(token, name); + 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 -router.post('/share-link/disable', authenticate, (req, res) => { +// POST /share-links/:id/reset – Token dieses Links neu erzeugen (Name bleibt, +// 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' }); - 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 }); }); @@ -891,8 +906,8 @@ router.post('/share-link/disable', authenticate, (req, res) => { // damit die installierte App direkt wieder auf der öffentlichen Seite landet // statt auf dem normalen Login der Haupt-App. router.get('/public/:token/manifest.json', (req, res) => { - const active = getShareToken(); - if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' }); + const link = getShareLinkByToken(req.params.token); + if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); res.setHeader('Content-Type', 'application/manifest+json'); res.json({ name: 'Dickens Prospekte', @@ -914,8 +929,8 @@ router.get('/public/:token/manifest.json', (req, res) => { // GET /public/:token/offers – öffentlich, nur Cache, kein Login router.get('/public/:token/offers', publicRateLimit, async (req, res) => { - const active = getShareToken(); - if (!active || req.params.token !== active) return res.status(404).json({ error: 'Nicht gefunden' }); + const link = getShareLinkByToken(req.params.token); + if (!link) return res.status(404).json({ error: 'Nicht gefunden' }); try { const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route 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 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, userAgent: req.headers['user-agent'] }); + const link = getShareLinkByToken(req.params.token); + 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'], linkName: link.name }); try { const result = await scrapeProspekte(); res.json(result); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c064583..42e1717 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3312,6 +3312,7 @@ function PushLogs({ toast }) {
{PUBLIC_LINK_LABELS[l.linkType] || '🔗 Öffentlicher Link besucht'} + {l.linkName && — {l.linkName}} diff --git a/frontend/src/tools/koepi.jsx b/frontend/src/tools/koepi.jsx index c7e04b2..b0acaab 100644 --- a/frontend/src/tools/koepi.jsx +++ b/frontend/src/tools/koepi.jsx @@ -262,35 +262,58 @@ export default function Koepi({ toast }) { finally { setResolvingStores(false); } }; - const [shareInfo, setShareInfo] = useState(null); // { token, url } | null + const [shareLinks, setShareLinks] = useState([]); const [shareOpen, setShareOpen] = useState(false); const [shareBusy, setShareBusy] = useState(false); - useEffect(() => { - if (!isAdmin) return; - api('/tools/koepi/share-link').then(setShareInfo).catch(()=>{}); - }, [isAdmin]); + const [newLinkName, setNewLinkName] = useState(''); + + const loadShareLinks = () => { + api('/tools/koepi/share-links').then(r => setShareLinks(r.links || [])).catch(()=>{}); + }; + useEffect(() => { if (isAdmin) loadShareLinks(); }, [isAdmin]); + const createShareLink = async () => { + const name = newLinkName.trim(); + if (!name) { toast?.('Bitte einen Namen eingeben','error'); return; } setShareBusy(true); try { - const r = await api('/tools/koepi/share-link', { method:'POST', body:{} }); - setShareInfo(r); - toast('🔗 Neuer Link erzeugt — alter Link ist damit ungültig'); + await api('/tools/koepi/share-links', { method:'POST', body:{ name } }); + setNewLinkName(''); + toast(`🔗 Link für "${name}" erstellt`); + loadShareLinks(); } catch(e) { toast?.(e.message||'Fehler','error'); } 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); try { - await api('/tools/koepi/share-link/disable', { method:'POST', body:{} }); - setShareInfo({ token:null, url:null }); - toast('🔗 Link deaktiviert'); + await api(`/tools/koepi/share-links/${id}/reset`, { method:'POST', body:{} }); + toast(`🔄 Link für "${name}" zurückgesetzt`); + loadShareLinks(); } catch(e) { toast?.(e.message||'Fehler','error'); } finally { setShareBusy(false); } }; - const copyShareLink = async () => { - if (!shareInfo?.url) return; - try { await navigator.clipboard.writeText(shareInfo.url); toast('Link kopiert'); } - catch { toast?.('Kopieren nicht möglich — bitte manuell markieren','error'); } + const deleteShareLink = async (id, name) => { + if (!window.confirm(`Link für "${name}" wirklich löschen?`)) return; + setShareBusy(true); + 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 ( @@ -310,37 +333,52 @@ export default function Koepi({ toast }) { {resolvingStores ? '⏳ läuft…' : '🏪 Filial-Liste neu auflösen'} -
)} {isAdmin && shareOpen && (
- Öffentlicher, schreibgeschützter Link ohne Login — zeigt zuerst die Prospekte, man kann dort - auch zu den Angeboten wechseln. Keine Admin-Funktionen sichtbar/erreichbar über diesen Link. + Öffentliche, schreibgeschützte Links ohne Login — zeigen zuerst die Prospekte, man kann dort + 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.
- {shareInfo?.token ? ( - <> -
- e.target.select()} style={{ ...S.inp, flex:1, minWidth:200, fontSize:11 }}/> - -
-
- - -
- - ) : ( - + + {shareLinks.length > 0 && ( +
+ {shareLinks.map(l => ( +
+
+ {l.name} +
+
+ e.target.select()} style={{ ...S.inp, flex:1, minWidth:180, fontSize:10 }}/> + +
+
+ + +
+
+ ))} +
)} + +
+ setNewLinkName(e.target.value)} + onKeyDown={e=>e.key==='Enter' && createShareLink()} + placeholder="Name (z.B. Sina)" style={{ ...S.inp, flex:1, minWidth:140 }}/> + +
)}