Files
Webklar-Kundenbereich/server/middleware/kundenansichtGuard.js
T
ClaudeandClaude Opus 5.5 e87140e5ba Merge remote-tracking branch 'origin/main' into feature/oeffnungszeiten
Zusammenführung von TP2-Abschluss und Webshop (origin/main cab9c33) mit Öffnungszeiten live. Beide
Funktionen bleiben vollständig: jeder Konflikt ist als Vereinigung gelöst, Reihenfolge der Funktionsschlüssel
überall news, termine, shop, zeiten.

Konfliktdateien (28): public/app.js, dashboard.html, funktionen.js, widerrufen.html; scripts/stripe-products.mjs;
server/config.js, index.js, middleware/kundenansichtGuard.js, routes/abo.js, routes/admin/funktionenAdmin.js,
routes/funktionen.js, services/customerAdmin.js, customerViewLog.js, featureRegistry.js, retention.js,
siteFeatures.js, stripeBilling.js; test/customerViewLog, featureFreischaltung, featureRegistry, funktionenRoutes,
funktionenTermine, kundenansichtGuard, mailTemplates, publicForms, siteFeatures, stripeProductsScript (je
.test.js) und test/support/schema.js.

Entscheidungen: Mailvorlagen G01–G15, K01–K30, T01–T21 lückenlos (K24–K26 Webshop, K27–K30 Öffnungszeiten);
Stripe-Katalog mit shopSetup und zeitenSetup; Widerruf kennt shop und zeiten; ABO_FUNKTIONEN bleibt analyse
und termine (kein zeiten); activate() schreibt die Zeiten-Felder nur über extra; kein Halter von
feature-activate:<id> ruft disable() auf; die Aufbewahrungs-Löschung löscht Zeiten-Daten vor dem Abschalten und
hält den Kunden bei einem Fehler (held); die Admin-Antwort trägt ersatzTicket und warnung; Hash-Routen webshop
und zeiten, Ansichten view-webshop und view-zeiten; assertOwnProject behält die Meldung von main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 06:12:23 +00:00

80 lines
4.8 KiB
JavaScript

import { parsePortalSession } from './session.js'
import { VIEW_EXPIRED_MESSAGE, endKundenansicht, isExpired, isKundenansicht, logOnce } from '../services/kundenansicht.js'
/**
* Waechter der Kundenansicht vor allen Kunden-Routen (Spec 2026-10-01, Abschnitt 3):
* 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg; ebenso alte Ansichten ohne viewId ("Als Kunde ansehen" aus
* dem frueheren Admin-Panel)
* 2. fremde Kennung im Header X-Kundenansicht -> 409 view_replaced (Tab einer anderen Kundenansicht)
* 3. Lesen ist erlaubt; Schreiben nur mit passender Kennung und nur, was auf der Erlaubnisliste steht.
* Neue Schreib-Routen sind damit in der Kundenansicht gesperrt, bis sie bewusst auf die Liste kommen.
*/
export const KUNDENANSICHT_ALLOWED = [
['POST', /^\/api\/news\/?$/],
['POST', /^\/api\/news\/image\/?$/],
['PATCH', /^\/api\/news\/[^/]+\/?$/],
['DELETE', /^\/api\/news\/[^/]+\/?$/],
['POST', /^\/api\/funktionen\/analyse\/heatmap-link\/?$/],
// Terminplaner: Einstellungen und Termine kosten nichts und sind keine Erklaerungen im Namen des Kunden (Spec 2026-10-02,
// Abschnitt 9). Genau diese sechs Pfade, jeder einzeln (keine Wildcard, Spec 2026-10-03): alles andere unter /api/termine,
// auch der Fragebogen (PUT /fragebogen, Bestellung), ist in der Kundenansicht gesperrt, ohne dass es einen Eintrag braucht.
['POST', /^\/api\/termine\/einstellungen\/vorlage\/?$/],
['POST', /^\/api\/termine\/ressourcen\/[^/]+\/feed\/?$/],
['POST', /^\/api\/termine\/buchungen\/?$/],
['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/],
['PATCH', /^\/api\/termine\/einstellungen\/?$/],
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/],
// Webshop: Artikel, Einstellungen, Rechtstexte-Entwurf und Bilder darf das Team mit dem Kunden ausfuellen (protokolliert). Genau diese
// sieben Pfade, jeder einzeln: Anfrage, Oeffnen, Bestaetigen der Rechtstexte (der Haendler bestaetigt persoenlich), Stripe und
// Erstattungen bleiben gesperrt, ohne dass es einen Eintrag braucht.
['POST', /^\/api\/shop\/produkte\/?$/],
['POST', /^\/api\/shop\/produkte\/sortierung\/?$/],
['PUT', /^\/api\/shop\/produkte\/[^/]+\/?$/],
['DELETE', /^\/api\/shop\/produkte\/[^/]+\/?$/],
['PUT', /^\/api\/shop\/einstellungen\/?$/],
['PUT', /^\/api\/shop\/rechtstexte\/?$/],
['POST', /^\/api\/shop\/bild\/?$/],
// Oeffnungszeiten: Speichern im Namen des Kunden (Spec 2026-10-04, Abschnitt 3.4); den WhatsApp-Haken sperrt die Route selbst (403).
// Nur dieser eine Pfad: die Vorschau (GET) braucht keinen Eintrag, alles andere unter /api/zeiten ist gesperrt.
['PUT', /^\/api\/zeiten\/[^/]+\/?$/],
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
['POST', /^\/api\/auth\/logout\/?$/],
['POST', /^\/api\/auth\/login\/?$/],
]
// Eigene Pruefung (Ticketsystem-JWT) oder Ablauf/Wechsel selbst behandelt (me, Einloesen); oeffentliche Schnittstellen
// (/api/public/: Pinnwand, Terminplaner fuer Gaeste) kennen keine Sitzung und ignorieren ein Kundenansicht-Cookie im Browser
const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/public/']
const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht']
const READ_METHODS = ['GET', 'HEAD', 'OPTIONS']
const replaced = (res, session) =>
res.status(409).json({ code: 'view_replaced', kunde: session.name || '', error: 'Diese Ansicht wurde durch eine andere Kundenansicht ersetzt.' })
export async function kundenansichtGuard(req, res, next) {
// Express routet ohne Ruecksicht auf Gross-/Kleinschreibung (/API/news trifft /api/news): hier genauso pruefen
const pathname = String(req.path || '').toLowerCase()
if (!pathname.startsWith('/api/')) return next()
if (PASS_PREFIXES.some((p) => pathname.startsWith(p)) || PASS_EXACT.includes(pathname.replace(/\/$/, ''))) return next()
const session = parsePortalSession(req)
if (!session) return next()
const current = isKundenansicht(session)
// Abgelaufen oder alte Ansicht ohne Kennung (frueheres Admin-Panel): beenden, nichts mehr lesen oder schreiben
if (session.viewAs && (!current || isExpired(session))) {
endKundenansicht(res)
if (current) await logOnce(session, 'abgelaufen')
return res.status(401).json({ code: 'view_expired', error: VIEW_EXPIRED_MESSAGE })
}
// Tab einer Kundenansicht, deren Cookie inzwischen zu etwas anderem gehoert (andere Kundenansicht,
// Kunden-Login): keine fremden Daten in diesen Tab
const header = req.get('X-Kundenansicht')
if (header && header !== session.viewId) return replaced(res, session)
if (!session.viewAs) return next()
if (READ_METHODS.includes(req.method)) return next()
if (!header) return replaced(res, session)
if (KUNDENANSICHT_ALLOWED.some(([method, re]) => method === req.method && re.test(pathname))) return next()
return res.status(403).json({ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' })
}