diff --git a/backend/src/tools/koepi/routes.js b/backend/src/tools/koepi/routes.js index 6740cf4..e168cd9 100644 --- a/backend/src/tools/koepi/routes.js +++ b/backend/src/tools/koepi/routes.js @@ -1,5 +1,6 @@ const express = require('express'); const https = require('https'); +const crypto = require('crypto'); const db = require('../../db'); const mqttClient = require('../../mqtt'); const { logPush } = require('../../pushLog'); @@ -180,16 +181,34 @@ function extractWeekInfo(name) { return m ? `(${m[0].replace(/\s+/g, ' ').trim()})` : ''; } -// Entfernt bekannten Stör-/Metatext aus dem angezeigten Prospekt-Titel (z.B. -// "(weekly)" bei ALDI). Liste bei Bedarf einfach erweitern, falls andere -// Ketten ähnlichen Text im name-Feld mitliefern. -const TITLE_NOISE_PATTERNS = [/\(weekly\)/gi]; +// Entfernt bekannten Stör-/Metatext aus dem angezeigten Prospekt-Titel: +// "(weekly)"-Zusätze und jede KW-Klammer (die steht ja schon separat als +// eigene weekInfo daneben, muss also nicht nochmal im Titel stehen — und dort +// hängt bei manchen Ketten zusätzlich technischer Datensatz-Text mit dran). +const TITLE_NOISE_PATTERNS = [/\(weekly\)/gi, /\([^)]*KW[^)]*\)/gi]; function cleanTitle(name) { let t = name || ''; for (const p of TITLE_NOISE_PATTERNS) t = t.replace(p, ''); return t.replace(/\s{2,}/g, ' ').trim(); } +// Fallback, falls eine Kette (z.B. REWE) gar keine KW-Angabe im Namen mitliefert: +// Kalenderwoche selbst aus dem Gültig-ab-Datum berechnen +function getISOWeekNumber(dateStr) { + const d = new Date(dateStr); + if (Number.isNaN(d.getTime())) return null; + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() + 4 - (d.getDay() || 7)); + const yearStart = new Date(d.getFullYear(), 0, 1); + return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); +} +function weekInfoWithFallback(name, validFromIso) { + const found = extractWeekInfo(name); + if (found) return found; + const week = validFromIso ? getISOWeekNumber(validFromIso) : null; + return week ? `(KW${week})` : ''; +} + // Löst die feste Liste gewünschter Filialen (LOCAL_STORE_SEED_LEAFLETS) einmalig // zu echten Store-IDs auf und speichert sie in der DB als Whitelist. Wird beim // ersten Scrape automatisch ausgeführt (falls die Tabelle leer ist) und kann @@ -293,7 +312,7 @@ async function fetchOffersViaApi() { const detail = detailByOfferId.get(r.id); const storeInfo = detail?.closestStore || { id: null, address: '', distanceMeters: null }; const validity0 = r.validityDates?.[0] || {}; - const weekInfo = extractWeekInfo(detail?.name); + const weekInfo = weekInfoWithFallback(detail?.name, validity0.from); const baseDateRange = validity0.from && validity0.to ? `${formatIsoDate(validity0.from)} - ${formatIsoDate(validity0.to)}` : formatIsoDate(validity0.to); @@ -545,11 +564,11 @@ async function scrapeProspekte() { if (!f.mainLeafletId) continue; const detail = await fetchLeafletDetail(f.mainLeafletId, leafletCache); const store = detail.closestStore; - const weekInfo = extractWeekInfo(detail.name); + const weekInfo = weekInfoWithFallback(detail.name, f.validFrom); all.push({ id: f.id, publisher: f.advertiser?.name || '', - title: cleanTitle(detail.name) || f.advertiser?.name || '', + title: f.advertiser?.name || cleanTitle(detail.name) || '', weekInfo, street: store.address || '', zip: MG_ZIP, @@ -779,6 +798,83 @@ 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) ───────────────────────────── +// 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; +} + +// Einfache In-Memory-Ratenbegrenzung pro IP (30 Anfragen/Minute) — bewusst +// simpel gehalten, reicht für dieses Nutzungsszenario, kein Redis o.ä. nötig +const publicRateLimits = new Map(); +function publicRateLimit(req, res, next) { + const ip = req.ip || 'unknown'; + const now = Date.now(); + const windowMs = 60000, maxReq = 30; + const arr = (publicRateLimits.get(ip) || []).filter(t => now - t < windowMs); + if (arr.length >= maxReq) return res.status(429).json({ error: 'Zu viele Anfragen, bitte kurz warten.' }); + arr.push(now); + publicRateLimits.set(ip, arr); + next(); +} +// Gelegentliches Aufräumen alter IP-Einträge, damit die Map nicht unbegrenzt wächst +setInterval(() => { + const now = Date.now(); + for (const [ip, arr] of publicRateLimits) { + const fresh = arr.filter(t => now - t < 60000); + if (fresh.length) publicRateLimits.set(ip, fresh); else publicRateLimits.delete(ip); + } +}, 5 * 60 * 1000); + +// GET /share-link – aktuellen Link anzeigen (Admin) +router.get('/share-link', 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 }); +}); + +// 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) => { + if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Kein Zugriff' }); + 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}` }); +}); + +// POST /share-link/disable – Link deaktivieren +router.post('/share-link/disable', 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); + res.json({ ok: true }); +}); + +// 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' }); + try { + const result = await scrapeMarktguru(false); // niemals forceRefresh über die öffentliche Route + res.json(result); + } catch (e) { res.status(500).json({ error: 'Angebote gerade nicht verfügbar' }); } +}); + +// 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' }); + try { + const result = await scrapeProspekte(); + res.json(result); + } catch (e) { res.status(500).json({ error: 'Prospekte gerade nicht verfügbar' }); } +}); + module.exports = router; module.exports.runDailyCheck = runDailyCheck; module.exports.refreshOffersOnly = refreshOffersOnly; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 36003c9..1141369 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3862,12 +3862,231 @@ function ScrollToTop({ scrollRef }) { } +// ── Öffentliche, schreibgeschützte KöPi-Ansicht (kein Login) ───────────────── +function koepiPublisherColor(name) { + if (!name) return '#555'; + const n = name.toLowerCase(); + if (n.includes('rewe')) return '#e2001a'; + if (n.includes('edeka') || n.includes('e center')) return '#e8a800'; + if (n.includes('netto')) return '#0057a8'; + if (n.includes('trinkgut')) return '#e87722'; + if (n.includes('penny')) return '#e2001a'; + if (n.includes('kaufland')) return '#e2001a'; + return '#888'; +} +function koepiRetailerLogo(name) { + if (!name) return null; + const n = name.toLowerCase(); + if (n.includes('netto') && n.includes('getränke')) return '/koepi/netto_getraenkemarkt.png'; + if (n.includes('netto')) return '/koepi/netto.png'; + if (n.includes('rewe dortmund')) return null; + if (n.includes('rewe')) return '/koepi/rewe.png'; + if (n.includes('edeka') || n.includes('e center')) return '/koepi/edeka.png'; + if (n.includes('trinkgut')) return '/koepi/trinkgut.png'; + if (n.includes('penny')) return '/koepi/penny.png'; + if (n.includes('lidl')) return '/koepi/lidl.png'; + if (n.includes('aldi')) return '/koepi/aldi.png'; + if (n.includes('hornbach')) return '/koepi/hornbach.png'; + if (n.includes('kaufland')) return '/koepi/kaufland.png'; + return null; +} +function koepiFmtDate(str) { + if (!str) return null; + try { return new Date(str).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' }); } + catch { return str; } +} +function koepiSortBeerFirst(arr) { + return [...arr].sort((a, b) => (b.hasKoenigPilsener?1:0) - (a.hasKoenigPilsener?1:0)); +} +function koepiGetISOWeek(dateStr) { + const d = new Date(dateStr); + d.setHours(0,0,0,0); + d.setDate(d.getDate() + 4 - (d.getDay() || 7)); + const yearStart = new Date(d.getFullYear(), 0, 1); + return { week: Math.ceil((((d - yearStart) / 86400000) + 1) / 7), year: d.getFullYear() }; +} +function koepiGroupByWeek(arr) { + const groups = new Map(); + for (const b of arr) { + if (!b.validFrom) { + if (!groups.has('unbekannt')) groups.set('unbekannt', { label:'Ohne Datum', sortKey:Infinity, items:[] }); + groups.get('unbekannt').items.push(b); + continue; + } + const { week, year } = koepiGetISOWeek(b.validFrom); + const key = `${year}-${week}`; + if (!groups.has(key)) groups.set(key, { label:`KW${week}`, sortKey:year*100+week, items:[] }); + groups.get(key).items.push(b); + } + return [...groups.values()].sort((a,b) => a.sortKey - b.sortKey); +} + +function PublicKoepiOfferCard({ offer }) { + const color = koepiPublisherColor(offer.retailer); + const logo = koepiRetailerLogo(offer.retailer); + return ( +