Files
Webklar-Kundenbereich/server/routes/publicZeiten.js
T
ClaudeandClaude Opus 5.5 d1f9176c99 Öffnungszeiten: öffentliche Route
GET /api/public/zeiten/v1/:siteKey mit CORS *, OPTIONS 204, 405, weichem ETag
ohne stand, Cache-Control max-age=60, Expose-Headers Date, Grenze 120 je Minute
und IP (zeiten/limits.js), 503 ohne Details und 404 für unbekannte Pfade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 01:19:43 +00:00

82 lines
3.9 KiB
JavaScript

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