Terminplaner: Kunden-Schnittstelle – Nachbesserungen

- GET /zeiten?ohne=<Buchung>: Leistung, Dauer und Person der Buchung wie beim
  Verschieben (verschiebeZeiten exportiert), unbekannte/fremde/Sperre 404 (R42)
- Schreib-Begrenzung als gemeinsamer Baustein (writeLimiter.js) fuer News und
  Terminplaner statt kopierter Funktion (R43)
- Unlesbares Einstellungs-JSON: Kontext traegt die echte Version, Speichern
  ueberschreibt den Schaden statt dauerhaft 409
- U+FEFF als Escape statt als rohes Zeichen
- Tests: Mandantentrennung mit echtem zweitem Kunden, R42, Limiter, kaputtes JSON

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent e3e198fb1a
commit bb45d35ec7
8 files changed
+319 -65

No files matched your search

+5 -22
View File
@@ -7,11 +7,10 @@ import { uploadSlot } from './chat.js'
import { config } from '../config.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { logIfKundenansicht } from '../services/kundenansicht.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
const router = Router()
const HOUR_MS = 60 * 60 * 1000
const MAX_WRITES_PER_HOUR = 30
const IMAGE_FILE_RE = /^([a-z0-9-]+)-(l|s)\.webp$/
function fail(res, err) {
@@ -23,26 +22,10 @@ function fail(res, err) {
}
// Schreib-Limit je Kunde und Stunde (im Speicher, wie beim Chat); zaehlt POST/PATCH/DELETE, nicht die Foto-Uploads
const recentWrites = new Map()
function writeRateLimited(customerId) {
const now = Date.now()
for (const [key, times] of recentWrites) {
const live = times.filter((t) => now - t < HOUR_MS)
if (live.length === 0) recentWrites.delete(key)
else if (live.length !== times.length) recentWrites.set(key, live)
}
const list = recentWrites.get(customerId) || []
list.push(now)
recentWrites.set(customerId, list)
return list.length > MAX_WRITES_PER_HOUR
}
function limitWrites(req, res, next) {
if (writeRateLimited(getSessionCustomerId(req))) {
return res.status(429).json({ error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' })
}
return next()
}
const limitWrites = createWriteLimiter({ max: 30 }).middleware(
(req) => getSessionCustomerId(req),
'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.'
)
// Protokoll-Detail der Kundenansicht: Titel, sonst der Anfang des Textes
const postLabel = (post) => String(post?.title || '').trim() || String(post?.text || '').slice(0, 60)
+35 -29
View File
@@ -5,8 +5,10 @@ import { getDocument } from '../services/appwriteAdmin.js'
import { analysePrivacyData } from '../services/analysePrivacy.js'
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { verschiebeZeiten } from '../services/termine/ablauf.js'
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
import { imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
import { hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js'
import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js'
import { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
import { zustellFehler } from '../services/termine/outbox.js'
@@ -33,7 +35,6 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine
const router = Router()
const HOUR_MS = 60 * 60 * 1000
const MAX_WRITES_PER_HOUR = 120
const MAX_TAGE = 62
const STATUS = ['gehalten', 'angefragt', 'bestaetigt', 'erledigt', 'nichterschienen', 'abgesagt', 'abgelehnt', 'verfallen']
@@ -70,6 +71,7 @@ const MELDUNG = {
leistung: 'Bitte eine Leistung auswählen.',
ressource: 'Unbekannte Person oder unbekannter Platz.',
keineRessource: 'Diese Person oder diesen Platz gibt es nicht.',
keineBuchung: 'Diesen Termin gibt es nicht.',
ungueltig: 'Ungültige Eingabe.',
}
@@ -112,28 +114,12 @@ const route = (fn) => async (req, res, next) => {
// ---------------------------------------------------------------- Schreib-Limit
// Schreib-Limit je Kunde und Stunde (im Speicher, wie bei den News); zählt POST/PATCH/DELETE
const recentWrites = new Map()
function writeRateLimited(customerId) {
const now = uhr().getTime()
for (const [key, times] of recentWrites) {
const live = times.filter((t) => now - t < HOUR_MS)
if (live.length === 0) recentWrites.delete(key)
else if (live.length !== times.length) recentWrites.set(key, live)
}
const list = recentWrites.get(customerId) || []
list.push(now)
recentWrites.set(customerId, list)
return list.length > MAX_WRITES_PER_HOUR
}
const limitWrites = route((req, res, next) => {
if (writeRateLimited(getSessionCustomerId(req))) return res.status(429).json({ error: MELDUNG.zuViele })
return next()
})
// 120 Aenderungen je Kunde und Stunde (im Speicher); zaehlt POST/PATCH/DELETE; die Uhr folgt setzeUhrFuerTests
const limiter = createWriteLimiter({ max: MAX_WRITES_PER_HOUR, now: () => uhr().getTime() })
const limitWrites = limiter.middleware((req) => getSessionCustomerId(req), MELDUNG.zuViele)
export function resetTermineRoutesForTests() {
recentWrites.clear()
limiter.reset()
uhr = () => new Date()
}
@@ -345,6 +331,12 @@ function buchungJson(b, zustellFehlerIds) {
const tagGueltig = (tag) => typeof tag === 'string' && zuUtc(tag, '12:00') !== null
/**
* Buchungen im Zeitraum `von` bis `bis` (Berliner Tage 'JJJJ-MM-TT', beide Tage eingeschlossen, beide Pflicht), nach Beginn
* sortiert, mit Sperren. Zeitraum: `bis` darf höchstens 62 Tage nach `von` liegen (bis ≤ von + 62 Tage, also bis zu 63
* Kalendertage) und nicht davor. `status` (optional) ist eine Liste bekannter Status, durch Komma getrennt; ohne Angabe
* kommen alle Status.
*/
router.get('/buchungen', requireSession, withTermine(), route(async (req, res) => {
const ctx = req.ctx
const { von: vonTag, bis: bisTag, status } = req.query
@@ -370,9 +362,27 @@ router.get('/buchungen', requireSession, withTermine(), route(async (req, res) =
res.json({ buchungen: liste.map((b) => buchungJson(b, mitMail)) })
}))
/**
* Freie Startzeiten eines Tages für den Betrieb (Termin eintragen, Verschieben): ohne Vorlauf und Horizont, nie in der
* Vergangenheit. `leistung` und `ressource` (Vorgabe 'egal') wählen, was gesucht wird.
* Mit `ohne=<Buchungs-ID>` (Verschieben) gelten Leistung, Dauer und Person der Buchung, genau wie beim Verschieben selbst
* (ablauf.js verschiebeZeiten, Ruling R42); `leistung` und `ressource` aus der Abfrage werden dann ignoriert. Unbekannte
* Buchung, Buchung einer anderen Funktion oder Sperre => 404.
*/
router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
const ctx = mitEinstellung(req)
const { leistung, ressource = 'egal', tag, ohne } = req.query
const now = uhr()
if (ohne !== undefined) {
if (typeof ohne !== 'string' || !ID_RE.test(ohne)) throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
const buchung = await hole(ohne)
if (!buchung || buchung.featureId !== ctx.feature.$id || buchung.quelle === 'sperre') throw stop(404, MELDUNG.keineBuchung, 'unbekannt')
const { zeiten } = await verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln: true })
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
}
if (typeof leistung !== 'string' || !(ctx.einstellung.leistungen || []).some((l) => l.id === leistung && l.aktiv)) {
throw stop(400, MELDUNG.leistung, undefined, 'leistung')
}
@@ -380,22 +390,18 @@ router.get('/zeiten', requireSession, withTermine(), route(async (req, res) => {
throw stop(400, MELDUNG.ressource, undefined, 'ressource')
}
if (!tagGueltig(tag)) throw stop(400, MELDUNG.tag, undefined, 'tag')
if (ohne !== undefined && (typeof ohne !== 'string' || !ID_RE.test(ohne))) throw stop(400, MELDUNG.ungueltig, undefined, 'ohne')
const now = uhr()
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
// ohneRegeln: der Betrieb trägt auch kurzfristig und jenseits des Buchungszeitraums ein (nie in die Vergangenheit);
// ohne = die Buchung, die verschoben wird, belegt ihre neue Zeit nicht selbst
const zeiten = freieZeiten({
einstellung: ctx.einstellung,
leistungId: leistung,
ressourceId: ressource,
tag,
belegung: belegungAus(amTag, now, ohne ? { ohneBuchung: ohne } : {}),
belegung: belegungAus(amTag, now),
now,
ohneRegeln: true,
})
res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
return res.json({ zeiten: zeiten.map((z) => z.uhrzeit) })
}))
// ---------------------------------------------------------------- Buchungen schreiben
@@ -462,7 +468,7 @@ router.get('/export.csv', requireSession, withTermine(), route(async (req, res)
b.gastNachricht,
]
})]
const csv = `${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
const csv = `\uFEFF${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
res.set({
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="termine.csv"',
+3 -2
View File
@@ -713,9 +713,10 @@ const verschiebeRessource = (einstellung, buchung) => (einstellung.regeln?.perso
/**
* Freie Startzeiten eines Tages fuer das Verschieben einer Buchung (ohne die Buchung selbst); => { zeiten, amTag }.
* ohneRegeln: Verschieben durch den Betrieb (kein Vorlauf, kein Horizont).
* Leistung, Dauer und Person kommen aus der Buchung (mitGebuchterDauer, verschiebeRessource), genau wie in verschiebeKern.
* ohneRegeln: Verschieben durch den Betrieb (kein Vorlauf, kein Horizont). Auch die Zeitenabfrage des Portals nutzt sie.
*/
async function verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln = false } = {}) {
export async function verschiebeZeiten(ctx, buchung, tag, now, { ohneRegeln = false } = {}) {
const amTag = await imZeitraum(ctx.feature.$id, zuUtc(tag, '00:00'), zuUtc(tag, '24:00'))
const zeiten = freieZeiten({
einstellung: mitGebuchterDauer(ctx.einstellung, buchung),
+14 -9
View File
@@ -15,7 +15,7 @@ import { pruefeNow, pruefeText } from './util.js'
* feature portalFeatures-Dokument (featureKey 'termine')
* project websiteProjects-Dokument oder null (Projekt fehlt)
* einstellung gespeicherte Einstellung (Objekt) oder null (noch nie gespeichert / unlesbar)
* version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert)
* version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert; bei unlesbarem JSON die Version des Dokuments)
* Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck.
*
* - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter
@@ -51,8 +51,11 @@ async function holeOderNull(collectionId, id) {
}
}
/** { einstellung, version } aus einem termineConfig-Dokument; unlesbares JSON gilt als nicht gespeichert (mit Log). */
function liesEinstellung(doc) {
/**
* { einstellung, version } aus einem termineConfig-Dokument. Unlesbares JSON (mit Log) ergibt einstellung null, die Version
* des Dokuments bleibt: wer die kaputte Einstellung ueberschreiben will, speichert mit genau dieser Version.
*/
function liesDokument(doc) {
const version = Number.isInteger(doc.version) ? doc.version : 0
try {
const einstellung = JSON.parse(doc.einstellung)
@@ -60,7 +63,7 @@ function liesEinstellung(doc) {
return { einstellung, version }
} catch (err) {
console.error(`[termine-kontext] Einstellung ${doc.$id} ist unlesbar: ${err.message}`)
return null
return { einstellung: null, version }
}
}
@@ -72,15 +75,17 @@ function liesEinstellung(doc) {
export async function ladeEinstellung(featureId) {
pruefeText(featureId, 'featureId')
const doc = await holeOderNull(config.collections.termineConfig, featureId)
return doc ? liesEinstellung(doc) : null
const gelesen = doc ? liesDokument(doc) : null
return gelesen?.einstellung ? gelesen : null
}
async function baueKontext(feature) {
const [project, gespeichert] = await Promise.all([
const [project, doc] = await Promise.all([
feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null,
ladeEinstellung(feature.$id),
holeOderNull(config.collections.termineConfig, feature.$id),
])
return { feature, project, einstellung: gespeichert?.einstellung ?? null, version: gespeichert?.version ?? 0 }
const gelesen = doc ? liesDokument(doc) : null
return { feature, project, einstellung: gelesen?.einstellung ?? null, version: gelesen?.version ?? 0 }
}
/**
@@ -160,7 +165,7 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version
const aktuell = vorhanden && Number.isInteger(vorhanden.version) ? vorhanden.version : 0
if (version !== null && version !== aktuell) throw veraltet()
const einstellung = alsFunktion ? await einstellungOderFn(vorhanden ? (liesEinstellung(vorhanden)?.einstellung ?? null) : null) : einstellungOderFn
const einstellung = alsFunktion ? await einstellungOderFn(vorhanden ? liesDokument(vorhanden).einstellung : null) : einstellungOderFn
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
const json = JSON.stringify(einstellung)
+41
View File
@@ -0,0 +1,41 @@
/**
* Schreib-Begrenzung je Schluessel (meist Kunde) im Speicher: hoechstens `max` Aufrufe je Fenster (Standard eine Stunde),
* gezaehlt ab dem Aufruf. Jeder Aufruf zaehlt, auch ein abgelehnter. Wie beim Chat und bei den News nur im Prozess, ein
* Neustart setzt die Zaehler zurueck; Schluessel ohne Treffer im Fenster werden bei der naechsten Abfrage vergessen.
* Genutzt von routes/news.js (30 je Stunde) und routes/termine.js (120 je Stunde).
*
* @param {{ max: number, fensterMs?: number, now?: () => number }} optionen now liefert Millisekunden (Tests: feste Uhr)
* @returns {{
* istUeberschritten: (key: string) => boolean,
* middleware: (keyOf: (req: object) => string, message: string) => (req: object, res: object, next: () => void) => void,
* reset: () => void,
* }} istUeberschritten zaehlt diesen Aufruf mit und sagt, ob damit mehr als `max` im Fenster liegen;
* middleware antwortet dann 429 { error: message }, sonst next(); reset leert alles (Tests)
*/
export function createWriteLimiter({ max, fensterMs = 60 * 60 * 1000, now = () => Date.now() } = {}) {
if (!Number.isInteger(max) || max < 1) throw new TypeError('createWriteLimiter: max muss eine ganze Zahl ab 1 sein')
if (!Number.isFinite(fensterMs) || fensterMs <= 0) throw new TypeError('createWriteLimiter: fensterMs muss groesser als 0 sein')
if (typeof now !== 'function') throw new TypeError('createWriteLimiter: now muss eine Funktion sein')
const treffer = new Map() // Schluessel -> Zeitpunkte (ms) im Fenster
function istUeberschritten(key) {
const jetzt = now()
for (const [schluessel, zeiten] of treffer) {
const frisch = zeiten.filter((t) => jetzt - t < fensterMs)
if (frisch.length === 0) treffer.delete(schluessel)
else if (frisch.length !== zeiten.length) treffer.set(schluessel, frisch)
}
const liste = treffer.get(key) || []
liste.push(jetzt)
treffer.set(key, liste)
return liste.length > max
}
const middleware = (keyOf, message) => (req, res, next) => {
if (istUeberschritten(keyOf(req))) return res.status(429).json({ error: message })
return next()
}
return { istUeberschritten, middleware, reset: () => treffer.clear() }
}