Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0334252fed | ||
|
|
8236cce85c | ||
|
|
0459daa074 | ||
|
|
9c9b0b864d | ||
|
|
dfae463402 | ||
|
|
b31da6175a |
@@ -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.
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
- quote: "André verbindet etwas, das ich in dieser Form nur selten erlebe: tiefes technisches Verständnis für KI mit einem echten Gespür für Menschen und Organisationen. Er kennt die Möglichkeiten moderner KI, versteht aber gleichzeitig die Besonderheiten von Hochschulen. Dadurch gelingt es ihm, komplexe Technologien verständlich zu machen und Organisationen zu befähigen, eigene tragfähige Lösungen zu entwickeln. Dabei geht er nicht den einfachsten Weg, sondern sucht gemeinsam nach der bestmöglichen Lösung. Besonders schätze ich die Zusammenarbeit im Tandem: Die Kombination aus unserer internen Perspektive und seiner externen Expertise hat unsere KI-Reise entscheidend vorangebracht."
|
||||
name: "Maike Mach"
|
||||
role: "Stellvertretende Leitung Organisationsentwicklung, Universität Kassel"
|
||||
role: "Stellv. Leiterin Personal- und Organisationsentwicklung, Universität Kassel"
|
||||
|
||||
- quote: "André bringt uns voran, begeistert Menschen, zeigt auf was sinnvoll ist und was nicht. Durch ihn sind wir Größenordnungen schneller und sicherer. Sein Team und er befähigen uns, Dinge umzusetzen, von denen wir vorher nicht geträumt hätten."
|
||||
name: "Kunde - Projektleitung Digitalisierung"
|
||||
|
||||
@@ -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,132 @@
|
||||
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)
|
||||
})
|
||||
|
||||
it('rejects unknown resources before any notification is sent', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/resource-download`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Test',
|
||||
email: 'test@example.invalid',
|
||||
company: 'Synthetic Test',
|
||||
resource_id: 'does-not-exist',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect((await response.json()).errors.resource_id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('silently accepts resource honeypot submissions', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/resource-download`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Test',
|
||||
email: 'test@example.invalid',
|
||||
company: 'Synthetic Test',
|
||||
resource_id: 'does-not-exist',
|
||||
company_website: 'https://bot.example.invalid',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect((await response.json()).ok).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import { Router } from 'express'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs'
|
||||
import { existsSync, readdirSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { resourceLimiter } from '../middleware/rateLimiter.js'
|
||||
import { antiSpam } from '../middleware/antiSpam.js'
|
||||
import { sendStakeholderNotification, MailerNotReadyError } from '../services/mailer.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const LEADS_FILE = join(__dirname, '..', 'data', 'leads.json')
|
||||
const leadsMutex = new Mutex()
|
||||
const RESOURCES_DIR = join(__dirname, '..', '..', 'public', 'resources')
|
||||
|
||||
// Only alphanumeric + hyphen resource IDs are allowed. This prevents path
|
||||
// traversal (e.g. "../../etc/passwd") when building the download URL.
|
||||
const RESOURCE_ID_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||||
|
||||
function loadLeads() {
|
||||
if (!existsSync(LEADS_FILE)) return []
|
||||
try { return JSON.parse(readFileSync(LEADS_FILE, 'utf-8')) }
|
||||
catch { return [] }
|
||||
}
|
||||
|
||||
function saveLeads(leads) {
|
||||
writeFileSync(LEADS_FILE, JSON.stringify(leads, null, 2), 'utf-8')
|
||||
export function getAvailableResourceIds() {
|
||||
if (!existsSync(RESOURCES_DIR)) return []
|
||||
return readdirSync(RESOURCES_DIR, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile() && entry.name.endsWith('.pdf') && entry.name !== 'speaker-cv-andre-knie.pdf')
|
||||
.map(entry => entry.name.slice(0, -4))
|
||||
}
|
||||
|
||||
const router = Router()
|
||||
@@ -34,26 +26,18 @@ router.post('/', resourceLimiter, antiSpam, async (req, res) => {
|
||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Gültige E-Mail erforderlich.'
|
||||
if (!company || company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
|
||||
if (company && company.length > 150) errors.company = 'Max. 150 Zeichen.'
|
||||
if (!resource_id || !RESOURCE_ID_PATTERN.test(resource_id)) errors.resource_id = 'Ungültige Resource-ID.'
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return res.status(400).json({ errors })
|
||||
if (!resource_id || !getAvailableResourceIds().includes(resource_id)) {
|
||||
errors.resource_id = 'Diese Ressource ist derzeit nicht verfügbar.'
|
||||
}
|
||||
|
||||
await leadsMutex.runExclusive(async () => {
|
||||
const leads = loadLeads()
|
||||
leads.push({
|
||||
name,
|
||||
email,
|
||||
company,
|
||||
resource_id,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
saveLeads(leads)
|
||||
})
|
||||
if (Object.keys(errors).length > 0) return res.status(400).json({ errors })
|
||||
|
||||
// Return download URL (resource files are in public/resources/)
|
||||
res.json({ ok: true, download_url: `/resources/${resource_id}.pdf` })
|
||||
try {
|
||||
await sendStakeholderNotification('resource-download', { name, email, company, resource_id })
|
||||
res.status(202).json({ ok: true, message: 'Danke! Wir prüfen die Anfrage und melden uns bei dir.' })
|
||||
} catch (error) {
|
||||
res.status(error instanceof MailerNotReadyError ? 503 : 502).json({ error: 'Die Anfrage konnte derzeit nicht übermittelt werden.' })
|
||||
}
|
||||
})
|
||||
|
||||
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,28 +54,35 @@ 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}`,
|
||||
'talk-request': `Neue Vortragsanfrage von ${data.name}`,
|
||||
'resource-download': `Ressourcenanfrage von ${data.name}`,
|
||||
}
|
||||
|
||||
const body = Object.entries(data)
|
||||
.map(([key, val]) => `${key}: ${val}`)
|
||||
.join('\n')
|
||||
const body = buildStakeholderMessage(type, data)
|
||||
|
||||
await t.sendMail({
|
||||
from: SMTP_USER,
|
||||
to: STAKEHOLDER_EMAIL,
|
||||
to: type === 'resource-download' ? 'kontakt@d-hive.de' : STAKEHOLDER_EMAIL,
|
||||
subject: subjects[type] || `Neue Anfrage (${type})`,
|
||||
text: body,
|
||||
})
|
||||
}
|
||||
|
||||
export function buildStakeholderMessage(type, data) {
|
||||
const fields = Object.entries(data).map(([key, val]) => `${key}: ${val}`)
|
||||
if (type !== 'resource-download') return fields.join('\n')
|
||||
|
||||
return [
|
||||
'Bitte die angefragten Inhalte prüfen und bei positiver Prüfung ein aktuelles Profil von André verschicken.',
|
||||
'',
|
||||
...fields,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export { STAKEHOLDER_EMAIL, BASE_URL }
|
||||
|
||||
/**
|
||||
@@ -80,11 +96,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 +124,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,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildStakeholderMessage,
|
||||
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' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('stakeholder notification content', () => {
|
||||
const data = {
|
||||
name: 'Synthetic Test',
|
||||
email: 'test@example.invalid',
|
||||
}
|
||||
|
||||
it('adds the manual profile instruction to resource requests', () => {
|
||||
const message = buildStakeholderMessage('resource-download', data)
|
||||
|
||||
expect(message).toContain('ein aktuelles Profil von André verschicken')
|
||||
expect(message).toContain('name: Synthetic Test')
|
||||
})
|
||||
|
||||
it('does not add the resource instruction to contact notifications', () => {
|
||||
const message = buildStakeholderMessage('contact', data)
|
||||
|
||||
expect(message).not.toContain('ein aktuelles Profil von André verschicken')
|
||||
expect(message).toBe('name: Synthetic Test\nemail: test@example.invalid')
|
||||
})
|
||||
})
|
||||
@@ -114,6 +114,23 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dock { gap: 6px; padding: 6px 8px; }
|
||||
.dock-item { width: 38px; height: 38px; }
|
||||
.dock-container {
|
||||
bottom: max(12px, env(safe-area-inset-bottom));
|
||||
max-width: calc(100vw - 24px);
|
||||
}
|
||||
|
||||
.dock {
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.dock-item-wrapper-secondary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dock-item {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,22 +10,27 @@ export default function BottomDock() {
|
||||
{ label: 'Home', path: '/', icon: <Home size={20} /> },
|
||||
{ label: 'Artikel', path: '/artikel', icon: <Newspaper size={20} /> },
|
||||
{ label: 'Kniepunkt', path: '/kniepunkt', icon: <span style={{ fontWeight: 800, fontSize: '1.2rem', fontFamily: 'serif' }}>K</span> },
|
||||
{ label: 'Podcast', path: '/podcast', icon: <Mic size={20} /> },
|
||||
{ label: 'Podcast', path: '/podcast', icon: <Mic size={20} />, secondary: true },
|
||||
{ label: 'Speaking', path: '/speaking', icon: <MessageSquare size={20} /> },
|
||||
{ label: 'Beratung', path: '/consulting', icon: <Briefcase size={20} /> },
|
||||
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} /> },
|
||||
{ label: 'Beratung', path: '/consulting', icon: <Briefcase size={20} />, secondary: true },
|
||||
{ label: 'Über mich', path: '/ueber-mich', icon: <User size={20} />, secondary: true },
|
||||
{ label: 'Kontakt', path: '/kontakt', icon: <Mail size={20} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="dock-container">
|
||||
<nav className="dock">
|
||||
<nav className="dock" aria-label="Schnellnavigation">
|
||||
{items.map((item) => {
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<div className="dock-item-wrapper" key={item.path}>
|
||||
<div className={`dock-item-wrapper${item.secondary ? ' dock-item-wrapper-secondary' : ''}`} key={item.path}>
|
||||
<span className="dock-tooltip">{item.label}</span>
|
||||
<Link to={item.path} className={`dock-item ${isActive ? 'active' : ''}`}>
|
||||
<Link
|
||||
to={item.path}
|
||||
className={`dock-item ${isActive ? 'active' : ''}`}
|
||||
aria-label={item.label}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import BottomDock from './BottomDock'
|
||||
|
||||
describe('BottomDock', () => {
|
||||
it('keeps secondary links available on desktop and marks them for mobile hiding', () => {
|
||||
const { container } = render(
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<BottomDock />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('.dock-item')).toHaveLength(8)
|
||||
expect(container.querySelectorAll('.dock-item-wrapper-secondary')).toHaveLength(3)
|
||||
expect(container.querySelectorAll('.dock-item[aria-label]')).toHaveLength(8)
|
||||
})
|
||||
|
||||
it('reserves mobile space and keeps touch targets at least 44 pixels', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src', 'components', 'BottomDock.css'), 'utf-8')
|
||||
const mobileRule = css.match(/@media \(max-width: 600px\) \{([\s\S]*)\}\s*$/)
|
||||
|
||||
expect(mobileRule?.[1]).toContain('dock-item-wrapper-secondary')
|
||||
expect(mobileRule?.[1]).toMatch(/width: 44px/)
|
||||
expect(mobileRule?.[1]).toMatch(/height: 44px/)
|
||||
})
|
||||
})
|
||||
@@ -42,6 +42,9 @@
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 24px;
|
||||
--radius-pill: 50px;
|
||||
|
||||
/* Space reserved for the fixed mobile navigation dock. */
|
||||
--dock-content-space: 0px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -65,6 +68,13 @@ body {
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: var(--dock-content-space);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:root {
|
||||
--dock-content-space: calc(88px + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
@@ -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. Ressourcenanfragen</strong></p>
|
||||
<p>Für eine Ressourcenanfrage werden Name, E-Mail-Adresse, Unternehmen und die angeforderte Ressource ausschließlich an <a href="mailto:kontakt@d-hive.de">kontakt@d-hive.de</a> übermittelt. Es erfolgt kein automatischer Download und kein automatischer Versand. Die Anfrage dient der Prüfung, ob André ein aktuelles Profil verschickt. Eine lokale Lead-Datei wird dabei nicht angelegt.</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>
|
||||
|
||||
@@ -12,6 +12,46 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.hero {
|
||||
padding-top: 100px;
|
||||
padding-bottom: calc(60px + var(--dock-content-space));
|
||||
}
|
||||
|
||||
.hero-photo {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.25rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-roles {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.hero-subtext {
|
||||
font-size: 1rem;
|
||||
line-height: 1.45;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.hero-actions .primary-button,
|
||||
.hero-actions .secondary-button {
|
||||
padding: 12px 18px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
@@ -1,29 +1,110 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useContent } from '../hooks/useContent'
|
||||
import { useFormSubmit } from '../hooks/useFormSubmit'
|
||||
import { validateResourceDownload } from '../utils/validation'
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
background: 'var(--bg-elevated)',
|
||||
border: '1px solid var(--glass-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '1rem',
|
||||
}
|
||||
|
||||
function ResourceRequestForm({ resource, onCancel }) {
|
||||
const resourceId = resource.id || resource.slug
|
||||
const [form, setForm] = useState({ name: '', email: '', company: '', resource_id: resourceId, company_website: '' })
|
||||
const { submit, loading, success, errors, serverError } = useFormSubmit('/api/resource-download', validateResourceDownload)
|
||||
|
||||
function updateField(event) {
|
||||
setForm(prev => ({ ...prev, [event.target.name]: event.target.value }))
|
||||
}
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault()
|
||||
await submit(form)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-panel" style={{ padding: '24px', marginTop: '24px' }}>
|
||||
{success ? (
|
||||
<p style={{ margin: 0, color: 'var(--text-primary)' }}>
|
||||
Danke! Wir prüfen die Anfrage und melden uns bei dir.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<h2 style={{ fontSize: '1.25rem', marginBottom: '8px' }}>Material anfragen</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', marginTop: 0 }}>
|
||||
Wir prüfen die Anfrage. Bei positiver Prüfung verschickt André ein aktuelles Profil persönlich.
|
||||
</p>
|
||||
|
||||
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', width: '1px', height: '1px', overflow: 'hidden' }}>
|
||||
<label htmlFor="resource-company-website">Firmen-Website (bitte leer lassen)</label>
|
||||
<input id="resource-company-website" name="company_website" tabIndex={-1} autoComplete="off" value={form.company_website} onChange={updateField} />
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="resource_id" value={resourceId} />
|
||||
|
||||
<label htmlFor="resource-name">Name</label>
|
||||
<input id="resource-name" name="name" value={form.name} onChange={updateField} style={inputStyle} autoComplete="name" aria-invalid={Boolean(errors.name)} />
|
||||
{errors.name && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.name}</p>}
|
||||
|
||||
<label htmlFor="resource-email">E-Mail</label>
|
||||
<input id="resource-email" name="email" type="email" value={form.email} onChange={updateField} style={inputStyle} autoComplete="email" aria-invalid={Boolean(errors.email)} />
|
||||
{errors.email && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.email}</p>}
|
||||
|
||||
<label htmlFor="resource-company">Unternehmen</label>
|
||||
<input id="resource-company" name="company" value={form.company} onChange={updateField} style={inputStyle} autoComplete="organization" aria-invalid={Boolean(errors.company)} />
|
||||
{errors.company && <p role="alert" style={{ color: '#ff4444', margin: '4px 0 12px' }}>{errors.company}</p>}
|
||||
|
||||
{serverError && <p role="alert" style={{ color: '#ff4444' }}>{serverError}</p>}
|
||||
{errors.resource_id && <p role="alert" style={{ color: '#ff4444' }}>{errors.resource_id}</p>}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '20px', flexWrap: 'wrap' }}>
|
||||
<button type="submit" className="primary-button" disabled={loading}>
|
||||
{loading ? 'Wird gesendet...' : 'Anfrage senden'}
|
||||
</button>
|
||||
<button type="button" className="secondary-button" onClick={onCancel}>Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Resources() {
|
||||
const { items } = useContent('resources', { pageSize: 20 })
|
||||
const [selectedResource, setSelectedResource] = useState(null)
|
||||
|
||||
return (
|
||||
<main style={{ paddingTop: '120px', minHeight: '100vh' }}>
|
||||
<div className="app-container">
|
||||
<div className="section-header">
|
||||
<h1 className="section-title"><span className="text-gradient">Ressourcen</span></h1>
|
||||
<p className="section-subtitle">Praktische Materialien zum Download.</p>
|
||||
<p className="section-subtitle">Materialien auf Anfrage. Jede Anfrage wird zunächst geprüft.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '20px', maxWidth: '700px', margin: '0 auto' }}>
|
||||
{items.map(res => (
|
||||
<div key={res.id || res.slug} className="glass-panel" style={{ padding: '24px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px' }}>
|
||||
{items.map(resource => {
|
||||
const resourceId = resource.id || resource.slug
|
||||
const selected = selectedResource === resourceId
|
||||
return (
|
||||
<div key={resourceId} className="glass-panel" style={{ padding: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<h3 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{res.title}</h3>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{res.description}</p>
|
||||
<h2 style={{ marginBottom: '4px', fontSize: '1.1rem' }}>{resource.title}</h2>
|
||||
<p style={{ color: 'var(--text-secondary)', margin: 0, fontSize: '0.9rem' }}>{resource.description}</p>
|
||||
</div>
|
||||
<button className="primary-button" style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
Download
|
||||
<button type="button" className="primary-button" onClick={() => setSelectedResource(selected ? null : resourceId)} style={{ padding: '10px 20px', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
{selected ? 'Schließen' : 'Material anfragen'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{selected && <ResourceRequestForm key={resourceId} resource={resource} onCancel={() => setSelectedResource(null)} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{items.length === 0 && <p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>Ressourcen werden vorbereitet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +41,6 @@ export function validateResourceDownload(data) {
|
||||
if (!data.email || !validateEmail(data.email)) errors.email = 'Gültige E-Mail-Adresse erforderlich.'
|
||||
if (!data.company || data.company.trim().length === 0) errors.company = 'Unternehmen ist erforderlich.'
|
||||
if (data.company && data.company.length > 150) errors.company = 'Unternehmen darf maximal 150 Zeichen lang sein.'
|
||||
if (!data.resource_id) errors.resource_id = 'Resource-ID fehlt.'
|
||||
if (!data.resource_id || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(data.resource_id)) errors.resource_id = 'Resource-ID fehlt oder ist ungültig.'
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
@@ -125,4 +125,10 @@ describe('validateResourceDownload', () => {
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.resource_id).toBeTruthy()
|
||||
})
|
||||
|
||||
it('fails for a resource id that could escape the resource naming scheme', () => {
|
||||
const result = validateResourceDownload({ ...valid, resource_id: '../profile' })
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.resource_id).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user