3 Commits
12 changed files with 1137 additions and 127 deletions
@@ -0,0 +1,763 @@
# andreknie.de Findings Remediation Plan
Status: Umsetzungsanleitung, noch nicht abgearbeitet
Zielgruppe: Nachfolgendes Codex-Modell, insbesondere Luna
Letzter Review: 2026-07-28
## 1. Auftrag und Arbeitsgrenzen
Dieser Plan beschreibt die schrittweise Behebung der Findings aus dem vollstaendigen
Review aller Seiten, Komponenten und relevanten Backend-Flows von andreknie.de.
Wichtig:
- Nicht alle Arbeitspakete auf einmal umsetzen.
- Zu Beginn eines neuen Tasks genau ein vom Benutzer ausgewaehltes Arbeitspaket
bearbeiten.
- Vor der Implementierung den aktuellen Stand erneut lesen und die Findings
verifizieren.
- Keine unaufgeforderten Refactorings ausserhalb des ausgewaehlten Pakets.
- Bestehende Benutzer-Aenderungen im Worktree nicht zuruecksetzen.
- Keine echten Kontakt-, Newsletter- oder Speaker-CV-Anfragen in Produktion
absenden.
- Personenbezogene Produktionsdaten niemals in Logs, Antworten, Screenshots,
Commits oder Test-Fixtures uebernehmen.
## 2. Verbindlicher Repository-Kontext
### 2.1 Source of Truth
Arbeitsverzeichnis:
```text
C:\Coding\Orchestrator
```
Component:
```text
C:\Coding\Orchestrator\privat\CV\andreknie.de
```
Hub:
```text
https://git.d-hive.de/ankn/Orchestrator.git
```
Foederiertes Spoke-Repository:
```text
https://git.d-hive.de/dHive/andreknie.de.git
```
Nicht verwenden:
- Das alte GitHub-Repository. Es ist veraltet.
- Eine separate lokale Clone-Kopie von andreknie.de.
- Direkte Aenderungen ausserhalb des Orchestrator-Monorepos.
### 2.2 Vor jeder Arbeit lesen
Vom Orchestrator-Root:
```text
AGENTS.md
.agents/AGENTS.md
.kiro/steering/*.md
privat/CV/andreknie.de/README.md
privat/CV/andreknie.de/DEPLOYMENT-PLAN.md
privat/CV/andreknie.de/.gitea/workflows/deploy.yml
privat/CV/andreknie.de/FINDINGS-REMEDIATION-PLAN.md
```
Dabei haben die aktuellen System- und Benutzeranweisungen Vorrang. Fuer
versionierte Aenderungen gilt der in den Repository-Regeln beschriebene
Feature-Branch-, Commit-, Push- und Pull-/Merge-Request-Ablauf. Nicht direkt auf
`master` oder `main` pushen.
### 2.3 Gitea und Foederation
- Gitea wird ueber `tea` mit Browser-Authentifizierung verwendet.
- Lokales Tool:
```text
C:\Coding\tools\tea\tea.exe
```
- Gitea-Login: `dhive`
- Der Runner arbeitet im foederierten Spoke.
- Deployment erst nach erfolgreichem Review und Merge.
- Vor einem Deployment die aktuellen Orchestrator-/Federationsanleitungen
erneut lesen. Keine eigene Synchronisationsvariante erfinden.
- Workflow-Status nach dem Merge im Spoke mit `tea actions runs` pruefen.
- Live-Deployment erst als erfolgreich melden, wenn der Gitea-Run erfolgreich
ist und die konkrete Aenderung auf `https://andreknie.de` verifiziert wurde.
## 3. Technischer Ausgangspunkt
Stack:
- React 19
- React Router 7
- Vite 8
- Express 5
- Caddy
- Datei-basiertes CMS aus Markdown und YAML
- Gitea Actions Deployment
Relevante Befehle im Component-Verzeichnis:
```powershell
& 'C:\Users\knie\.cache\codex-runtimes\codex-primary-runtime\dependencies\bin\fallback\pnpm.cmd' run build
& 'C:\Users\knie\.cache\codex-runtimes\codex-primary-runtime\dependencies\bin\fallback\pnpm.cmd' run test
.\node_modules\.bin\eslint.CMD src server plugins vite.config.js
```
Hinweise:
- Der volle Lint ueber das gesamte Repository erfasst alte generierte Dateien
unter `site/assets` und ist deshalb fuer gezielte Source-Pruefungen nicht
aussagekraeftig.
- Fuer UI-Tests den lokalen Vite-Server starten und Desktop sowie Mobil mit
Browser-Automation pruefen.
- Relevante Viewports:
- Desktop: 1440 x 900
- Mobile: 375 x 844
- Mobile: 390 x 844
- Mobile: 430 x 932
- Nach Tests alle selbst gestarteten lokalen Server beenden.
## 4. Allgemeiner Ablauf fuer jedes Arbeitspaket
1. Repository-Regeln und diesen Plan lesen.
2. `git status --short --branch` im Orchestrator pruefen.
3. Bei fremden Aenderungen nicht zuruecksetzen; Ueberschneidungen sorgfaeltig
analysieren.
4. Aktuellen Hub-Stand per Fast-Forward aktualisieren.
5. Einen aussagekraeftigen Feature-Branch erstellen.
6. Nur die Dateien des ausgewaehlten Arbeitspakets bearbeiten.
7. Relevante Unit-, Integrations- und Browser-Tests ausfuehren.
8. Build und gezielten Source-Lint ausfuehren.
9. Diff auf Scope, Secrets, generierte Artefakte und unbeabsichtigte
Content-Aenderungen pruefen.
10. Conventional Commit erstellen.
11. Branch zum Gitea-Hub pushen und Pull-/Merge-Request erstellen.
12. PR/MR pruefen; nicht ohne erfolgreiche Quality Gates mergen.
13. Nach Freigabe mergen und den dokumentierten Hub-and-Spoke-
Federationsablauf ausfuehren.
14. Gitea-Deployment beobachten.
15. Live nur die betroffenen Funktionen read-only pruefen. Keine echten
Formulare absenden.
## 5. Prioritaet 1: Datenschutz und Ausfallsicherheit
Branch-Vorschlag:
```text
fix/andreknie-data-lifecycle
```
### 5.1 Token-Lebenszyklus korrigieren
Betroffene Dateien:
```text
server/services/confirmationToken.js
server/routes/contact.js
server/routes/talk-request.js
server/routes/newsletter.js
src/pages/Datenschutz.jsx
```
Aktuelles Problem:
- Bestaetigte Tokens bleiben durch die Cleanup-Bedingung dauerhaft erhalten.
- Die Token-Datei enthaelt personenbezogene Formulardaten.
- Bestaetigte Tokens werden vor erfolgreichem Abschluss des Folgeprozesses
verbraucht.
Umsetzung:
1. Token-Zustaende explizit modellieren, mindestens `pending` und `processing`.
2. Eine atomare Funktion einfuehren, die einen gueltigen Token fuer die
Verarbeitung reserviert, ihn aber noch nicht endgueltig entfernt.
3. Nach erfolgreichem Folgeprozess den Token vollstaendig aus der Datei
entfernen.
4. Bei einem Folgefehler den Token wieder auf `pending` setzen oder eine
kontrollierte Wiederholung erlauben.
5. Abgelaufene Tokens unabhaengig vom bisherigen Status entfernen.
6. Bestaetigte personenbezogene Payloads nicht als Historie behalten.
7. Gleichzeitige Aufrufe weiterhin durch den vorhandenen Mutex schuetzen.
8. Keine Tokenwerte oder Payloads loggen.
9. Vor einer Bereinigung bestehender Produktionsdaten eine Sicherung nach dem
vorhandenen Betriebs-/Backup-Verfahren vorsehen. Keine Produktionsdateien
aus dem lokalen Task heraus manipulieren.
Vorgesehene Tests:
- Unit: Ein neuer Token ist vor Ablauf gueltig.
- Unit: Ein abgelaufener Token wird abgelehnt und entfernt.
- Unit: Ein erfolgreich verarbeiteter Token wird entfernt.
- Unit: Ein fehlgeschlagener Folgeprozess laesst einen kontrollierten Retry zu.
- Unit: Zwei parallele Bestaetigungen erzeugen genau einen Folgeprozess.
- Unit: Cleanup entfernt abgelaufene pending/processing-Datensaetze.
- Unit: Cleanup behaelt keine erfolgreich verarbeiteten personenbezogenen
Payloads.
- Integration Kontakt: Erfolgreicher Mailversand fuehrt zu Success-Redirect.
- Integration Kontakt: Mailfehler fuehrt nicht zum endgueltigen Tokenverlust.
- Integration Newsletter: Wiederholte Bestaetigung erzeugt keinen doppelten
Subscriber.
- Integration Talk Request: Gleiches Verhalten wie beim Kontaktformular.
Akzeptanzkriterien:
- Kein erfolgreich verarbeiteter Token verbleibt in `pending-tokens.json`.
- Ein temporaerer Mailfehler vernichtet keine Anfrage.
- Ein Token kann nicht zwei Stakeholder-Mails oder zwei Newsletter-Eintraege
erzeugen.
- Tests enthalten nur synthetische Daten.
### 5.2 SMTP-Konfiguration und Fehlerverhalten haerten
Betroffene Dateien:
```text
server/services/mailer.js
server/index.js
server/routes/contact.js
server/routes/talk-request.js
server/routes/newsletter.js
server/routes/speaker-cv.js
```
Umsetzung:
1. Produktionsbetrieb ohne notwendige SMTP-Konfiguration nicht stillschweigend
als erfolgreichen Versand behandeln.
2. Konfigurationspruefung beim Serverstart oder einen eindeutigen
`mailerReady`-Status einfuehren.
3. Healthcheck so erweitern, dass die Mail-Konfiguration ohne Ausgabe von
Secrets als bereit/nicht bereit erkennbar ist.
4. Nutzerfreundliche Fehlerantwort liefern, wenn kein Versand moeglich ist.
5. Serverlogs auf technische IDs und Fehlertypen begrenzen.
6. Speaker-CV-Lead nur gemaess klarer Reihenfolge speichern und versenden;
Wiederholungen duerfen keine unkontrollierten Duplikate erzeugen.
Vorgesehene Tests:
- Unit: Mailer meldet fehlende Konfiguration als nicht bereit.
- Unit: Keine SMTP-Zugangsdaten erscheinen in Fehlern oder Logs.
- Integration: Route liefert bei fehlendem Mailer keinen falschen Erfolg.
- Integration: SMTP-Fehler wird mit passendem HTTP-Status behandelt.
- Integration: Erfolgsfall bleibt unveraendert.
- Integration Speaker CV: Fehlendes PDF und Mailfehler werden getrennt
behandelt.
Akzeptanzkriterien:
- Keine UI-Erfolgsmeldung, wenn nachweislich keine Mail ausgeloest werden konnte.
- Healthcheck offenbart keine Secrets.
- Bestehender erfolgreicher Produktionsflow bleibt kompatibel.
### 5.3 Datenschutztext mit Implementierung synchronisieren
Betroffene Datei:
```text
src/pages/Datenschutz.jsx
```
Umsetzung:
1. Erst nach Festlegung und Implementierung der Speicherfristen anpassen.
2. Kontakt, Vortragsanfrage, Newsletter, Ressourcen und Speaker CV getrennt
beschreiben.
3. Aufbewahrungsdauer beziehungsweise Loeschkriterium konkret benennen.
4. Widerrufs- oder Abmeldeweg fuer Newsletter eindeutig nennen.
5. Keine rechtlichen Behauptungen erfinden; bei fachlicher Unsicherheit Text
als pruefbeduerftig kennzeichnen und den Benutzer um Freigabe bitten.
Vorgesehene Tests:
- Browser: Datenschutzseite bei Desktop und Mobile ohne Layoutfehler.
- Content-Review: Jede Aussage entspricht dem implementierten Datenfluss.
- Linktest: Alle genannten Mailadressen und internen Links sind korrekt.
## 6. Prioritaet 2: Mobile Nutzbarkeit und Ressourcen
Branch-Vorschlag:
```text
fix/andreknie-mobile-navigation
```
### 6.1 Bottom-Dock mobil korrigieren
Betroffene Dateien:
```text
src/components/BottomDock.jsx
src/components/BottomDock.css
src/index.css
src/pages/Home.css
```
Umsetzung:
1. Mobile Informationsarchitektur festlegen:
- bevorzugt weniger primaere Dock-Eintraege plus Menue, oder
- ein horizontal scrollbares Dock mit klarer Bedienbarkeit.
2. Dock-Hoehe als CSS-Variable definieren.
3. Global ausreichend unteren Inhaltsabstand reservieren.
4. `env(safe-area-inset-bottom)` beruecksichtigen.
5. Touch-Ziele mindestens 44 x 44 CSS-Pixel gross halten.
6. Primaere Hero-CTAs duerfen zu keinem Zeitpunkt vom Dock verdeckt werden.
7. Footer, Kontaktformular, About-Profil und lange Detailseiten pruefen.
8. Desktop-Dock nicht unbeabsichtigt veraendern.
Vorgesehene Tests:
- Browser 375 x 844: Hero-Buttons vollstaendig sichtbar und klickbar.
- Browser 390 x 844: Kein Text oder Formularfeld durch Dock verdeckt.
- Browser 430 x 932: Safe-Area und Footer funktionieren.
- Browser 1440 x 900: Desktop-Dock unveraendert.
- DOM-Messung: Dock liegt innerhalb des Viewports.
- DOM-Messung: Dokument besitzt keinen horizontalen Overflow.
- Tastatur: Jeder sichtbare Dock-Eintrag ist erreichbar.
- Screenshot-Vergleich fuer Home, About, Contact und Article Detail.
Akzeptanzkriterien:
- Kein Dock-Overlay auf CTAs, Formularen oder Footer-Inhalten.
- Kein horizontaler Seitenoverflow.
- Mobile Touch-Ziele bleiben ergonomisch.
### 6.2 Ressourcen-Flow entscheiden und umsetzen
Betroffene Dateien:
```text
src/pages/Resources.jsx
src/hooks/useFormSubmit.js
src/utils/validation.js
server/routes/resource-download.js
content/resources/*
src/pages/Datenschutz.jsx
```
Vor Implementierung Benutzerentscheidung einholen:
- Variante A: Freier direkter Download ohne Lead-Erfassung.
- Variante B: Download nach erfolgreicher Lead-Erfassung.
- Variante C: Ressourcen-Seite bis zur Befuellung ausblenden.
Fuer Variante B:
1. Formular mit Name, E-Mail, Unternehmen, Resource-ID und Honeypot erstellen.
2. Vorhandene Validierung verwenden statt duplizieren.
3. API-Antwort auswerten und nur die serverseitig gelieferte Download-URL
verwenden.
4. Lade-, Validierungs-, Fehler- und Erfolgszustand implementieren.
5. Resource-ID serverseitig gegen eine Allowlist vorhandener Ressourcen
validieren, nicht nur gegen ein Zeichenmuster.
6. Datenschutztext und Speicherfristen anpassen.
Vorgesehene Tests:
- Unit: Client- und Servervalidierung fuer alle Pflichtfelder.
- Unit: Unbekannte Resource-ID wird abgelehnt.
- Integration: Gueltige Anfrage liefert erwartete lokale Download-URL.
- Integration: Honeypot erzeugt keine Lead-Datei.
- Integration: Fehler beim Speichern liefert keinen Download-Erfolg.
- Browser: Download-Button besitzt eine echte Funktion.
- Browser: Fehler- und Ladezustand sind sichtbar und zugaenglich.
- Security: Keine Pfadmanipulation ueber Resource-ID.
Akzeptanzkriterien:
- Kein sichtbarer Button ohne Funktion.
- Download und Datenschutzerklaerung entsprechen derselben gewaehlten Variante.
## 7. Prioritaet 3: SEO und Dokumentstruktur
Branch-Vorschlag:
```text
fix/andreknie-seo
```
### 7.1 SEOHead normalisieren
Betroffene Dateien:
```text
index.html
src/components/SEOHead.jsx
src/pages/*.jsx
public/images/*
```
Umsetzung:
1. API von `SEOHead` eindeutig machen: `title` ist immer ohne Brand-Suffix.
2. Brand-Suffix zentral genau einmal hinzufuegen.
3. Statische Metadaten in `index.html` als kompatiblen Fallback gestalten,
sodass React Helmet keine konkurrierenden doppelten Tags erzeugt.
4. Canonical-Link ergaenzen.
5. Open-Graph- und Twitter-Metadaten vervollstaendigen.
6. Ein tatsaechlich vorhandenes OG-Bild bereitstellen.
7. Jede Route mit individuellem Titel, Description und Canonical versehen.
8. Artikel und Kniepunkte aus ihren Content-Metadaten versorgen.
9. 404-Seite mit `noindex,follow` auszeichnen.
Vorgesehene Tests:
- Unit fuer `SEOHead`: Brand erscheint genau einmal.
- Unit: Relative und absolute Bild-URLs werden korrekt behandelt.
- Browser je Route: genau ein aktiver Titel.
- Browser je Route: genau eine Description.
- Browser je Route: genau ein Canonical-Link.
- Browser Artikel: Titel und Description stammen aus dem Artikel.
- Browser Kniepunkt: Titel, Description, URL und Bild stimmen mit der Ausgabe
ueberein.
- Asset-Test: OG-Bild liefert lokal und live HTTP 200.
- Build: Keine Helmet- oder React-Warnungen.
Akzeptanzkriterien:
- Kein Titel enthaelt `Dr. Andre Knie` doppelt.
- Keine konkurrierenden Description-Tags.
- Alle 15 Routen haben passende Metadaten.
### 7.2 Ueberschriften und Landmarks korrigieren
Betroffene Dateien:
```text
src/pages/Articles.jsx
src/pages/ArticleSingle.jsx
src/pages/KniepunktIssue.jsx
plugins/vite-plugin-content.js
content/kniepunkt/*
```
Umsetzung:
1. Artikeluebersicht und Artikeldetail mit einem `main`-Landmark versehen.
2. Kniepunkt-Titel nur einmal als `h1` rendern.
3. Bevorzugt Markdown-Content beim Build normalisieren oder den ersten
redundanten H1 kontrolliert entfernen.
4. Keine pauschale Regex-Manipulation verwenden, wenn ein Markdown-AST oder
`marked`-Hook die Struktur sicherer bearbeiten kann.
5. Ueberschriftenhierarchie in allen Detailseiten pruefen.
Vorgesehene Tests:
- Browser: Jede Route besitzt genau ein `main`.
- Browser: Jede normale Seite besitzt genau ein `h1`.
- Unit Content-Plugin: Markdown mit und ohne initiales H1 wird korrekt gerendert.
- Regression: Bestehende Artikelkoerper verlieren keine Inhalte.
## 8. Prioritaet 4: Barrierefreiheit
Branch-Vorschlag:
```text
fix/andreknie-accessibility
```
### 8.1 Navigation und Formulare
Betroffene Dateien:
```text
src/components/Navigation.jsx
src/components/BottomDock.jsx
src/pages/Contact.jsx
src/components/NewsletterSignup.jsx
src/components/SpeakerCvRequest.jsx
```
Umsetzung:
1. Jedem Dock-Link einen zugreifbaren Namen geben.
2. Aktive Route mit `aria-current="page"` markieren, auch auf Detailrouten.
3. Hamburger um `aria-expanded` und `aria-controls` ergaenzen.
4. Label zwischen `Menue oeffnen` und `Menue schliessen` wechseln.
5. Menue per Escape schliessbar machen.
6. Formularlabels mit eindeutigen `id`/`htmlFor` verbinden.
7. Fehlertexte mit `aria-describedby` verknuepfen.
8. Serverfehler und Erfolgsmeldungen ueber `aria-live` bekanntgeben.
9. Honeypots mit `hidden` oder einer gleichwertigen robusten Loesung vollstaendig
aus Accessibility- und Tab-Reihenfolge entfernen.
10. Sichtbare Fokusdarstellung fuer Suchfelder und Buttons sicherstellen.
Vorgesehene Tests:
- Component-Test Navigation: `aria-expanded` folgt dem Menuezustand.
- Component-Test Navigation: Escape schliesst das Menue.
- Component-Test BottomDock: Jeder Link hat einen Namen.
- Component-Test Forms: Labels sind programmatisch zugeordnet.
- Component-Test Forms: Fehler referenzieren das richtige Feld.
- Tastaturtest: Komplette Navigation ohne Maus.
- Browser-Snapshot: Honeypot erscheint nicht im Accessibility-Baum.
- Browser-Snapshot: Dock-Links besitzen eindeutige Namen.
### 8.2 Testimonials und Logos
Betroffene Dateien:
```text
src/components/TestimonialSlider.jsx
src/components/TestimonialSlider.css
src/components/LogoMarquee.jsx
src/components/LogoMarquee.css
```
Umsetzung:
1. Nur einen logischen Satz Testimonials fuer Assistenztechnologien exponieren.
2. Technische Loop-Kopien mit `aria-hidden="true"` ausblenden.
3. Sichtbaren aktiven Eintrag beziehungsweise Carousel-Position semantisch
bekanntgeben.
4. Pfeile und Scrollbereich per Tastatur bedienbar machen.
5. Logo-Duplikate fuer Screenreader ausblenden.
6. Animationen bei Fokus/Hover pausierbar machen.
7. Bestehendes visuelles Endlosverhalten und Zentrierung erhalten.
Vorgesehene Tests:
- Component-Test: Bei drei Testimonials werden fuer Screenreader nur drei
logische Inhalte exponiert.
- Browser-Snapshot: Keine 27 vorgelesenen Testimonials.
- Browser-Snapshot: Keine dreifach vorgelesenen Logos.
- Desktop: zehnmal rechts und zehnmal links ohne sichtbaren Sprung.
- Mobile: Swipe bleibt funktionsfaehig.
- Tastatur: Vor/zurueck funktioniert und Fokus bleibt sichtbar.
### 8.3 Reduced Motion und Canvas-Lebenszyklus
Betroffene Dateien:
```text
src/index.css
src/pages/Home.css
src/components/BottomDock.css
src/components/LogoMarquee.css
src/components/StatsCube.css
src/components/BackgroundPattern.jsx
src/components/DotCloudIcon.jsx
```
Umsetzung:
1. Zentrale `prefers-reduced-motion: reduce`-Regeln ergaenzen.
2. Endlos-CSS-Animationen stoppen oder auf einen statischen Zustand setzen.
3. Canvas-Animationen bei Reduce Motion nicht starten.
4. Canvas-Animationen pausieren, wenn der Tab verborgen ist.
5. Optional Intersection Observer verwenden, damit nicht sichtbare Canvas-
Komponenten keine Frames berechnen.
6. `getComputedStyle` nicht in jedem Animationsframe erneut lesen.
7. Debug-Logs aus `DotCloudIcon` entfernen.
Vorgesehene Tests:
- Browser mit Reduce Motion: keine Endlosanimationen.
- Browser normal: Design und Interaktion bleiben erhalten.
- Component-Test: `requestAnimationFrame` wird beim Unmount abgebrochen.
- Component-Test: Reduce Motion startet keinen Animationsloop.
- Console-Test: Keine DotCloud-Debugausgaben.
## 9. Prioritaet 5: Performance, Dependencies und Deployment
Branch-Vorschlag:
```text
perf/andreknie-initial-load
```
### 9.1 Initialen Bundle verkleinern
Betroffene Dateien:
```text
src/App.jsx
src/hooks/useContent.js
plugins/vite-plugin-content.js
vite.config.js
```
Umsetzung in zwei getrennten Schritten:
1. Routen mit `React.lazy` und `Suspense` code-splitten.
2. Danach Content-Auslieferung umbauen:
- Listenmetadaten von vollstaendigen HTML-Bodies trennen.
- Detailinhalte nur fuer die aufgerufene Detailseite laden.
- Build-Zeit-Generierung beibehalten; kein unnoetiges neues CMS einfuehren.
3. Einen stabilen Ladezustand ohne Layoutsprung bereitstellen.
4. Chunk-Grenzwert als CI-Pruefung dokumentieren.
Vorgesehene Tests:
- Build: mehrere Route-Chunks statt eines einzigen App-Chunks.
- Bundle-Messung vor/nachher dokumentieren.
- Zielwert initiales JS: deutlich unter dem aktuellen Build von ca. 805 KB
unkomprimiert.
- Browser: Direktaufruf jeder Route funktioniert.
- Browser: Reload einer Detailroute funktioniert hinter Caddy-Fallback.
- Browser: Keine leeren Seiten waehrend Lazy Loading.
- Regression: Alle 62 Artikel, 48 Kniepunkte und 19 Podcast-Eintraege bleiben
erreichbar.
### 9.2 Dependency-Findings bearbeiten
Betroffene Dateien:
```text
package.json
package-lock.json
```
Umsetzung:
1. `npm audit --omit=dev` erneut ausfuehren.
2. React-Router-Advisory auf Anwendbarkeit pruefen.
3. Keine automatische Downgrade-Empfehlung ungeprueft anwenden.
4. Auf eine nachweislich gefixte kompatible Version aktualisieren.
5. Router-Verhalten aller Routen testen.
6. Audit als CI-Schritt einbauen, wenn das Repository dies ohne instabile
Netzwerkfehler erlaubt.
Vorgesehene Tests:
- `npm audit --omit=dev`
- Build und Source-Lint.
- Direktnavigation und Client-Navigation aller Routen.
- Browser Back/Forward.
- 404 und Detailrouten.
### 9.3 Deployment-Artefakte bereinigen
Betroffene Datei:
```text
.gitea/workflows/deploy.yml
```
Aktuelles Problem:
- Neue `dist/assets` werden nach `site/assets` kopiert.
- Alte gehashte Dateien werden nicht entfernt.
- Aktueller Bestand beim Review: 29 Dateien, ca. 28.5 MB.
Umsetzung:
1. Neues Build zuerst in ein separates Release-Verzeichnis kopieren.
2. Vollstaendigkeit des Releases pruefen.
3. `site` atomar oder kontrolliert auf den neuen Stand umschalten.
4. Rollback ueber ein klar begrenztes vorheriges Release ermoeglichen.
5. Nicht mit einer ungesicherten rekursiven Loeschung auf einem berechneten
Pfad arbeiten.
6. Caddy-Konfiguration und Umami-Dateien nicht versehentlich entfernen.
7. Alte Releases nach einer definierten Anzahl kontrolliert bereinigen.
Vorgesehene Tests:
- Workflow-Test auf Feature-Branch ohne Produktionsumschaltung, falls
Infrastruktur dies unterstuetzt.
- Build-Artefakt enthaelt `index.html`, aktuelle JS-/CSS-Dateien und Assets.
- Keine Referenz in `index.html` zeigt auf eine geloeschte Datei.
- Rollback-Test auf das unmittelbar vorherige Release.
- Nach Deployment: Startseite und eine Detailroute liefern HTTP 200.
- Live: HTML referenziert genau den neuen Bundle-Hash.
- Live: Umami-Script und CSP bleiben erhalten.
## 10. Abschluss-Quality-Gates je Paket
Pflicht:
```powershell
& 'C:\Users\knie\.cache\codex-runtimes\codex-primary-runtime\dependencies\bin\fallback\pnpm.cmd' run build
& 'C:\Users\knie\.cache\codex-runtimes\codex-primary-runtime\dependencies\bin\fallback\pnpm.cmd' run test
.\node_modules\.bin\eslint.CMD src server plugins vite.config.js
git diff --check
git status --short
```
Zusaetzlich je nach Paket:
- Backend: Integrations-Tests der betroffenen Routes.
- UI: Desktop- und Mobile-Browserpruefung.
- Accessibility: DOM-/Accessibility-Snapshot und Tastaturpruefung.
- SEO: Anzahl und Inhalt aller Head-Tags kontrollieren.
- Performance: Buildgroessen vor/nachher festhalten.
- Deployment: Gitea-Run und Live-Bundle verifizieren.
Ein Paket ist erst fertig, wenn:
- alle vorgesehenen Tests gruen sind,
- keine unerklaerten Warnungen neu hinzugekommen sind,
- der Diff auf das Paket begrenzt ist,
- PR/MR erstellt und geprueft wurde,
- bei beauftragtem Deployment der Gitea-Run erfolgreich ist,
- die konkrete Aenderung live verifiziert wurde.
## 11. Empfohlene Paket-Reihenfolge
1. Datenschutz und Ausfallsicherheit
2. Mobiles Bottom-Dock
3. SEO und Dokumentstruktur
4. Barrierefreiheit
5. Ressourcen-Flow nach Benutzerentscheidung
6. Performance und Dependencies
7. Deployment-Artefakte
Die Pakete 1 und 2 haben den groessten unmittelbaren Nutzen. SEO und
Barrierefreiheit koennen danach unabhaengig bearbeitet werden. Die
Content-/Bundle-Architektur sollte erst angefasst werden, wenn die funktionalen
Flows stabil und ausreichend getestet sind.
## 12. Startprompt fuer einen neuen Codex-Chat
Der Benutzer ersetzt `<ARBEITSPAKET>` durch genau eines der oben beschriebenen
Pakete.
```text
Arbeite im Orchestrator-Monorepo unter C:\Coding\Orchestrator an andreknie.de.
Die Source of Truth ist ausschliesslich
C:\Coding\Orchestrator\privat\CV\andreknie.de. Verwende nicht das alte
GitHub-Repository und keine separate Clone-Kopie.
Lies zuerst vollstaendig:
- C:\Coding\Orchestrator\AGENTS.md
- C:\Coding\Orchestrator\.agents\AGENTS.md
- die relevanten Dateien unter C:\Coding\Orchestrator\.kiro\steering\
- C:\Coding\Orchestrator\privat\CV\andreknie.de\README.md
- C:\Coding\Orchestrator\privat\CV\andreknie.de\DEPLOYMENT-PLAN.md
- C:\Coding\Orchestrator\privat\CV\andreknie.de\.gitea\workflows\deploy.yml
- C:\Coding\Orchestrator\privat\CV\andreknie.de\FINDINGS-REMEDIATION-PLAN.md
Setze ausschliesslich dieses Arbeitspaket aus dem Plan um:
<ARBEITSPAKET>
Vorgehen:
1. Verifiziere den aktuellen Stand und das Finding.
2. Erstelle einen Feature-Branch nach den Repository-Regeln.
3. Implementiere nur den Scope des ausgewaehlten Pakets.
4. Fuehre alle im Plan vorgesehenen Tests sowie Build, Tests und gezielten
Source-Lint aus.
5. Pruefe Desktop und Mobile, sofern UI betroffen ist.
6. Zeige mir danach Findings, Aenderungen, Testergebnisse und verbleibende
Risiken.
7. Nutze Gitea und die dokumentierte Hub-and-Spoke-Foederation. Nicht direkt
auf master/main pushen.
8. Erstelle einen PR/MR, aber merge und deploye erst, wenn ich das in diesem
Chat ausdruecklich freigebe.
Schuetze personenbezogene Daten und Secrets. Sende keine echten Formulare in
der Live-Umgebung ab. Erfinde keine Deployment- oder Federationsschritte,
sondern halte dich an die vorhandenen Anleitungen.
```
+12 -3
View File
@@ -8,6 +8,7 @@ import talkRequestRouter from './routes/talk-request.js'
import newsletterRouter from './routes/newsletter.js'
import resourceDownloadRouter from './routes/resource-download.js'
import speakerCvRouter from './routes/speaker-cv.js'
import { isMailerReady } from './services/mailer.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const PORT = process.env.PORT || 3003
@@ -30,7 +31,11 @@ app.use(express.json({ limit: '10kb' }))
// Routes
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', time: new Date().toISOString() })
res.json({
status: isMailerReady() ? 'ok' : 'degraded',
mailer: { ready: isMailerReady() },
time: new Date().toISOString(),
})
})
app.use('/api/contact', contactRouter)
@@ -45,6 +50,10 @@ app.use((err, _req, res, _next) => {
res.status(500).json({ error: 'Interner Serverfehler.' })
})
app.listen(PORT, () => {
if (process.env.NODE_ENV !== 'test') {
app.listen(PORT, () => {
console.log(`[andreknie.de Backend] Running on port ${PORT}`)
})
})
}
export { app }
@@ -0,0 +1,99 @@
import { beforeAll, afterAll, describe, expect, it } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
let server
let baseUrl
let testDir
let tokensFile
let createToken
let reserveToken
let releaseToken
beforeAll(async () => {
testDir = mkdtempSync(join(tmpdir(), 'andreknie-server-test-'))
tokensFile = join(testDir, 'pending-tokens.json')
process.env.CONFIRMATION_TOKEN_FILE = tokensFile
const tokenService = await import('./services/confirmationToken.js')
createToken = tokenService.createToken
reserveToken = tokenService.reserveToken
releaseToken = tokenService.releaseToken
const { app } = await import('./index.js')
server = app.listen(0)
await new Promise(resolve => server.once('listening', resolve))
baseUrl = `http://127.0.0.1:${server.address().port}`
})
afterAll(async () => {
if (server) await new Promise(resolve => server.close(resolve))
delete process.env.CONFIRMATION_TOKEN_FILE
rmSync(testDir, { recursive: true, force: true })
})
describe('backend mail safety', () => {
it('reports degraded health without SMTP configuration', async () => {
const response = await fetch(`${baseUrl}/api/health`)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.status).toBe('degraded')
expect(body.mailer).toEqual({ ready: false })
expect(JSON.stringify(body)).not.toContain('SMTP_PASS')
})
it.each([
['contact', { name: 'Test', email: 'test@example.invalid', message: 'Synthetic test' }],
['talk-request', {
name: 'Test',
email: 'test@example.invalid',
event_name: 'Synthetic event',
topic: 'Synthetic topic',
message: 'Synthetic test',
}],
['newsletter', { email: 'test@example.invalid' }],
])('does not retain %s data when confirmation mail cannot be sent', async (endpoint, payload) => {
const response = await fetch(`${baseUrl}/api/${endpoint}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
})
expect(response.status).toBe(503)
expect((await response.json()).error).toBeTruthy()
expect(existsSync(tokensFile) ? JSON.parse(readFileSync(tokensFile, 'utf8')) : []).toEqual([])
})
it('does not report success when speaker CV mail cannot be sent', async () => {
const response = await fetch(`${baseUrl}/api/speaker-cv`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Test',
email: 'test@example.invalid',
message: 'Synthetic test',
}),
})
expect(response.status).toBe(503)
expect((await response.json()).error).toBeTruthy()
})
it('releases a confirmation token when stakeholder notification fails', async () => {
const token = await createToken('contact', {
name: 'Synthetic Test',
email: 'test@example.invalid',
message: 'No external mail is sent.',
})
const response = await fetch(`${baseUrl}/api/contact/confirm/${token}`, {
redirect: 'manual',
})
expect(response.status).toBe(302)
expect(response.headers.get('location')).toBe('/bestaetigung?status=error')
expect(await reserveToken(token)).toMatchObject({ status: 'processing' })
await releaseToken(token)
})
})
+20 -11
View File
@@ -1,13 +1,14 @@
import { Router } from 'express'
import { contactLimiter } from '../middleware/rateLimiter.js'
import { antiSpam } from '../middleware/antiSpam.js'
import { createToken, verifyToken } from '../services/confirmationToken.js'
import { sendConfirmationEmail, sendStakeholderNotification } from '../services/mailer.js'
import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js'
import { sendConfirmationEmail, sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
const router = Router()
router.post('/', contactLimiter, antiSpam, async (req, res) => {
const { name, email, message } = req.body
let token
const errors = {}
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
if (name && name.length > 100) errors.name = 'Max. 100 Zeichen.'
@@ -15,23 +16,31 @@ router.post('/', contactLimiter, antiSpam, async (req, res) => {
if (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.'
if (Object.keys(errors).length > 0) {
return res.status(400).json({ errors })
}
if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
const token = await createToken('contact', { name, email, message }, 24)
try {
token = await createToken('contact', { name, email, message }, 24)
await sendConfirmationEmail(email, 'contact', token)
res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' })
} catch (error) {
if (token) await completeToken(token)
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht versendet werden.' })
}
})
router.get('/confirm/:token', async (req, res) => {
const baseUrl = process.env.BASE_URL || ''
const entry = await verifyToken(req.params.token)
if (!entry) {
return res.redirect(baseUrl + '/bestaetigung?status=error')
}
const baseUrl = (process.env.BASE_URL || '').replace(/\/+$/, '')
const entry = await reserveToken(req.params.token)
if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error')
try {
await sendStakeholderNotification('contact', entry.data)
await completeToken(req.params.token)
res.redirect(baseUrl + '/bestaetigung?status=success')
} catch {
await releaseToken(req.params.token)
res.redirect(baseUrl + '/bestaetigung?status=error')
}
})
export default router
@@ -5,8 +5,8 @@ import { fileURLToPath } from 'url'
import { Mutex } from 'async-mutex'
import { newsletterLimiter } from '../middleware/rateLimiter.js'
import { antiSpam } from '../middleware/antiSpam.js'
import { createToken, verifyToken } from '../services/confirmationToken.js'
import { sendConfirmationEmail } from '../services/mailer.js'
import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js'
import { sendConfirmationEmail, MailerNotReadyError } from '../services/mailer.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const SUBS_FILE = join(__dirname, '..', 'data', 'subscribers.json')
@@ -26,39 +26,49 @@ const router = Router()
router.post('/', newsletterLimiter, antiSpam, async (req, res) => {
const { email } = req.body
let token
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return res.status(400).json({ errors: { email: 'Gültige E-Mail erforderlich.' } })
}
// Check for existing subscription
const subs = loadSubscribers()
if (subs.find(s => s.email === email && s.active)) {
return res.status(409).json({ error: 'Diese E-Mail ist bereits abonniert.' })
}
const token = await createToken('newsletter', { email }, 48)
try {
token = await createToken('newsletter', { email }, 48)
await sendConfirmationEmail(email, 'newsletter', token)
res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' })
} catch (error) {
if (token) await completeToken(token)
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht versendet werden.' })
}
})
router.get('/confirm/:token', async (req, res) => {
const baseUrl = process.env.BASE_URL || ''
const entry = await verifyToken(req.params.token)
if (!entry) {
return res.redirect(baseUrl + '/bestaetigung?status=error')
}
const baseUrl = (process.env.BASE_URL || '').replace(/\/+$/, '')
const entry = await reserveToken(req.params.token)
if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error')
try {
await subsMutex.runExclusive(async () => {
const subs = loadSubscribers()
if (!subs.find(s => s.email === entry.data.email && s.active)) {
subs.push({
email: entry.data.email,
subscribedAt: new Date().toISOString(),
active: true,
})
saveSubscribers(subs)
}
})
await completeToken(req.params.token)
res.redirect(baseUrl + '/bestaetigung?status=success')
} catch {
await releaseToken(req.params.token)
res.redirect(baseUrl + '/bestaetigung?status=error')
}
})
export default router
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'
import { Mutex } from 'async-mutex'
import { resourceLimiter } from '../middleware/rateLimiter.js'
import { antiSpam } from '../middleware/antiSpam.js'
import { sendSpeakerCv } from '../services/mailer.js'
import { sendSpeakerCv, MailerNotReadyError } from '../services/mailer.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const LEADS_FILE = join(__dirname, '..', 'data', 'speaker-cv-leads.json')
@@ -38,15 +38,22 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => {
}
const lead = { name, email, message, timestamp: new Date().toISOString() }
try {
const result = await sendSpeakerCv(email, lead, PDF_PATH)
if (result.pdfMissing) {
return res.status(502).json({ error: 'Der Speaker CV ist derzeit nicht verfügbar.' })
}
await leadsMutex.runExclusive(async () => {
const leads = loadLeads()
leads.push(lead)
saveLeads(leads)
})
await sendSpeakerCv(email, lead, PDF_PATH)
res.status(201).json({ ok: true, message: 'Danke! Der Speaker CV ist auf dem Weg in dein Postfach.' })
} catch (error) {
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Der Versand ist derzeit nicht verfügbar.' })
}
})
export default router
@@ -1,13 +1,14 @@
import { Router } from 'express'
import { talkRequestLimiter } from '../middleware/rateLimiter.js'
import { antiSpam } from '../middleware/antiSpam.js'
import { createToken, verifyToken } from '../services/confirmationToken.js'
import { sendConfirmationEmail, sendStakeholderNotification } from '../services/mailer.js'
import { createToken, reserveToken, completeToken, releaseToken } from '../services/confirmationToken.js'
import { sendConfirmationEmail, sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
const router = Router()
router.post('/', talkRequestLimiter, antiSpam, async (req, res) => {
const { name, email, event_name, topic, message } = req.body
let token
const errors = {}
if (!name || name.trim().length === 0) errors.name = 'Name ist erforderlich.'
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige E-Mail erforderlich.'
@@ -16,23 +17,31 @@ router.post('/', talkRequestLimiter, antiSpam, async (req, res) => {
if (!message || message.trim().length === 0) errors.message = 'Nachricht ist erforderlich.'
if (message && message.length > 2000) errors.message = 'Max. 2000 Zeichen.'
if (Object.keys(errors).length > 0) {
return res.status(400).json({ errors })
}
if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
const token = await createToken('talk-request', { name, email, event_name, topic, message }, 48)
try {
token = await createToken('talk-request', { name, email, event_name, topic, message }, 48)
await sendConfirmationEmail(email, 'talk-request', token)
res.status(201).json({ ok: true, message: 'Bestätigungs-E-Mail gesendet.' })
} catch (error) {
if (token) await completeToken(token)
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht versendet werden.' })
}
})
router.get('/confirm/:token', async (req, res) => {
const baseUrl = process.env.BASE_URL || ''
const entry = await verifyToken(req.params.token)
if (!entry) {
return res.redirect(baseUrl + '/bestaetigung?status=error')
}
const baseUrl = (process.env.BASE_URL || '').replace(/\/+$/, '')
const entry = await reserveToken(req.params.token)
if (!entry) return res.redirect(baseUrl + '/bestaetigung?status=error')
try {
await sendStakeholderNotification('talk-request', entry.data)
await completeToken(req.params.token)
res.redirect(baseUrl + '/bestaetigung?status=success')
} catch {
await releaseToken(req.params.token)
res.redirect(baseUrl + '/bestaetigung?status=error')
}
})
export default router
@@ -6,11 +6,10 @@ import { Mutex } from 'async-mutex'
const __dirname = dirname(fileURLToPath(import.meta.url))
const DATA_DIR = join(__dirname, '..', 'data')
const TOKENS_FILE = join(DATA_DIR, 'pending-tokens.json')
const TOKENS_FILE = process.env.CONFIRMATION_TOKEN_FILE || join(DATA_DIR, 'pending-tokens.json')
const tokenMutex = new Mutex()
// Ensure data directory exists
mkdirSync(DATA_DIR, { recursive: true })
mkdirSync(dirname(TOKENS_FILE), { recursive: true })
function loadTokens() {
if (!existsSync(TOKENS_FILE)) return []
@@ -22,13 +21,6 @@ function saveTokens(tokens) {
writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2), 'utf-8')
}
/**
* Create a confirmation token.
* @param {string} type - 'contact' | 'talk-request' | 'newsletter'
* @param {object} data - The form data to store
* @param {number} expiresInHours - Token expiration (default: 48h)
* @returns {string} The generated token
*/
export async function createToken(type, data, expiresInHours = 48) {
const token = crypto.randomUUID()
await tokenMutex.runExclusive(async () => {
@@ -39,51 +31,64 @@ export async function createToken(type, data, expiresInHours = 48) {
data,
createdAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + expiresInHours * 60 * 60 * 1000).toISOString(),
confirmed: false,
status: 'pending',
})
saveTokens(tokens)
})
return token
}
/**
* Verify and consume a token.
* @returns {object|null} The stored data if valid, null if expired/invalid
*/
export async function verifyToken(token) {
/** Reserve a valid token for exactly one confirmation flow. */
export async function reserveToken(token) {
return await tokenMutex.runExclusive(async () => {
const tokens = loadTokens()
const idx = tokens.findIndex(t => t.token === token && !t.confirmed)
const idx = tokens.findIndex(t => t.token === token && (t.status || (t.confirmed ? 'completed' : 'pending')) === 'pending')
if (idx === -1) return null
const entry = tokens[idx]
if (new Date(entry.expiresAt) < new Date()) {
// Expired — remove it
if (new Date(entry.expiresAt) <= new Date()) {
tokens.splice(idx, 1)
saveTokens(tokens)
return null
}
// Mark as confirmed
tokens[idx].confirmed = true
tokens[idx].status = 'processing'
saveTokens(tokens)
return entry
})
}
/**
* Clean up expired tokens (run periodically).
*/
/** Remove a successfully processed token and its personal payload. */
export async function completeToken(token) {
await tokenMutex.runExclusive(async () => {
const tokens = loadTokens()
const remaining = tokens.filter(t => t.token !== token)
if (remaining.length !== tokens.length) saveTokens(remaining)
})
}
/** Return a failed confirmation to pending so it can be retried. */
export async function releaseToken(token) {
await tokenMutex.runExclusive(async () => {
const tokens = loadTokens()
const entry = tokens.find(t => t.token === token)
if (!entry) return
entry.status = 'pending'
saveTokens(tokens)
})
}
// Compatibility for code that only needs the reservation behavior.
export const verifyToken = reserveToken
export async function cleanupExpiredTokens() {
await tokenMutex.runExclusive(async () => {
const tokens = loadTokens()
const now = new Date()
const valid = tokens.filter(t => new Date(t.expiresAt) > now || t.confirmed)
if (valid.length !== tokens.length) {
saveTokens(valid)
}
const valid = tokens.filter(t => !t.confirmed && new Date(t.expiresAt) > now)
if (valid.length !== tokens.length) saveTokens(valid)
})
}
// Run cleanup every hour
setInterval(cleanupExpiredTokens, 60 * 60 * 1000)
const cleanupTimer = setInterval(cleanupExpiredTokens, 60 * 60 * 1000)
cleanupTimer.unref?.()
@@ -0,0 +1,92 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
let testDir
let tokensFile
let createToken
let reserveToken
let completeToken
let releaseToken
let cleanupExpiredTokens
function removeTokensFile() {
if (existsSync(tokensFile)) unlinkSync(tokensFile)
}
beforeAll(async () => {
testDir = mkdtempSync(join(tmpdir(), 'andreknie-token-test-'))
tokensFile = join(testDir, 'pending-tokens.json')
process.env.CONFIRMATION_TOKEN_FILE = tokensFile
const service = await import('./confirmationToken.js')
createToken = service.createToken
reserveToken = service.reserveToken
completeToken = service.completeToken
releaseToken = service.releaseToken
cleanupExpiredTokens = service.cleanupExpiredTokens
})
beforeEach(removeTokensFile)
afterEach(removeTokensFile)
afterAll(() => {
delete process.env.CONFIRMATION_TOKEN_FILE
rmSync(testDir, { recursive: true, force: true })
})
describe('confirmation token lifecycle', () => {
it('creates a pending token and reserves it once', async () => {
const token = await createToken('contact', { name: 'Test', email: 'test@example.invalid' }, 1)
const first = await reserveToken(token)
const second = await reserveToken(token)
expect(first.status).toBe('processing')
expect(second).toBeNull()
expect(JSON.parse(readFileSync(tokensFile, 'utf8')).find(entry => entry.token === token).status).toBe('processing')
})
it('removes an expired token instead of returning it', async () => {
const token = await createToken('contact', { email: 'expired@example.invalid' }, -1)
expect(await reserveToken(token)).toBeNull()
expect(JSON.parse(readFileSync(tokensFile, 'utf8'))).toEqual([])
})
it('removes a successfully processed token and its payload', async () => {
const token = await createToken('contact', { email: 'done@example.invalid' })
await reserveToken(token)
await completeToken(token)
expect(JSON.parse(readFileSync(tokensFile, 'utf8'))).toEqual([])
})
it('releases a failed processing attempt for a controlled retry', async () => {
const token = await createToken('contact', { email: 'retry@example.invalid' })
await reserveToken(token)
await releaseToken(token)
expect((await reserveToken(token)).status).toBe('processing')
})
it('allows exactly one parallel reservation', async () => {
const token = await createToken('contact', { email: 'parallel@example.invalid' })
const results = await Promise.all([reserveToken(token), reserveToken(token)])
expect(results.filter(Boolean)).toHaveLength(1)
})
it('cleans expired pending and processing entries', async () => {
const pending = await createToken('contact', { email: 'pending@example.invalid' }, -1)
const processing = await createToken('contact', { email: 'processing@example.invalid' }, 1)
await reserveToken(processing)
const stored = JSON.parse(readFileSync(tokensFile, 'utf8'))
stored.find(entry => entry.token === processing).expiresAt = new Date(Date.now() - 1000).toISOString()
writeFileSync(tokensFile, JSON.stringify(stored), 'utf8')
await cleanupExpiredTokens()
expect(pending).toBeTruthy()
expect(JSON.parse(readFileSync(tokensFile, 'utf8'))).toEqual([])
})
})
@@ -9,6 +9,18 @@ const BASE_URL = process.env.BASE_URL || 'https://andreknie.de'
let transporter = null
export class MailerNotReadyError extends Error {
constructor() {
super('Mailer ist nicht konfiguriert.')
this.name = 'MailerNotReadyError'
this.code = 'MAILER_NOT_READY'
}
}
export function isMailerReady() {
return Boolean(SMTP_HOST && SMTP_USER && SMTP_PASS)
}
function getTransporter() {
if (!transporter && SMTP_HOST && SMTP_USER && SMTP_PASS) {
transporter = nodemailer.createTransport({
@@ -23,10 +35,7 @@ function getTransporter() {
export async function sendConfirmationEmail(to, type, token) {
const t = getTransporter()
if (!t) {
console.log(`[Mailer] SMTP not configured. Would send ${type} confirmation to ${to}`)
return
}
if (!t) throw new MailerNotReadyError()
const confirmUrl = `${BASE_URL}/api/${type}/confirm/${token}`
const subjects = {
@@ -45,10 +54,7 @@ export async function sendConfirmationEmail(to, type, token) {
export async function sendStakeholderNotification(type, data) {
const t = getTransporter()
if (!t) {
console.log(`[Mailer] SMTP not configured. Would notify stakeholder about ${type}`)
return
}
if (!t) throw new MailerNotReadyError()
const subjects = {
contact: `Neue Kontaktanfrage von ${data.name}`,
@@ -80,11 +86,7 @@ export async function sendSpeakerCv(to, lead, pdfPath) {
const t = getTransporter()
const pdfMissing = !pdfPath || !existsSync(pdfPath)
if (!t) {
console.log(`[Mailer] SMTP not configured. Would send speaker CV to ${to} (pdfMissing=${pdfMissing})`)
await notifyStakeholderSpeakerCv(lead, pdfMissing)
return { sent: false, pdfMissing }
}
if (!t) throw new MailerNotReadyError()
if (!pdfMissing) {
await t.sendMail({
@@ -112,10 +114,7 @@ async function notifyStakeholderSpeakerCv(lead, pdfMissing) {
: 'CV wurde automatisch an den Anfragenden gesendet.',
].join('\n')
if (!t) {
console.log(`[Mailer] SMTP not configured. Would notify stakeholder about speaker-cv lead:\n${body}`)
return
}
if (!t) throw new MailerNotReadyError()
await t.sendMail({
from: SMTP_USER,
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { isMailerReady, MailerNotReadyError, sendConfirmationEmail } from './mailer.js'
describe('mailer readiness', () => {
it('reports missing SMTP configuration without exposing credentials', async () => {
expect(isMailerReady()).toBe(false)
await expect(sendConfirmationEmail('test@example.invalid', 'contact', 'synthetic-token'))
.rejects.toBeInstanceOf(MailerNotReadyError)
await expect(sendConfirmationEmail('test@example.invalid', 'contact', 'synthetic-token'))
.rejects.toMatchObject({ code: 'MAILER_NOT_READY' })
})
})
@@ -14,32 +14,28 @@ export default function Datenschutz() {
E-Mail: <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a>
</p>
<p><strong style={{ color: 'var(--text-primary)' }}>2. Erhebung und Verarbeitung personenbezogener Daten</strong></p>
<p>Die Nutzung dieser Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Personenbezogene Daten werden nur erhoben, wenn Sie diese aktiv über Formulare (Kontakt, Vortragsanfrage, Newsletter, Resource-Download) übermitteln.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>2. Erhebung und Zweck</strong></p>
<p>Diese Website kann grundsätzlich ohne Angabe personenbezogener Daten genutzt werden. Daten werden erhoben, wenn Sie ein Formular für eine Kontaktaufnahme, Vortragsanfrage, Newsletter-Anmeldung, einen Ressourcen-Download oder die Speaker-CV-Anfrage absenden.</p>
<p>Die Daten werden ausschließlich zur Bearbeitung der jeweiligen Anfrage, zur Zustellung angeforderter Inhalte oder zur Verwaltung des Newsletter-Abonnements verwendet. Eine Weitergabe erfolgt nur, soweit sie für diesen Zweck erforderlich ist oder Sie zugestimmt haben.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>3. Zweck der Datenverarbeitung</strong></p>
<p>Ihre Daten werden ausschließlich zur Bearbeitung Ihrer Anfrage bzw. zur Zustellung des Newsletters verwendet. Eine Weitergabe an Dritte ohne Ihre ausdrückliche Zustimmung erfolgt nicht.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>3. Kontakt- und Vortragsanfragen</strong></p>
<p>Name, E-Mail-Adresse und Nachricht werden bis zur Bestätigung in einer temporären Token-Datei gespeichert. Der Bestätigungslink ist 24 Stunden (Kontakt) beziehungsweise 48 Stunden (Vortragsanfrage) gültig. Nicht bestätigte, abgelaufene und erfolgreich verarbeitete Anfragen werden einschließlich ihrer Formulardaten gelöscht. Bei einem technischen Fehler bleibt die Anfrage vorübergehend für einen kontrollierten erneuten Verarbeitungsversuch erhalten.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>4. Rechtsgrundlage</strong></p>
<p>Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) bzw. zur Vertragsanbahnung (Art. 6 Abs. 1 lit. b DSGVO).</p>
<p><strong style={{ color: 'var(--text-primary)' }}>4. Newsletter (Double-Opt-In)</strong></p>
<p>Für die Newsletter-Anmeldung wird Ihre E-Mail-Adresse bis zur Bestätigung temporär gespeichert. Nach erfolgreicher Bestätigung wird sie als aktives Abonnement gespeichert, bis Sie sich abmelden. Eine Abmeldung können Sie jederzeit per E-Mail an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> mit dem Hinweis Newsletter abbestellen veranlassen.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>5. Newsletter (Double Opt-In)</strong></p>
<p>Bei Anmeldung zum Newsletter wird Ihre E-Mail-Adresse gespeichert. Die Anmeldung erfolgt im Double-Opt-In-Verfahren: Sie erhalten eine Bestätigungs-E-Mail und müssen die Anmeldung aktiv bestätigen. Sie können das Abonnement jederzeit widerrufen.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>5. Ressourcen-Downloads</strong></p>
<p>Für einen Ressourcen-Download werden Name, E-Mail-Adresse, Unternehmen, angeforderte Ressource und Zeitpunkt gespeichert. Die Daten dienen der Bearbeitung der Download-Anfrage und der Beziehungspflege. Sie werden gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>6. Kontaktformulare</strong></p>
<p>Bei Nutzung der Kontakt- oder Vortragsanfrage-Formulare werden die übermittelten Daten (Name, E-Mail, Nachricht) zur Bearbeitung der Anfrage gespeichert. Eine Bestätigungs-E-Mail wird zur Verifizierung versendet. Nicht bestätigte Anfragen werden nach 48 Stunden gelöscht.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>6. Speaker-CV-Anfrage</strong></p>
<p>Für die Speaker-CV-Anfrage werden Name, E-Mail-Adresse, Nachricht und Zeitpunkt gespeichert. Die Daten werden nach erfolgreichem Versand des CV zur Bearbeitung und Beziehungspflege gespeichert und gelöscht, sobald der Zweck entfällt oder Sie die Löschung unter <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a> anfordern.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>7. Resource-Downloads & Speaker CV</strong></p>
<p>Für den Download bestimmter Ressourcen sowie für die Anforderung des Speaker CV werden Name, E-Mail und Ihre Nachricht erhoben. Diese Daten werden zur Bearbeitung Ihrer Anfrage, zur Zusendung der angeforderten Unterlagen und zur Beziehungspflege verwendet.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>7. Rechtsgrundlage und Ihre Rechte</strong></p>
<p>Die Verarbeitung erfolgt je nach Vorgang auf Grundlage Ihrer Einwilligung oder zur Bearbeitung einer von Ihnen angefragten Leistung. Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich dafür an <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a>.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>8. Hosting</strong></p>
<p>Diese Webseite wird auf Servern in der EU gehostet. Es erfolgt keine Datenübertragung in Drittländer.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>8. Hosting und Webanalyse</strong></p>
<p>Diese Webseite wird auf Servern in der EU gehostet. Es werden keine Analyse-Cookies gesetzt. Zur statistischen Auswertung wird die selbstgehostete Open-Source-Software Umami unter <strong>stats.andreknie.de</strong> eingesetzt. Der Tracker ist auf andreknie.de und www.andreknie.de begrenzt, respektiert die Do-Not-Track-Einstellung und arbeitet ohne Cookies; die IP-Adresse wird anonymisiert.</p>
<p><strong style={{ color: 'var(--text-primary)' }}>9. Ihre Rechte</strong></p>
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Wenden Sie sich an: <a href="mailto:datenschutz@d-hive.de">datenschutz@d-hive.de</a></p>
<p><strong style={{ color: 'var(--text-primary)' }}>10. Cookies und Webanalyse</strong></p>
<p>Diese Webseite verwendet <strong>keine</strong> Cookies zur Analyse Ihres Nutzerverhaltens. Um die Nutzung unserer Website statistisch auszuwerten und das Angebot zu verbessern, setzen wir die selbstgehostete Open-Source-Software <strong>Umami</strong> unter <strong>stats.andreknie.de</strong> ein. Der Tracker ist auf andreknie.de und www.andreknie.de begrenzt, respektiert die Do-Not-Track-Einstellung Ihres Browsers und übermittelt keine Suchparameter oder URL-Hash-Werte. Umami arbeitet ohne Cookies; Ihre IP-Adresse wird anonymisiert. Ein Rückschluss auf Ihre Person ist nicht möglich.</p>
</div>
</div>
</main>