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>
82 lines
3.9 KiB
JavaScript
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
|