// Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/): // Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und // Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum. // Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben. import { access, open, readFile, stat } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { Router } from 'express' import { config } from '../config.js' const publicDir = fileURLToPath(new URL('../../public/', import.meta.url)) /** Wurzel der ausgelieferten Dateien (Standard: public/); nur Tests tauschen sie gegen ein Verzeichnis mit Kopien aus. */ let basisDir = publicDir /** Nur fuer Tests: Verzeichnis, in dem legalPdfFiles und featureLegalReady die Dateien suchen (null = public/). */ export function setLegalDirForTests(dir) { basisDir = dir ?? publicDir } const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/ /** * Dokumente je Satz in Anhangsreihenfolge: slug = Dateiname der HTML-Seite und des PDFs (-.html|pdf), * pdf = Dateiname des Mail-Anhangs (-.pdf). config.js baut die Adressen des Terminplaners aus denselben * Slugs (config.js darf diese Datei nicht importieren); test/legalDocs.test.js gleicht beide ab. */ const SAETZE = { analyse: { avv: { slug: 'avv-website-analyse', pdf: 'AVV-Website-Analyse' }, lb: { slug: 'leistungsbeschreibung-website-analyse', pdf: 'Leistungsbeschreibung-Website-Analyse' }, }, termine: { lb: { slug: 'leistungsbeschreibung-terminplaner', pdf: 'Leistungsbeschreibung-Terminplaner' }, avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' }, widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' }, }, } /** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */ const FUNKTION_SATZ = { news: null, termine: 'termine' } /** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */ const aktuelleFassung = (satz) => (satz === 'analyse' ? config.analyse.avvVersion : config.termine.avvVersion) function satzDokumente(satz) { if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz') return SAETZE[satz] } function pruefeFassung(version) { if (!VERSION_RE.test(String(version))) throw new Error('Rechtstexte: ungültige Fassung') return String(version) } /** Adressen der versionierten HTML-Seiten: { [doc]: '/rechtliches/-.html' }. */ export function legalPaths(satz, version) { const dokumente = satzDokumente(satz) const v = pruefeFassung(version) return Object.fromEntries(Object.entries(dokumente).map(([doc, d]) => [doc, `/rechtliches/${d.slug}-${v}.html`])) } /** PDF je Dokument: { [doc]: { filename (Anhangsname), path (Datei unter public/rechtliches/) } }. */ export function legalPdfFiles(satz, version) { const dokumente = satzDokumente(satz) const v = pruefeFassung(version) return Object.fromEntries( Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(basisDir, 'rechtliches', `${d.slug}-${v}.pdf`) }]) ) } /** * PDFs als Mail-Anhaenge in der Reihenfolge des Satzes (termine: lb, avv, widerruf; analyse: avv, lb); wirft, wenn eine * Datei fehlt oder nicht lesbar ist. ohne: Dokumente, die nicht angehaengt werden (sie muessen nicht vorhanden sein). */ export async function legalPdfAttachments(satz, version, { ohne = [] } = {}) { const dateien = legalPdfFiles(satz, version) for (const doc of ohne) { if (!Object.hasOwn(dateien, doc)) throw new Error('Rechtstexte: unbekanntes Dokument') } return Promise.all( Object.entries(dateien) .filter(([doc]) => !ohne.includes(doc)) .map(async ([, f]) => ({ filename: f.filename, content: await readFile(f.path), contentType: 'application/pdf' })) ) } function funktionSatz(featureKey) { if (typeof featureKey !== 'string' || !Object.hasOwn(FUNKTION_SATZ, featureKey)) throw new Error('Rechtstexte: unbekannte Funktion') return FUNKTION_SATZ[featureKey] } /** * Anhaenge der Bestellmail einer Funktion in der aktuellen Fassung: termine => Satz 'termine', news => []. Wirft bei * unbekannter Funktion und bei fehlender Datei (der Aufrufer entscheidet, was dann geschieht). opts.fassung waehlt eine andere * (ausgelieferte) Fassung, etwa die, der ein Kunde bei der Bestellung zugestimmt hat; sonst gilt die aktuelle. */ export async function featureLegalAttachments(featureKey, { fassung, ...opts } = {}) { const satz = funktionSatz(featureKey) return satz ? legalPdfAttachments(satz, fassung ?? aktuelleFassung(satz), opts) : [] } /** * Adressen der HTML-Seiten der Funktion ({ lb, avv, widerruf }, versioniert) in der aktuellen oder der gewaehlten Fassung * (opts.fassung); news => null. Wirft bei unbekannter Funktion oder ungueltiger Fassung. */ export function featureLegalPaths(featureKey, { fassung } = {}) { const satz = funktionSatz(featureKey) return satz ? legalPaths(satz, fassung ?? aktuelleFassung(satz)) : null } /** * Ist die Datei ein lesbares PDF? Grösse > 0 (stat) und die ersten fünf Bytes "%PDF-"; nur diese fünf Bytes werden gelesen, nie die * ganze Datei (featureLegalReady läuft bei jeder Bestellung, vor jeder Stripe-Sitzung). Wirft bei jedem Lesefehler. */ async function istLesbaresPdf(pfad) { const info = await stat(pfad) if (!info.isFile() || info.size <= 0) return false const datei = await open(pfad, 'r') try { const kopf = Buffer.alloc(5) const { bytesRead } = await datei.read(kopf, 0, 5, 0) return bytesRead === 5 && kopf.toString('latin1') === '%PDF-' } finally { await datei.close() } } /** * true, wenn alle Rechtsdokumente der Funktion in der aktuellen Fassung vorliegen: HTML-Seiten (die die Bestellseite * verlinkt) und PDFs (die Mail anhaengt; je PDF stat und die ersten fuenf Bytes, nicht der ganze Inhalt). Unbekannte * Funktion und jeder Lesefehler: false (fail-closed), nie ein Wurf. opts.fassung prueft eine andere Fassung als die aktuelle. */ export async function featureLegalReady(featureKey, { fassung } = {}) { try { const satz = funktionSatz(featureKey) if (!satz) return true const v = fassung ?? aktuelleFassung(satz) await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(basisDir, adresse)))) const lesbar = await Promise.all(Object.values(legalPdfFiles(satz, v)).map((f) => istLesbaresPdf(f.path))) return lesbar.every(Boolean) } catch { return false } } /** * Adressen ohne Fassung (/rechtliches/.html) liefern die Datei der aktuellen Fassung aus config. Nur fuer * Menschen und die WOMS-Karten: keine Mail und kein Zustimmungstext darf sie verlinken, denn sie aendern sich mit der * Fassung. Gehoert vor express.static; fehlt die Datei, geht die Anfrage weiter (404). saetze: welche Saetze bedient * werden, dir: Verzeichnis der Dateien (Standard public/rechtliches, Tests uebergeben ein eigenes). */ export function legalRouter({ saetze = Object.keys(SAETZE), dir = join(publicDir, 'rechtliches') } = {}) { const router = Router() for (const satz of saetze) { for (const d of Object.values(satzDokumente(satz))) { router.get(`/${d.slug}.html`, (_req, res, next) => { const datei = join(dir, `${d.slug}-${pruefeFassung(aktuelleFassung(satz))}.html`) res.sendFile(datei, (err) => err && next(err.code === 'ENOENT' ? undefined : err)) }) } } return router }