import { createHash } from 'node:crypto' import { Router } from 'express' import { darfZeitenAnfrage } from '../services/zeiten/limits.js' import { oeffentlicheZeitenAntwort } from '../services/zeiten/oeffentlich.js' /** * Oeffentliche Lese-API der Oeffnungszeiten fuer das Skript auf den Kundenwebsites: /api/public/zeiten/v1/:siteKey * (Spezifikation 2026-10-04-oeffnungszeiten-live-design.md, Abschnitt 4.1). Ohne Session, eigenes CORS (`*`, nur GET und HEAD), * Antworten enthalten nie Kunden-, Projekt-, Ticket- oder Stripe-IDs (das stellt services/zeiten/oeffentlich.js sicher). * * - Reihenfolge: CORS-Kopfzeilen (auch bei jedem Fehler, damit das Skript sie lesen kann) => OPTIONS 204 => 405 fuer alles ausser * GET/HEAD => Grenze 120 Abrufe je Minute und IP (services/zeiten/limits.js) => Antwort. * - Der Speicher (30 s, LRU 500), die Pruefung des siteKey-Formats und alle { aktiv: false }-Faelle (Schalter aus, unbekannter * Schluessel, geloeschter Kunde, Zustand nicht setup/active) liegen im Dienst; hier wird nur gesendet. * - Das weiche ETag hasht den Body OHNE `stand`: es bleibt gleich, solange sich die Daten nicht aendern, auch wenn die Antwort * neu gebaut wird. `Access-Control-Expose-Headers: Date` laesst das Skript die Serverzeit lesen (Uhrabweichung). * - Ein Fehler des Dienstes (Appwrite) wird zu 503 mit fester Meldung, nie mit dem Text des Fehlers. */ const router = Router() const TEXT_ZU_VIELE = 'Zu viele Anfragen. Bitte versuch es gleich noch einmal.' const TEXT_NICHT_ERREICHBAR = 'Vorübergehend nicht erreichbar.' // Uhr der Schnittstelle: Tests setzen eine feste Uhr (die Dienste nehmen `now` immer als Parameter). let uhr = () => new Date() export function setzeUhrFuerTests(neueUhr) { uhr = typeof neueUhr === 'function' ? neueUhr : () => new Date() } export function resetPublicZeitenForTests() { uhr = () => new Date() } router.use((req, res, next) => { res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, If-None-Match', 'Access-Control-Expose-Headers': 'Date', 'Access-Control-Max-Age': '86400', }) if (req.method === 'OPTIONS') return res.sendStatus(204) if (req.method !== 'GET' && req.method !== 'HEAD') return res.status(405).json({ error: 'Methode nicht erlaubt' }) // die App steht hinter Traefik, 'trust proxy' ist gesetzt -> req.ip ist die Client-IP if (!darfZeitenAnfrage(req.ip, uhr())) { res.set({ 'Retry-After': '60', 'Cache-Control': 'no-store' }) return res.status(429).json({ error: TEXT_ZU_VIELE }) } return next() }) /** Sendet den Body mit weichem ETag (sha1, base64url) ueber den Body ohne `stand`; 304 bei If-None-Match (auch `*`). */ function sendeMitEtag(req, res, body) { const etag = `W/"${createHash('sha1').update(JSON.stringify({ ...body, stand: undefined })).digest('base64url')}"` res.set({ ETag: etag, 'Cache-Control': 'public, max-age=60', 'X-Content-Type-Options': 'nosniff' }) const gesendet = String(req.get('If-None-Match') || '') .split(',') .map((s) => s.trim().replace(/^W\//, '')) if (gesendet.includes(etag.slice(2)) || gesendet.includes('*')) return res.status(304).end() return res.type('application/json').send(JSON.stringify(body)) } function antworteNichtErreichbar(res, err) { console.error(`[publicZeiten] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`) if (res.headersSent) return res.end() res.set('Cache-Control', 'no-store') return res.status(503).json({ error: TEXT_NICHT_ERREICHBAR }) } router.get('/:siteKey', async (req, res) => { try { return sendeMitEtag(req, res, await oeffentlicheZeitenAntwort(req.params.siteKey, uhr())) } catch (err) { return antworteNichtErreichbar(res, err) } }) router.use((req, res) => res.status(404).json({ error: 'Nicht gefunden' })) router.use((err, req, res, _next) => antworteNichtErreichbar(res, err)) export default router