Terminplaner: Empfaenger-Kappe ueber Betriebe (G01, Freitext in G04/G06)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 10:03:39 +00:00
1 parent c35711caad
commit 4d96565240
5 files changed
+257 -18

No files matched your search

+27 -3
View File
@@ -13,8 +13,11 @@ import { pruefeNow, pruefeText } from './util.js'
* Buchung, ein Treffer bekommt seine Mails immer.
* Oeffentliche Schnittstelle (routes/publicTermine.js): darfAnfrage / darfAnfrageSite (lesende Abrufe und Token-Aufrufe
* je IP und je siteKey), darfBuchungsversuch (je IP), darfHaltung (je siteKey), darfWiderrufsversuch (je IP) und darfG01
* (Bestaetigungsmails je Adresse und Funktion). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist
* (Bestaetigungsmails je Adresse). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist
* ein ganzes /64, die Adresse darin wechselt beliebig.
* Empfaenger-Kappe: eine fremde Adresse bekommt ueber alle Betriebe (Funktionen) zusammen hoechstens 3 Mails je Stunde und
* 10 je Tag, die ein Gast oder ein Betrieb ausloest (darfG01 beim Gast-Formular, darfEmpfaenger fuer den Freitext des
* Betriebs in G04/G06, mails.js anGast); beide teilen dieselben Zaehler.
*
* Speicher: zwei getrennte Maps mit je hoechstens 10 000 Schluesseln. Die Zaehler je IP (Schluessel mit 'ip:') haben
* beliebig viele, von Angreifern frei waehlbare Schluessel; sie duerfen die langlebigen Zaehler je Adresse, Funktion
@@ -46,6 +49,9 @@ const HALTUNGEN_JE_SITE = 25
const WIDERRUFE_JE_IP = 10
/** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */
const G01_JE_ADRESSE = 5
/** Empfaenger-Kappe ueber alle Funktionen: je (normalisierter) Adresse und Stunde, je Adresse und Tag. */
const EMPFAENGER_JE_STUNDE = 3
const EMPFAENGER_JE_TAG = 10
/**
* Eine Map schluessel -> { start, ende (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne.
@@ -195,9 +201,27 @@ export function darfWiderrufsversuch(ip, now) {
return zaehle(`ip:widerruf:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now)
}
/** Darf an diese (normalisierte) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag (gehalten wie "zu viele"). */
/** Die zwei Regeln der Empfaenger-Kappe (Stunde und Tag) fuer eine normalisierte Adresse, ueber alle Funktionen. */
const empfaengerRegeln = (adresse) => [
{ schluessel: `empfaenger-h:${adresse}`, max: EMPFAENGER_JE_STUNDE, fensterMs: STUNDE_MS },
{ schluessel: `empfaenger-d:${adresse}`, max: EMPFAENGER_JE_TAG, fensterMs: TAG_MS },
]
/**
* Darf an diese (normalisierte: klein, getrimmt) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag
* (gehalten wie "zu viele") und die Empfaenger-Kappe ueber alle Funktionen (3 je Stunde, 10 je Tag). Alles oder nichts:
* eine abgelehnte Anfrage erhoeht keinen Zaehler.
*/
export function darfG01(featureId, adresse, now) {
return zaehle(`g01:${featureId}:${adresse}`, G01_JE_ADRESSE, TAG_MS, now)
return zaehleAlle([{ schluessel: `g01:${featureId}:${adresse}`, max: G01_JE_ADRESSE, fensterMs: TAG_MS }, ...empfaengerRegeln(adresse)], now)
}
/**
* Darf an diese (normalisierte) Adresse eine Mail mit Freitext des Betriebs gehen (G04, G06)? Dieselben Zaehler wie
* darfG01 (3 je Stunde, 10 je Tag ueber alle Funktionen), aber ohne die Regel je Funktion.
*/
export function darfEmpfaenger(adresse, now) {
return zaehleAlle(empfaengerRegeln(adresse), now)
}
/** Leert alle Zaehler (Tests). */
+25 -3
View File
@@ -3,6 +3,7 @@ import { getDocument } from '../appwriteAdmin.js'
import { preisHinweis, preisText, zahlungText } from './einstellungen.js'
import { icsTermin } from './ics.js'
import { websiteUrl } from './kontext.js'
import { darfEmpfaenger } from './limits.js'
import { einreihen } from './outbox.js'
import { widerrufsbelehrung } from './rechtstexte.js'
import { buchungsNummer, verwaltungsToken } from './token.js'
@@ -21,6 +22,8 @@ import { datumKurz, datumLang, uhrzeit } from './zeit.js'
* - Person oder Platz (Block person in G02/G05) nur, wenn der Betrieb mehrere Ressourcen hat; K20/K21 zeigen sie immer.
* - G01 und G08 enthalten keinen Text, den der Gast eingegeben hat (die Mail geht an eine ungeprueft angegebene Adresse);
* in keinem Betreff steht ein Gastname.
* - Freitext des Betriebs (G04 nachricht, G06 grund und nachricht bei Absage durch den Betrieb) geht nur bis zur
* Empfaenger-Kappe (limits.js darfEmpfaenger) an eine Adresse; danach geht die Mail ohne diesen Text raus (anGast).
* - Verwaltungslink `${publicBaseUrl}/termin.html#m=<verwaltungsToken>` (G02, G03, G05, G07), Bestaetigungslink `#v=<token>`
* (G01, Token von extra.verifyToken), Portal-Link der Betriebsmails `${publicBaseUrl}/dashboard.html#termine`.
*
@@ -52,6 +55,8 @@ const GAST_ARTEN = ['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08']
const BETRIEBS_ARTEN = ['K20', 'K21']
const ICS_METHODE = { G02: 'REQUEST', G07: 'REQUEST', G06: 'CANCEL', K20: 'PUBLISH' }
const VERIFY_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
/** Felder mit Freitext des Betriebs je Gastmail (G06 nur bei von === 'betrieb'). */
const FREITEXT_FELDER = { G04: ['nachricht'], G06: ['grund', 'nachricht'] }
function einfrieren(wert) {
if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) {
@@ -458,10 +463,26 @@ function kalenderDatei(art, ctx, buchung, extra, jetzt) {
// ---------------------------------------------------------------- Einreihen
/**
* Zaehlt eine Mail mit Freitext des Betriebs gegen die Empfaenger-Kappe der Adresse (limits.js darfEmpfaenger) und gibt
* das extra zurueck, mit dem die Mail rausgeht: unveraendert, solange die Kappe nicht erreicht ist oder kein Freitext
* dabei ist (dann zaehlt nichts), sonst ohne die Freitextfelder (Log-Warnung ohne Adresse). G05, K20/K21 und der Rest
* sind nicht betroffen, ebenso G06 des Gastes oder des Systems.
*/
function begrenzeFreitext(art, extra, empfaenger, now) {
const felder = FREITEXT_FELDER[art]
if (!felder || (art === 'G06' && extra.von !== 'betrieb') || !felder.some((feld) => hatText(extra[feld]))) return extra
const adresse = hatText(empfaenger) ? empfaenger.trim().toLowerCase() : ''
if (adresse === '' || darfEmpfaenger(adresse, now)) return extra
console.warn(`[termine-mails] ${art}: Empfänger-Kappe erreicht, die Mail geht ohne Freitext raus`)
return { ...extra, ...Object.fromEntries(felder.map((feld) => [feld, undefined])) }
}
/**
* Reiht eine Mail an den Gast ein (Absender: Betriebsname, Antworten an die Betriebs-E-Mail, nie BCC). Empfaenger ist
* extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL), jeweils
* ausser mit extra.ohneIcs.
* ausser mit extra.ohneIcs. Freitext des Betriebs in G04/G06 geht nur bis zur Empfaenger-Kappe der Adresse mit (siehe
* begrenzeFreitext).
* @param {string} art 'G01' ... 'G08'
* @param {object} ctx
* @param {object|null} buchung
@@ -472,7 +493,8 @@ function kalenderDatei(art, ctx, buchung, extra, jetzt) {
export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) {
if (!GAST_ARTEN.includes(art)) throw new TypeError(`anGast: ${String(art).slice(0, 20)} ist keine Gastmail`)
pruefeNow(now, 'anGast')
extra = extra ?? {}
const an = hatText(extra?.an) ? extra.an : buchung?.gastEmail
extra = begrenzeFreitext(art, extra ?? {}, an, now)
const { values, blocks } = werteFuer(art, ctx, buchung, extra)
const b = ctx.einstellung.betrieb
const ics = extra.ohneIcs === true ? null : kalenderDatei(art, ctx, buchung, extra, now)
@@ -481,7 +503,7 @@ export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) {
featureId: ctx.feature?.$id,
bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG,
art,
an: hatText(extra.an) ? extra.an : buchung?.gastEmail,
an,
fromName: b.name,
replyTo: b.email,
template: art,