Compare commits

..

78 commits
v1.3.2 ... main

Author SHA1 Message Date
d6f71b9299 dev: Rechtspaket Deploy A — Entscheidungen 14.10., Go-Live-Plan, Deploy-Notiz v1.14.0, Roadmap 3
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Ausgearbeitete Texte (Syndikationsvertrag, Prüfvermerk-Antworten, DSE-0.4-
Ersatztexte, AGB-Bildrechte-Gate, Versandfassung beantwortet), DSE-0.4-
Veröffentlichungsfassung, Umsetzungsstand mit Nachtrag 15.10. und der
Abschluss- und Go-Live-Plan (D1–D7, R1–R8, Fristenmatrix, Deploys A/A2/B).
Roadmap: v1.14.0 in der Release-Tabelle, 3.1 und 3.2 erledigt, Stand des
Rechtspakets unter Abschnitt 3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 12:48:53 +00:00
95b7b0d062 api: Alt-Schnittstelle speichert „keine Angabe“ statt „ohne KI“, Einreich-Dialog nennt Mensch und AGB
RP-09: Meldungen über /service ohne KI-Feld bekommen AiTextDeclaration
NotDeclared — kein öffentliches Label, keine Bestätigung im Namen des
Kunden, im Editor und in API v1 nicht wählbar (declarableValues()). Der
Audit-Trail hält „nicht erklärt“ fest; dafür wird
press_release_ai_declarations.confirmed_at nullable (Migration). Der
Einreich-Dialog sagt, dass eine menschliche Prüfung schon nach der ersten
Ablehnung verlangt werden kann, verweist bei der Freistellung auf die AGB
und formuliert die KI-Angabe ohne Gesetzesbehauptung.

Changelog: Verbessert
Public: Über die bisherige Schnittstelle eingereichte Meldungen erhalten keine KI-Erklärung mehr im Namen des Kunden; der Einreich-Dialog nennt, dass eine menschliche Prüfung schon nach der ersten Ablehnung möglich ist.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 12:48:28 +00:00
6c504262e6 web: Boost als „Anzeige · Gebuchte Platzierung“, Aufmacher ohne redaktionelles Tag
Entscheidung D1 (14.10.): Das Label nennt „Anzeige“, der Hinweis die
Bezahlung („nicht unsere Auswahl“). Der bezahlte Aufmacher auf Start- und
Kategorieseite trägt nur noch das Anzeige-Tag, der unbezahlte heißt
„Neueste Meldung“ statt „Top-Meldung“. Preisseite nennt das Label. DE/EN.

Changelog: Verbessert
Public: Gebuchte Platzierungen sind auf den Portalen als „Anzeige · Gebuchte Platzierung“ gekennzeichnet; der bezahlte Aufmacher trägt kein redaktionelles Tag mehr.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 12:48:28 +00:00
d56f758e2e legal: Datenschutzerklärung 0.4 ab 16.10., Turnstile ohne IP, Art.-14-Hinweis in Pressekontakt-Mails
Rechtspaket 10/2026, Deploy A (Ist-Berichtigung). DSE 0.4 als Blade-Fassung
mit drei Partials (Aktivitätsverlauf, Bilder, Beweisarchiv); Version und
Stand kommen aus legal_documents, der Seeder trägt 0.4 mit Gültigkeit
16.10.2026 ein. Neue Abschnitte: Fehlerprotokoll (3a, nur serverseitig),
Kontosicherheit (7a), Zustellbarkeit (12a), Archivfunktion statt § 57 MStV,
nur OpenAI als Prüfanbieter, vollständige Fristenliste; kommende Fristen aus
Deploy B stehen als „führen wir ein“. Turnstile sendet kein remoteip mehr.
Pressekontakt-Mails verweisen unter dem Abmeldehinweis auf die DSE.

Changelog: Verbessert
Public: Die Datenschutzerklärung beschreibt ab dem 16. Oktober in Version 0.4 alle laufenden Verarbeitungen, Fristen und das Beweisarchiv; Pressekontakte finden den Link dazu in jeder Benachrichtigung.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 12:48:28 +00:00
ce5d882280 dev: Roadmap v1.13.1 live
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:06:15 +00:00
218d706141 dev: Deploy-Notiz v1.13.1 ergänzt (Changelog verlinkt)
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:49:52 +00:00
e9cd572aa4 web(hub): Changelog im Header vor der FAQ und im Footer unter Plattform
Changelog: Verbessert
Public: Der Changelog ist auf pressekonto.com über die Navigation und den Footer erreichbar.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:49:52 +00:00
b38c69bd36 dev: Deploy-Notiz v1.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:30:26 +00:00
39c5065807 dev: Roadmap 6.17, Prüfmail nachgezogen
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:30:04 +00:00
67016ed549 mail: Prüfmail führt Pressekontakte per Anmeldelink zur Meldung
Die Mail „Ihre Pressemitteilung wird persönlich geprüft“ verlinkte auch
bei API-Meldungen in den Kundenbereich. Empfänger ohne Konto landeten auf
der Passwort-Anmeldung. Wie in der Veröffentlichungs- und Ablehnungsmail
schickt jetzt ein Klick einen Anmeldelink an die Adresse der Mail; die
Zugangsseite übernimmt die Herkunft mail_review für den Funnel.

Changelog: Verbessert
Public: Auch die Mail zur persönlichen Prüfung führt Pressekontakte ohne Passwort zu ihrer Meldung.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:30:04 +00:00
209a79f173 dev: Roadmap v1.13.0 live, Changelog-Nachtrag freigegeben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:43:02 +00:00
ee849271fc dev: deploy.sh legt Changelog-Entwürfe an, Deploy-Notiz v1.13.0, Roadmap 6.6
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:31:21 +00:00
678070a0f9 build: Assets für v1.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:30:46 +00:00
2c590fa9df changelog: Entwürfe aus den Commits, Freigabe im Admin, öffentliche Seite mit RSS
Decision-Update Changelog, Stufen 1 bis 3:
- changelog:collect liest die Commits zwischen zwei Tags und legt aus den
  Zeilen Changelog:/Public: Entwürfe an (ganze Nachricht, nicht nur der
  Trailer-Block); ohne Changelog-Zeile intern, Sicherheit als eine
  Sammelzeile je Release, Sicherheitsbegriffe im Titel stufen hoch, ohne
  Public-Satz ein markierter Vorschlag aus dem Titel; idempotent.
- changelog:backfill trägt die Neuerungen seit dem Relaunch als Entwurf nach.
- Admin ▸ Changelog (settings:manage): gegenlesen, bearbeiten, Rubrik,
  artikelwürdig, freigeben/verwerfen, Tag gesammelt freigeben, Eintrag von Hand.
- pressekonto.com/changelog und /changelog/feed (RSS), auf den Portalen
  301 zum Hub; Kundenmenü „Was ist neu“ mit Neu-Kennzeichen (14 Tage).

Changelog: Neu
Public: Unter pressekonto.com/changelog steht, was sich auf pressekonto, presseecho und businessportal24 ändert – auch als RSS-Feed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:30:46 +00:00
ebd61c8fd9 dev: Roadmap 6.18 entschieden und umgesetzt, Deploy-Notiz v1.12.0 ergänzt
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:54:29 +00:00
b65998cbed mail: Pressekontakte reichen abgelehnte API-Meldungen kostenlos neu ein
Roadmap 6.18, Entscheidung 14.10.2026: Der Pressekontakt überarbeitet eine
abgelehnte API-Meldung über den Anmeldelink selbst und gibt sie erneut zur
Prüfung frei; die Ablehnungsmail sagt das jetzt so. Kostenlos, weil ein Slot
erst bei der Veröffentlichung und einmal je Meldung verbraucht wird.

Behoben: Das Mail-Routing las die Herkunft aus dem LETZTEN Review-Übergang.
Nach einer Wiedereinreichung im Kundenbereich galt die Meldung nicht mehr als
API-Meldung, und Veröffentlichungs- bzw. Ablehnungsmail wären an das Konto
des Einlieferers (connektar) gegangen statt an den Kontakt. Jetzt zählt jeder
Review-Übergang aus der API, wie schon in der Indexierungsauswertung.

Changelog: Verbessert
Public: Wird eine Pressemitteilung abgelehnt, die über einen Verteiler kam, kann der Pressekontakt sie selbst überarbeiten und kostenlos erneut einreichen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:54:17 +00:00
bd0aaaf2e6 dev: Deploy-Notiz v1.12.0, Roadmap 6.17 erledigt, 6.18 Wiedereinreichung und 6.19 Änderungsregel/Datenschutz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:06:41 +00:00
1efb30f9ce mail: Anmeldelink an die Adresse der Mail statt „in Ihrem Konto“
Roadmap 6.17. Veröffentlichungs- und Ablehnungsmails zu API-Meldungen gehen
an Pressekontakt oder Firmen-E-Mail — Empfänger ohne (bekanntes) Konto. Die
Mail sagte „in Ihrem Konto … mit Passwort oder Magic-Link“, gelesen wurde
„geht nicht“. Jetzt ein Kasten mit der Adresse und dem Knopf „Anmeldelink
anfordern“: signierter Link (30 Tage, Adresse verschlüsselt) auf /zugang,
ein Klick schickt den Anmeldelink, die Anmeldung landet auf der Meldung.
Ablehnung zu API-Meldungen: nur ansehen, neu eingereicht wird auf dem
ursprünglichen Weg. Kunden mit Konto: Knopf „Im pressekonto öffnen“.
Stufen 2 und 3 des Trichters messen die neue Seite mit.

Changelog: Verbessert
Public: Wer eine Veröffentlichungs- oder Ablehnungsmail bekommt, erhält mit einem Klick einen Anmeldelink an genau diese Adresse – ohne Konto und ohne Passwort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:06:14 +00:00
c2f44f42c3 dev: Deploy-Notiz v1.11.0, Roadmap 6.16 live und 6.17 teilweise erledigt
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:27:08 +00:00
33fcde5558 portal: Firmenseiten verlinken den öffentlichen Newsroom
Im Backend heißt der Datensatz weiter „Firma“, öffentlich ist er der
Newsroom. Kunden- und Admin-Firmenseite heißen „Firma & Newsroom“ und zeigen
unter dem Namen die Newsroom-Links je Portal oder den Hinweis, dass der
Newsroom erst mit der ersten Meldung erscheint (Company::publicNewsroomLinks).

Changelog: Verbessert
Public: Die Firmenseite im Kundenbereich verlinkt jetzt direkt auf den öffentlichen Newsroom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:26:08 +00:00
2c332052ce web: Newsroom-Seite mit eigenem Kasten für den Verwaltungszugang
Statt einer grauen Textzeile in der Profilbox derselbe Kasten wie unter jeder
Meldung (maskierte Adresse, Knopf „Anmeldelink anfordern“), jetzt als
gemeinsame Komponente. Mobil direkt unter dem Profil, am Desktop oben in der
Seitenleiste; der doppelte Kasten „Eigenen Newsroom starten“ entfällt.
Roadmap 6.17: 7 Firmen öffneten die Verwaltungsseite, eine forderte an.

Changelog: Verbessert
Public: Auf jeder Newsroom-Seite führt ein eigener Kasten zum kostenlosen Verwaltungszugang – mit der Adresse, an die der Anmeldelink geht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:26:08 +00:00
a157d7a2a1 dev: Deploy-Notiz v1.10.0, Roadmap 6.16, Umami-Hinweis in CLAUDE.md
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:55:55 +00:00
623fa66771 build: Assets für v1.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:55:29 +00:00
b4870f98bb admin: Reports ▸ Wachstum mit den Abbruchbedingungen, Umami nur lesend angebunden
Roadmap 6.1/6.16: Block 1 bis 3 mit Ist, zeitanteiligem Soll, Schwelle und
Status, Trichter seit 17.09., Transparenzseiten aus Umami. Neue Befehle
growth:report und umami:report. Zugang über UMAMI_API_* (View-only-Benutzer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:55:29 +00:00
e8cf684f02 conversion: Trichter misst Verwaltungsseite, Anforderung und Firma bei der Einlösung
Roadmap 6.16. Bisher stand jede Einlösung ohne Firma im Trichter (Stufe
"Magic Link eingelöst" immer 0 Firmen), zwischen Mail-Klick und Einlösung
fehlten "Verwaltungsseite geöffnet" und "Anmeldelink angefordert", und
die Erstbuchung stand immer auf organic. Die Leiter hat jetzt sieben Stufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:55:29 +00:00
f5e3f1d1fe dev: Roadmap — Abbruchbedingungen, Spitze ohne Partner, Messfehler im Trichter
IMKIS abgeschlossen, Partnerunterlagen unter dev/partner/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:44:24 +00:00
eb9e991654 dev: Roadmap — Anwaltspaket versandfertig
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:40:27 +00:00
0498c734fd dev: Anschreiben an den Anwalt, BN-01 erledigt, Anlagenliste final
Anschreiben mit Bitte, Vorrang, Lesereihenfolge und vollständiger
Dateiliste der Anlage 7 (nur Belege, keine Arbeitsnotizen). BN-01: doppelte
.env-Einträge bereinigt, kurzzeitig abgeschaltete Rechtebestätigung wieder
aktiv, keine Uploads in der Lücke.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:40:20 +00:00
4db979eb63 dev: BN-01 mit Live-Ausgabe und Widerspruch zur geladenen Konfiguration, Boost-Screenshots nach v1.9.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 12:32:31 +00:00
9961fbe8a1 dev: Paket ohne ZDR-Zusage, Verträge seit Go-Live, ui-06 nachgereicht
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
OpenAI: Zero Data Retention derzeit nicht beantragbar; DSE-Entwurf nennt die
Standardbehandlung der API (kein Training, bis zu 30 Tage
Missbrauchserkennung) und mögliche Verarbeitung in den USA auf Basis von
SCCs, keine EU-Datenresidenz. Alle Dienstleisterverträge bestehen seit vor
dem Go-Live; die Belege sind Neuausfertigungen vom 13.10.2026. Hetzner
Anlage 1 erweitert. Frage 5 um die Impressumsangabe nach § 18 Abs. 2 MStV
ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:55:42 +00:00
6c6015f960 dev: Nachweise gesichtet, Vertragsübersicht BN-06, Deploy-Notiz v1.9.1
Screenshots und Verträge geprüft und in nachweise/ zusammengeführt;
Binärdateien per .gitignore ausgeschlossen (vertraulich, Kunden- und
Organisationsnummern, Größe). Vertragsübersicht mit Fassungen, Daten und
Drittlandgrundlagen. Versandfassung: DPF-Status Cloudflare vom 13.10.2026,
OpenAI-DPA unterzeichnet am 13.10.2026. Juli-Zahlen auf Live bestätigt.
Protokollzeile v1.9.0 im Git-Deploy-Dokument.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:22:15 +00:00
6647e420c8 build: Assets für v1.9.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:22:10 +00:00
b3d2d1e5b4 web: Bildnachweis am Aufmacher auf Mobil nicht mehr vom Boost-Label verdeckt
Auf 390 px Breite lag das Schild „Gebuchte Platzierung“ über dem
Bildnachweis oben rechts. Der Nachweis sitzt auf Mobil jetzt unten rechts im
freien Rand unter der Meta-Zeile (ab sm unverändert oben rechts). Die
Erklärung am Schild ist rechtsbündig und stößt nicht mehr an den Bildrand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 11:22:10 +00:00
2d7e32b13c dev: Rollenprüfung BN-02 auf Live erledigt, Juli-Abfrage korrigiert
Nachher-Liste vom 13.10.2026: ein Admin-Konto mit Passkey und TOTP,
Super-Admin, keine Editoren, Zugänge außerhalb der Anwendung nur beim
Inhaber. Versandfassung D3 und Klärung U13 nachgezogen. Tinker-Befehl für
die Juli-Kampagnen nutzt status->value (Enum).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:37:57 +00:00
4bf22a81d0 admin: audit:admins mit Einzahl in der Zusammenfassung
„Gelistet ist 1 Konto“ statt „Gelistet sind 1 Konten“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:37:57 +00:00
53da182d17 dev: Rechtspaket mit Nachweisordner, Archiv und Deploy-Notiz v1.9.0
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Versandfassung mit Codeabgleich vom 13.10. korrigiert (Zugriffsprotokoll
des Beweisarchivs als Ziel, Admin-Bestand, Juli-Mails mit Zahlen und
Empfängerregel, C2PA als geplant, Gate-Dialog heute), Anlage 6 abgelegt.
Neuer Ordner rechtliches/nachweise mit Checkliste, Juli-Mailtexten,
OpenAI-Endpunkten, BN-01-Vorlage und BN-02-Ablauf inklusive Notfallzugang.
Vorfassungen, Codeauswertung, To-do-Liste und Rückfragen nach
archive/rechtliches-2026-10. Deploy-Notiz archive/betrieb/deploy-v1.9.0.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:16:21 +00:00
2d74fd28c9 build: Assets für v1.9.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:16:21 +00:00
fce7baea2b admin: Boost vorzeitig beenden, Berechtigungsliste audit:admins
Admin ▸ Pressemitteilung zeigt laufende Boosts; „Boost beenden“ verlangt
einen Grund und billing:manage, beendet laufende und angeschlossene
Zeiträume, verwirft die Seiten-Caches und protokolliert „Boost beendet“.
Credits werden nicht zurückgebucht.

audit:admins listet alle Konten mit Rechten über den Kundenbereich hinaus
(Super-Admin, Rollen außer customer/api-only, direkte Permissions), auch
gesperrte und gelöschte, als Markdown mit 2FA, Passkeys, Tokens und letztem
Sitzungswiderruf. E-Mail standardmäßig nur mit Domain (Nachweis BN-02).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:16:21 +00:00
2150d4b7ae web: Boost als „Gebuchte Platzierung“ mit Erklärung gekennzeichnet
Label „Gebuchte Platzierung“ (en „Booked placement“) statt „Top-Platzierung“
auf Start- und Branchenseite. Die Erklärung „Das Unternehmen hat diesen Platz
gebucht …“ erscheint am Desktop beim Überfahren und auf Mobil per Tipp, ohne
den umgebenden Link zu öffnen; Screenreader lesen sie mit. Kopfzeile über dem
Aufmacher „Aktuell“ statt „Top-Auswahl“ (automatische Auswahl, keine
Redaktion). Preisseite nennt die Kennzeichnung (Rechtspaket U10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:16:21 +00:00
8124d20946 dev: Deploy v1.8.0 im Git-Deploy-Protokoll, Hotfix v1.8.1 vorbereitet
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:37:33 +00:00
853dfe2131 web: „qualitätsgeprüft“ auch in Marken-Konfiguration und Tarif-Vorteilen ersetzt
Nachtrag zu v1.8.0, beim Prüfen nach dem Deploy gefunden: Meta-Beschreibungen
und Taglines der drei Marken (config/domains.php), Tarifvorteil „Starter“ im
PlanSeeder, Platzhalter im Admin. Der Wächtertest prüft jetzt auch config/
und database/seeders/. Live steht der Tarifvorteil in der Datenbank und wird
im Admin geändert (Seeder überschreibt sonst alle Tarifdaten).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:37:24 +00:00
2172d91d19 dev: Server-Logs auf 14 Tage umgestellt (Roadmap 5.13)
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:03:32 +00:00
da0b5bffbb dev: Server-Logs 52 → 14 Tage als Live-Aufgabe, Backup- und Monitoring-Fristen im Anwaltspaket
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:00:06 +00:00
a3663d5628 dev: Interessenabwägung Beweisarchiv (Entwurf), Anwaltspaket mit Hetzner Falkenstein und Backup-Rotation 14 Tage
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:52:28 +00:00
da011ccdbf dev: Entwurf Anwaltspaket Datenschutz (DSE 0.4, Fragen F1–F11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:39:40 +00:00
9692de581d dev: Changelog-Entscheidungen O1–O13 und E19–E21, Roadmap v1.8-Stand
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:35:54 +00:00
62d00a6c9d tests: fester Test-APP_KEY in phpunit.xml
Die Suite hing vom lokalen APP_KEY ab (lokal ungültig, Roadmap 1.3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:35:54 +00:00
ddff7dc989 web: Wortwahl „automatisierte Prüfung“ statt „qualitätsgeprüft“
Decision-Update Bildrechte 3.6 / Plan D.6: keine Siegel- oder
Redaktionszusage. Startseiten-Meta, CTA, Veröffentlichen-Seite (DE/EN),
Preisseite, Dashboard. „Qualitätsgeprüft seit 2012“ war sachlich falsch →
„Im Archiv seit :year“. Wächtertest gegen Rückfälle (Roadmap 3.7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:35:54 +00:00
ccef83b952 api: Newsletter-Anmeldung über die API eingestellt (410)
Der Endpunkt legte Abos ohne Bestätigungsweg an, die Spezifikation versprach
ein Double-Opt-in; seit dem Go-Live kein Aufruf. Antwortet jetzt mit 410,
die Ability ist für neue Tokens nicht mehr wählbar (Roadmap 4.3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:35:54 +00:00
002dc08b5c privacy: Löschfristen für Aktivitätsverlauf (395 Tage) und API-Zugriffsprotokoll (90 Tage)
Beide Tabellen hatten keine Frist; activity_events trägt Namen und Adressen
im Klartext, api_usage_logs IP und User-Agent. MassPrunable, täglich 04:05
über model:prune, Fristen in config/monitoring.php (Roadmap 4.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:35:54 +00:00
d1b9ffdcf1 security: Breadcrumbs ohne Daten an GlitchTip, Übergangsmarke der Session-Sperre entfernt
- ErrorReportScrubber entfernt die Daten aller Breadcrumbs und bereinigt ihre
  Nachrichten; Livewire-Breadcrumbs standardmäßig aus — das SDK hängt dort
  Formularwerte und Properties an (Roadmap 4.9). Bestand in GlitchTip geprüft:
  keine Formular- oder Passwortdaten.
- SessionRevocation::legacyCachedMark() entfernt (Übergang bis v1.6.0)

Changelog: Sicherheit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:35:54 +00:00
29e68330ca feat(security): Sicherheitsseite zeigt dieses Gerät und die letzten Anmeldungen
Die Liste „Aktive Sessions“ las die sessions-Tabelle, Live speichert Sessions
in Redis — sie war immer leer. Jetzt: „Dieses Gerät“ (seit wann angemeldet)
und die letzten fünf Anmeldungen mit Anmeldeweg aus dem Aktivitätsverlauf,
ohne IP; Kachel „Anmeldungen in 30 Tagen“ (Roadmap 4.1).

Changelog: Verbessert
Public: Die Sicherheitsseite zeigt Ihre letzten Anmeldungen und wie Sie sich jeweils angemeldet haben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:35:54 +00:00
53e3b270e0 dev: Vorgehen bei sensiblen Daten in der Historie dokumentiert, Roadmap 1.1/1.2 erledigt
- git-deploy.md Abschnitt G: Rotieren, Sichern, Umschreiben, Prüfen,
  Hash-Zuordnung, Push, Server, Forgejo/NAS, lokales Aufräumen
- Protokoll: Speicherbereinigung in Forgejo und auf dem NAS erledigt,
  keine weiteren Klone, Live-Schlüssel bereits rotiert
- Agenten-Regel: keine personenbezogenen Daten oder Schlüssel ins Repository
- Roadmap: 1.1 und 1.2 erledigt, Context7 und lokales MySQL-Root unter 1.3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:53:24 +00:00
919b753f54 dev: Historienbereinigung auf Remotes und Server abgeschlossen
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:45:09 +00:00
421c0d92f9 dev: Historie von den Produktiv-SQL-Dumps bereinigt, lokale Dateien mit Personenbezug gelöscht
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
- Zuordnung alter zu neuen Commit-Hashes in dev/archive/commit-map-2026-10-09.txt
- Git-Deploy-Protokoll: Eintrag und einmaliger Server-Abgleich (Tags mit
  --force holen, gc), Speicherbereinigung in Forgejo und auf dem NAS
- README und Roadmap 1.1/1.4 nachgeführt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:30:49 +00:00
8e3554bf91 dev: Doku aufgeräumt, neue Roadmap als einzige Liste offener Punkte
- dev/roadmap.md neu: alle offenen Punkte aus Plänen, Audits und Protokollen,
  gegen Code und Git abgeglichen (Stand v1.7.0)
- Abgeschlossenes ins Archiv: 2026-09-01 → archive/2026-09-nutzergewinnung,
  2026-09-11-audit → archive/2026-09-audit, lagacy-rechnungen →
  archive/legacy-rechnungen, Deploy-Protokoll v1.1.0, Umami-Snippets,
  Go-Live-Roadmap vom 20.07.
- Aktiv bleiben eigene Ordner bildrechte/ (Plan Fassung 2, Decision-Update)
  und changelog/ (Decision-Update Changelog)
- dev/README.md als Ordnerkarte neu, Verweise und zwei Code-Kommentare auf
  die neuen Pfade

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:25:09 +00:00
35fd2f6e55 dev: Deploy v1.7.0 im Git-Deploy-Protokoll, Spamschutz-Nacharbeiten erledigt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:55:33 +00:00
b3bcc43c4c dev: Entwicklungsplan Spamschutz, Wiedervorlage der Nacharbeiten nach dem Deploy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:49:58 +00:00
010258a2ab build: Assets für v1.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:49:58 +00:00
06d2889a4d feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier,
  <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und
  im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert
- Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit
  Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden);
  Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen
- Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach
- Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations
  (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true)
- contact-messages:quarantine-spam für den Altbestand
- CSP erlaubt challenges.cloudflare.com, Log-Kanal spam
- Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:49:58 +00:00
61f449f483 dev: Deploy v1.6.1 im Git-Deploy-Protokoll
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:56:52 +00:00
75e206dc92 dev: Imagick als Server-Voraussetzung, Wiedervorlage der Restpunkte im Sicherheits-Entwicklungsplan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:54:14 +00:00
b06f5a8659 build: Assets für v1.6.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:54:14 +00:00
bbc2b6048f fix: Imagick in der Betriebsüberwachung, Scanner-Anfragen an Livewire als 400, abgebrochene Alpine-Übergänge nicht mehr melden
Auf Live fehlte die PHP-Erweiterung imagick: Herkunftsprüfung und
KI-Vorab-Check der Bilder scheiterten still und ließen jedes Bild durch.
AdminSystemHealth zeigt jetzt die Zeile „PHP-Erweiterung Imagick“ (rot bei
eingeschaltetem Vorab-Check, sonst gelb).

GlitchTip server-pressekonto, Issues 38 und 39: gebaute Livewire-Anfragen
setzen gesperrte Properties (portal/language im Meldungs-Feed und in der
Suche) oder schicken Uploads an Komponenten ohne Upload. Livewire weist sie
korrekt ab; statt 500 bzw. 419 (das als „Seite abgelaufen“ gemeldet wurde)
antworten sie jetzt mit 400 und werden nicht mehr gemeldet.

Browser (Issue 18): Alpine bricht x-transition-Übergänge mit
{isFromCancelledTransition: true} ab — keine Fehlermeldung mehr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:54:14 +00:00
65086cafd2 dev: Deploy v1.6.0 im Git-Deploy-Protokoll
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:15:58 +00:00
077e522554 dev: Phase 4 im Sicherheits-Entwicklungsplan auf Stand v1.5.0/v1.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:02:49 +00:00
5dfd622141 security: Andere Geräte abmelden, IP-weites Login-Limit, dauerhafte Sperrmarke, Passwortregeln, CSP im Melde-Modus
Restliche Phase 4 aus dem Sicherheitsaudit 07.10.2026:

- Sicherheitsseite: „Andere Geräte abmelden“ rotiert remember_token und
  beendet alle anderen Sessions, die aktuelle bleibt (nicht während einer
  Impersonation).
- LoginIpThrottle: 20 Fehlversuche je IP in 15 Minuten über alle Konten.
  Gezählt über das Failed-Event, damit Livewire-Login und Fortifys
  POST /login denselben Zähler teilen; Erfolg setzt ihn nicht zurück.
- Sperrmarke für Sessions in users.sessions_revoked_at_ms statt nur im
  Cache — übersteht cache:clear. Die alte Cache-Marke zählt übergangsweise
  mit und kann im nächsten Release entfallen.
- Neue und geänderte Passwörter: mindestens 12 Zeichen, in Produktion
  zusätzlich uncompromised() (HIBP, k-Anonymität). Bestehende bleiben gültig.
- Content-Security-Policy-Report-Only in Produktion; Meldungen über
  /csp-report nach storage/logs/csp-*.log, je Meldung höchstens einmal pro
  Stunde, ohne Query und tokenartige Pfadsegmente.

Migration: users.sessions_revoked_at_ms (users hat FULLTEXT, 25 MB, Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:02:49 +00:00
9c4962fbe0 build: npm-Advisories für shell-quote und source-map-js behoben
source-map-js 1.2.1 → 1.2.2 (GHSA-68fv-2mgg-jv7q). shell-quote per
overrides auf ^1.11.0 (installiert 1.12.0, GHSA-pqg4-j6r4-53mv u. a.):
concurrently legt 1.9.0 fest, auch in Version 10, und `npm audit fix`
stufte concurrently nur auf eine andere verwundbare Version herab. Beide
Pakete laufen nur in Entwicklung und Build, nichts davon im Browser.
`npm audit`: 0 Schwachstellen, Build unverändert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:02:49 +00:00
798ae75e64 dev: Deploy v1.5.0 im Git-Deploy-Protokoll
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:18:24 +00:00
e77d9e7d09 feat: API-Zugang endet mit der Buchung, optionales Ablaufdatum für API-Tokens
Bisher blieb ein einmal erstellter Token gültig, auch wenn Abo bzw. Buchung
längst beendet waren. EnsureApiAccessIsBooked prüft jetzt bei jeder Anfrage
(v1 und /service/…-Altpfade), ob das Konto API-Zugang hat
(ApiAccessEligibilityService::hasApiAccess), sonst 403. Die Tokens bleiben
erhalten und funktionieren nach erneuter Buchung wieder.

Kunden können beim Erstellen optional ein Ablaufdatum setzen (bis 5 Jahre);
die Token-Liste zeigt „Gültig bis“ und einen Hinweis, wenn der Zugang
pausiert ist. sanctum:prune-expired räumt abgelaufene Tokens nach 30 Tagen
täglich ab. API-Doku (public/api/v1.yml) ergänzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:41:24 +00:00
3def447f51 security: Proxy-Vertrauen nur noch lokal, HSTS in Produktion, Tests ohne echte ntfy-Pushes
Live-Prüfung 08.10.2026: nginx beantwortet die Anfragen direkt (kein CDN,
kein vorgeschalteter Proxy), die App vertraute aber jedem Proxy
(trustProxies at '*'). Jeder Client konnte per X-Forwarded-For seine IP
vorgeben und damit IP-Limits bei Login und Passwort-Reset umgehen sowie
IP-Protokolle fälschen. Jetzt steuert config/trustedproxy.php das Vertrauen:
lokal (Traefik) weiterhin '*', sonst keinem Proxy; ein späteres CDN kommt
über TRUSTED_PROXIES dazu.

HSTS fehlte auf Live. SecurityHeaders setzt Strict-Transport-Security auf
HTTPS-Antworten, standardmäßig nur in Produktion (max-age 1 Jahr, bewusst
ohne includeSubDomains/preload).

phpunit.xml leert MONITORING_NTFY_*, damit Testläufe keine echten
Push-Nachrichten mehr auslösen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:41:24 +00:00
f693226d4a dev: Deploy v1.4.0 und v1.4.1 im Git-Deploy-Protokoll, Release-Stand im Sicherheitsaudit und Entwicklungsplan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:19:19 +00:00
af72810296 fix: Abgleich der Firmenrechte entzieht Bestandskonten ohne E-Mail-Bestätigung nicht mehr pauschal alle Rechte — memberships:resync-auto mit Probelauf und Notbremse
Auf Live meldete memberships:resync-auto nach v1.4.0 „vorher 19607, nachher 9":
syncMembershipsForEmail() entzog Konten ohne email_verified_at alle
automatischen Rechte, und 19.597 davon gehören Bestandskonten aus dem
Altsystem. Jetzt wird nur entzogen, was keine E-Mail-Grundlage mehr hat —
unabhängig vom Kontostatus (S-03 bleibt wirksam); neue Rechte weiterhin nur
für aktive, verifizierte Konten (F01).

memberships:resync-auto läuft in einer Transaktion, kennt --dry-run und rollt
zurück, wenn mehr als 5 % (mindestens 50) der Rechte wegfallen würden
(--force zum Erzwingen). Probelauf gegen die Live-Kopie: 19.607 → 19.316.
Wiederherstellung: storage/app/private/restore-company-user-auto-2026-10-07.sql
(nicht versioniert, INSERT IGNORE, idempotent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:22:25 +00:00
8045ef18cc build: Assets für v1.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:09:35 +00:00
bae8347f6c security: Sicherheitsaudit 07.10.2026 (S-01–S-06) behoben, Passkeys und Zweitfaktor-Pflicht für Admins
Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen:
dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md.

- S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute).
- S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt.
- S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder
  Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto.
- S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset
  alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet.
  API-Tokens bleiben bestehen.
- S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID;
  Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink
  in derselben Browser-Session.
- S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder
  Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung;
  Hinweis-Mails an die (bisherige) Kontoadresse.
- Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins
  und Admins bestätigen die Session für den Admin-Bereich per Passkey oder
  App-Code (EnsureStrongAdminAuthentication, Notfallschalter
  AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins.

Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen),
Larastan ohne Meldung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:09:35 +00:00
fd8dd74d5f ops: Lokale Datenbank gegen fremde Testläufe absichern — DB_* nicht mehr als Container-Umgebung, Agenten-Regel, Testdatenbank-Pflicht im Baukasten
Am 07.10.2026 hat das RefreshDatabase eines frischen Demo-Projekts (Test der
Vorlagen aus dev/git-deploy-monitoring) die lokale Datenbank `pressekonto` auf
global-mysql vollständig geleert (Binärlog binlog.000009, Pos. 566161486).
Ursache: docker-compose.yml setzte DB_* als Container-Umgebungsvariablen, die
in Laravel Vorrang vor jeder .env haben; die phpunit.xml des Demo-Projekts
erzwang kein SQLite. Die eigene Suite war nicht beteiligt (force="true" und
Guard in tests/TestCase.php greifen; Gegenprobe mit MySQL-Variablen grün).

- docker-compose.yml: DB_* entfernt (Werte identisch in .env), wirksam nach
  Neuerstellen des Containers.
- Baukasten: „Testdatenbank absichern" (phpunit.xml mit force, Guard,
  Gegenprobe) als Pflicht vor RefreshDatabase.
- .ai/rules/general.md und CLAUDE.md: fremde/frische Laravel-Projekte nur mit
  SQLite :memory: testen; migrate hier erst nach migrate:status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:09:25 +00:00
32e1349517 dev: Baukasten Git-Deploy und Betriebsüberwachung für andere Laravel-Projekte (dev/git-deploy-monitoring) — Anleitungen, Checkliste, Vorlagen für Laravel, deploy.sh und Monitoring-Server
Vorlagen in einem frischen Laravel 12 eingebaut (30 Tests grün), deploy.sh
gegen ein simuliertes Repository durchgespielt (Deploy, Abbruch bei
Migrationen, Drift, Rollback, fehlender Worker). GlitchTip-Aufbewahrung heißt
seit Version 6 GLITCHTIP_RETENTION_DAYS — in der Projekt-Doku korrigiert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:45:16 +00:00
d083110b00 dev: Deploy v1.3.2 im Git-Deploy-Protokoll (Worker-Prüfung 4 s, Supervisor enabled)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:14:12 +00:00
367 changed files with 23267 additions and 986 deletions

21
.ai/rules/general.md Normal file
View file

@ -0,0 +1,21 @@
---
paths:
- '**/*'
---
# General
## Befehle direkt im Dev-Container ausführen
Die Arbeitsumgebung /var/www/html liegt bereits im DEV-Container. PHP-/Artisan-, Composer- und Node-Befehle dort direkt ausführen (z. B. php artisan test --compact), ohne vendor/bin/sail davor. Eine Sail-Meldung über nicht laufendes Docker ist in diesem Container kein Nachweis, dass Docker auf dem Host gestoppt ist. Diese ausdrückliche Benutzeranweisung ersetzt die allgemeine Sail-Prefix-Vorgabe für diese Umgebung.
## Fremde oder frische Laravel-Projekte nie gegen die lokale Datenbank testen
Auf `global-mysql` liegen die Datenbanken mehrerer Projekte, und Container-Umgebungsvariablen haben in Laravel Vorrang vor `.env` und nicht erzwungenen `phpunit.xml`-Werten. Ein frisches Laravel-Projekt hat die SQLite-Zeilen in `phpunit.xml` nur auskommentiert — `RefreshDatabase` führt dann `migrate:fresh` auf der Datenbank aus, die die Umgebung vorgibt (07.10.2026: so wurde `pressekonto` vollständig geleert). Deshalb:
- Tests, `migrate`, `migrate:fresh`, `db:wipe` oder Seeder eines anderen bzw. frisch angelegten Laravel-Projekts in diesem Container nur mit `DB_CONNECTION=sqlite DB_DATABASE=:memory: DB_URL=` davor ausführen, z. B. `DB_CONNECTION=sqlite DB_DATABASE=:memory: DB_URL= php artisan test`.
- Vorher prüfen, dass `phpunit.xml` dort SQLite `:memory:` mit `force="true"` setzt (siehe `dev/git-deploy-monitoring/02-fehlerprotokoll-glitchtip.md`, „Testdatenbank absichern“).
- `php artisan migrate` in diesem Projekt erst nach `php artisan migrate:status`: Stehen unerwartet viele Migrationen aus, ist die Datenbank nicht die erwartete — anhalten und nachfragen.
## Keine personenbezogenen Daten oder Schlüssel ins Repository
Datenbank-Dumps, Produktions-Logs, Postman-Collections, Adresslisten, `.env`-Inhalte und API-Schlüssel nie committen, auch nicht unter `dev/` oder im Archiv. Am 12.05.2026 wurden zwei Produktiv-Dumps committet; Enttracken half nicht, erst die Historienbereinigung vom 09.10.2026 hat sie entfernt (alle Hashes neu, Force-Push, Server-Abgleich). Deshalb:
- Vor jedem `git add` prüfen, ob neue Dateien solche Daten enthalten; im Zweifel nachfragen. Solche Arbeitsdateien gehören ins Scratchpad und werden nach Gebrauch gelöscht.
- Ist es doch passiert: nicht nur löschen, sondern Betreiber informieren und nach `dev/betrieb/git-deploy.md`, Abschnitt G vorgehen (zuerst Schlüssel rotieren).
- Die Historie nie ohne ausdrückliche Freigabe umschreiben oder force-pushen.

7
.ai/rules/index.md Normal file
View file

@ -0,0 +1,7 @@
# Project Rules Index
Before planning or editing, find the row whose globs match the file's path and read that rule file.
| Applies to | Rule file |
| --- | --- |
| **/* | .ai/rules/general.md |

View file

@ -278,6 +278,14 @@ UMAMI_ENABLED=false
# erst nach Ergänzung der Datenschutzerklärung einschalten. # erst nach Ergänzung der Datenschutzerklärung einschalten.
UMAMI_PORTAL_ENABLED=false UMAMI_PORTAL_ENABLED=false
UMAMI_IDENTIFY_USERS=false UMAMI_IDENTIFY_USERS=false
# Nur lesender API-Zugang für Admin ▸ Reports ▸ Wachstum und `umami:report`
# (Umami-Benutzer mit der Rolle „View only“, Mitglied im Team mit den drei
# Websites). Ohne diese Werte zeigt die Seite die Umami-Kennzahlen als
# „nicht verbunden“.
UMAMI_API_URL=
UMAMI_API_USERNAME=
UMAMI_API_PASSWORD=
UMAMI_API_TEAM_ID=
# Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB, # Haupt-E-Mail für alle rechtlichen Anliegen (Impressum, Datenschutz, AGB,
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch. # Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
@ -346,3 +354,24 @@ IMAGE_PRECHECK=false
IMAGE_PRECHECK_STAGE=1 IMAGE_PRECHECK_STAGE=1
# IMAGE_PRECHECK_MODEL= # IMAGE_PRECHECK_MODEL=
# IMAGE_PRECHECK_DOWNGRADE= # IMAGE_PRECHECK_DOWNGRADE=
# Proxy-Vertrauen: leer = keinem Proxy (Live, nginx direkt). Lokal ohne Angabe `*` (Traefik).
# Steht ein Proxy/CDN davor, dessen Adressbereiche kommagetrennt eintragen.
# TRUSTED_PROXIES=
# HSTS: ohne Angabe nur bei APP_ENV=production an, max-age 1 Jahr.
# SECURITY_HSTS=
# SECURITY_HSTS_MAX_AGE=31536000
# CSP im Melde-Modus: ohne Angabe nur bei APP_ENV=production an, Meldungen in storage/logs/csp-*.log.
# SECURITY_CSP_REPORT_ONLY=
# Spamschutz mit Cloudflare Turnstile (Kontakt-Widget, Registrierung). Ohne beide Keys aus.
# Lokal die Testkeys von Cloudflare verwenden (1x00000000000000000000AA / 1x0000000000000000000000000000000AA),
# die echten Keys sind an die Live-Hostnamen gebunden. Ist Cloudflare nicht erreichbar,
# wird durchgelassen und markiert; TURNSTILE_FAIL_OPEN=false lehnt stattdessen ab.
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# TURNSTILE_TIMEOUT=3
# TURNSTILE_FAIL_OPEN=true
# Unbestätigte Bot-Registrierungen nach 14 Tagen löschen (users:prune-bot-registrations).
# Erst einschalten, nachdem der Probelauf auf Live geprüft ist.
# USERS_PRUNE_BOT_REGISTRATIONS=false

5
.gitignore vendored
View file

@ -83,3 +83,8 @@ config/files/
# Produktions-Logs zur Auswertung (können personenbezogene Daten enthalten) # Produktions-Logs zur Auswertung (können personenbezogene Daten enthalten)
/dev/**/*.log /dev/**/*.log
# Nachweise zum Anwaltspaket: Screenshots und Verträge nicht versionieren
# (Vertraulich, Kunden- und Organisationsnummern, Größe). Ablage lokal und im Paket.
/dev/rechtliches/nachweise/*.png
/dev/rechtliches/nachweise/*.pdf

View file

@ -172,12 +172,24 @@ look at GlitchTip or at production errors, follow
workflow and the privacy rules for error reporting. Monitoring decisions: workflow and the privacy rules for error reporting. Monitoring decisions:
`dev/betrieb/monitoring-plan.md`; deploys: `dev/betrieb/deploy-runbook.md`. `dev/betrieb/monitoring-plan.md`; deploys: `dev/betrieb/deploy-runbook.md`.
### Webanalyse (Umami) and growth figures
Self-hosted Umami (`sys.adametz.media`, v3 API) is reachable read-only via
`UMAMI_API_*` in `.env` (view-only user, websites in the team „Presse
Reports“, IDs in `config/domains.php`; never print the credentials or the
token). Use `php artisan umami:report` for page views, referrers and
channels, and `php artisan growth:report` for the abort conditions of the
growth blocks (Roadmap 6.1, thresholds in `config/growth.php`) — the same
figures as Admin ▸ Reports ▸ Wachstum. The conversion funnel itself:
`php artisan conversion:funnel`.
### Testing Framework ### Testing Framework
- **Pest PHP** (v3.8+) - Modern testing framework built on PHPUnit - **Pest PHP** (v3.8+) - Modern testing framework built on PHPUnit
- Configuration: `phpunit.xml` and `tests/Pest.php` - Configuration: `phpunit.xml` and `tests/Pest.php`
- Test database: Uses `DB_DATABASE=testing` environment (configured in phpunit.xml) - Test database: Uses `DB_DATABASE=testing` environment (configured in phpunit.xml)
- Test directories: `tests/Feature/` (authentication, dashboard, settings) and `tests/Unit/` - Test directories: `tests/Feature/` (authentication, dashboard, settings) and `tests/Unit/`
- **Never run tests, migrations or seeders of another or freshly created Laravel project in this container against MySQL.** `global-mysql` holds several projects' databases and container env vars beat `.env`; a fresh project's `RefreshDatabase` wiped `pressekonto` on 07.10.2026. Prefix such runs with `DB_CONNECTION=sqlite DB_DATABASE=:memory: DB_URL=` and check `migrate:status` before `migrate` here (details: `.ai/rules/general.md`).
## Important Files & Configuration ## Important Files & Configuration

View file

@ -212,25 +212,12 @@ paths:
/newsletter/subscribe: /newsletter/subscribe:
post: post:
tags: [Newsletter] tags: [Newsletter]
summary: Subscribe an email address deprecated: true
description: Requires `newsletter:subscribe`. summary: Discontinued — newsletter subscription via the API
requestBody: description: Discontinued on 2026-10-09. Responds with 410 and creates no subscription.
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SubscribeNewsletterRequest'
responses: responses:
'201': '410':
description: Subscription created. description: Endpoint discontinued.
content:
application/json:
schema:
$ref: '#/components/schemas/NewsletterSubscriptionResponse'
'403':
$ref: '#/components/responses/Forbidden'
'422':
$ref: '#/components/responses/ValidationError'
components: components:
securitySchemes: securitySchemes:
sanctumBearer: sanctumBearer:

View file

@ -3,7 +3,7 @@
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Hash; use App\Services\Auth\PasswordChanger;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords; use Laravel\Fortify\Contracts\UpdatesUserPasswords;
@ -12,7 +12,10 @@ class UpdateUserPassword implements UpdatesUserPasswords
use PasswordValidationRules; use PasswordValidationRules;
/** /**
* Validate and update the user's password. * Validate and update the user's password. Die Fortify-Route dazu ist
* abgeschaltet (Sicherheitsaudit 07.10.2026, Entscheidung 4); die Action
* nutzt trotzdem den gemeinsamen PasswordChanger, falls sie wieder
* aktiviert wird.
* *
* @param array<string, string> $input * @param array<string, string> $input
*/ */
@ -25,8 +28,6 @@ class UpdateUserPassword implements UpdatesUserPasswords
'current_password.current_password' => __('The provided password does not match your current password.'), 'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword'); ])->validateWithBag('updatePassword');
$user->forceFill([ app(PasswordChanger::class)->change($user, $input['password'], request()->hasSession() ? request()->session() : null);
'password' => Hash::make($input['password']),
])->save();
} }
} }

View file

@ -0,0 +1,151 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* Berechtigungsnachweis BN-02 (Rechtspaket 10/2026): listet jedes Konto, das
* über den Kundenbereich hinaus Rechte hat — Super-Admin-Merker, eine Rolle
* außer `customer`/`api-only` oder direkt vergebene Permissions —, auch
* gesperrte und gelöschte. Ausgabe als Markdown für die Ablage unter
* `dev/rechtliches/nachweise/`; jährlich wiederholen.
*
* Sitzungen liegen auf Live in Redis und lassen sich nicht je Konto zählen;
* die Liste zeigt stattdessen den letzten Widerruf (`sessions_revoked_at_ms`).
*
* E-Mail-Adressen erscheinen standardmäßig nur mit Domain, damit die Datei
* ins Repository darf. Der Befehl ändert nichts.
*/
class AuditAdmins extends Command
{
/** @var list<string> */
private const CUSTOMER_ROLES = ['customer', 'api-only'];
protected $signature = 'audit:admins
{--with-emails : Vollständige E-Mail-Adressen statt nur der Domain ausgeben}';
protected $description = 'Listet alle Konten mit Admin-, Editor-, Super-Admin- oder Sonderrechten als Markdown (Nachweis BN-02).';
public function handle(): int
{
$users = $this->privilegedUsers();
$now = now();
$this->line(sprintf('# Berechtigungen, Stand %s', $now->format('d.m.Y H:i')));
$this->newLine();
$this->line($this->summary($users, $now));
$this->newLine();
$this->line('| ID | E-Mail | Rollen | Super-Admin | Aktiv | Gelöscht | Admin-Zugang | TOTP | Passkeys | Letzte Anmeldung | API-Tokens | Sitzungen zuletzt widerrufen |');
$this->line('|---|---|---|---|---|---|---|---|---|---|---|---|');
foreach ($users as $user) {
$this->line('| '.implode(' | ', $this->row($user)).' |');
}
if ($users->isEmpty()) {
$this->line('| – | – | – | – | – | – | – | – | – | – | – | – |');
}
$this->newLine();
$this->line('Admin-Zugang = aktiv und Super-Admin oder Rolle `admin`/`editor` (`User::canAccessAdmin()`). Sitzungen liegen in Redis und sind je Konto nicht zählbar; ein Widerruf beendet alle älteren Sitzungen.');
$this->newLine();
$this->line('**Zugänge außerhalb der Anwendung** (Ploi, Hetzner-Konsole, SSH): [eintragen]');
return self::SUCCESS;
}
/**
* @return Collection<int, User>
*/
private function privilegedUsers(): Collection
{
return User::query()
->withTrashed()
->with(['roles:id,name', 'permissions:id,name'])
->withCount(['passkeys', 'tokens'])
->withMax('tokens', 'last_used_at')
->where(fn (Builder $query) => $query
->where('is_super_admin', true)
->orWhereHas('roles', fn (Builder $roles) => $roles->whereNotIn('name', self::CUSTOMER_ROLES))
->orWhereHas('permissions'))
->orderBy('id')
->get();
}
/**
* @param Collection<int, User> $users
*/
private function summary(Collection $users, Carbon $now): string
{
$withAccess = $users->filter(fn (User $user): bool => ! $user->trashed() && $user->canAccessAdmin());
$withSecondFactor = $withAccess->filter(fn (User $user): bool => $this->hasSecondFactor($user));
$editors = $withAccess->filter(fn (User $user): bool => $user->roles->contains('name', 'editor'));
$superAdmins = $withAccess->where('is_super_admin', true);
return sprintf(
'Stand %s: %d %s mit Zugang zum Admin-Bereich, davon %d mit zweitem Faktor (TOTP oder Passkey), %d Super-Admin, %d Editor-%s. Gelistet %s %d %s mit Rechten über den Kundenbereich hinaus, einschließlich gesperrter und gelöschter.',
$now->format('d.m.Y'),
$withAccess->count(),
$withAccess->count() === 1 ? 'Konto' : 'Konten',
$withSecondFactor->count(),
$superAdmins->count(),
$editors->count(),
$editors->count() === 1 ? 'Konto' : 'Konten',
$users->count() === 1 ? 'ist' : 'sind',
$users->count(),
$users->count() === 1 ? 'Konto' : 'Konten',
);
}
/**
* @return list<string>
*/
private function row(User $user): array
{
$roles = $user->roles->pluck('name')
->merge($user->permissions->pluck('name')->map(fn (string $name): string => 'direkt: '.$name))
->implode(', ');
$revokedAt = $user->sessions_revoked_at_ms
? Carbon::createFromTimestampMs($user->sessions_revoked_at_ms)->timezone(config('app.timezone'))->format('d.m.Y H:i')
: '–';
$tokenLastUsedAt = $user->getAttribute('tokens_max_last_used_at');
$tokenLastUsed = $tokenLastUsedAt
? ', zuletzt '.Carbon::parse($tokenLastUsedAt)->format('d.m.Y')
: '';
return [
(string) $user->id,
$this->email($user),
$roles !== '' ? $roles : '–',
$user->is_super_admin ? 'ja' : 'nein',
$user->is_active ? 'ja' : 'gesperrt',
$user->trashed() ? $user->deleted_at->format('d.m.Y') : 'nein',
! $user->trashed() && $user->canAccessAdmin() ? 'ja' : 'nein',
$user->two_factor_confirmed_at ? 'ja' : 'nein',
(string) $user->passkeys_count,
$user->last_login_at?->format('d.m.Y H:i') ?? '–',
$user->tokens_count.$tokenLastUsed,
$revokedAt,
];
}
private function hasSecondFactor(User $user): bool
{
return $user->two_factor_confirmed_at !== null || $user->passkeys_count > 0;
}
private function email(User $user): string
{
if ($this->option('with-emails')) {
return (string) $user->email;
}
return '…@'.Str::after((string) $user->email, '@');
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands;
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use App\Models\ChangelogEntry;
use Illuminate\Console\Command;
/**
* Nachtrag des Changelogs seit dem Relaunch (Decision-Update Changelog, O7)
* aus `database/seeders/data/changelog-backfill.php` — als Entwurf, idempotent.
*/
class BackfillChangelog extends Command
{
protected $signature = 'changelog:backfill {--dry-run : nur zählen, nichts speichern}';
protected $description = 'Changelog-Einträge seit dem Relaunch als Entwurf nachtragen.';
public function handle(): int
{
/** @var list<array{date: string, tag: string|null, category: string, text: string}> $rows */
$rows = require database_path('seeders/data/changelog-backfill.php');
$created = 0;
foreach ($rows as $row) {
$key = 'backfill:'.sha1($row['date'].'|'.$row['category'].'|'.$row['text']);
if (ChangelogEntry::query()->where('source_key', $key)->exists()) {
continue;
}
$created++;
if ($this->option('dry-run')) {
continue;
}
ChangelogEntry::query()->create([
'released_on' => $row['date'],
'release_tag' => $row['tag'],
'category' => ChangelogCategory::from($row['category']),
'text' => $row['text'],
'status' => ChangelogStatus::Draft,
'source_key' => $key,
'review_note' => 'Nachtrag aus Protokoll und Roadmap — Datum und Text prüfen.',
]);
}
$this->info(sprintf('%s%d von %d Einträgen nachgetragen (Entwurf). Freigabe im Admin ▸ Changelog.', $this->option('dry-run') ? '[Dry-Run] ' : '', $created, count($rows)));
return self::SUCCESS;
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Services\Changelog\ChangelogCollector;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Throwable;
/**
* Stufe 2 des Changelogs: Entwürfe aus den Commits eines Releases. Läuft in
* `dev/betrieb/deploy.sh` nach `up`; ein Fehler hier bricht keinen Deploy ab.
*/
class CollectChangelog extends Command
{
protected $signature = 'changelog:collect
{from : vorheriger Tag (ausschließlich), z. B. v1.11.0}
{to : neuer Tag, z. B. v1.12.0}
{--date= : Datum der Einträge (Standard: heute)}
{--dry-run : nur anzeigen, nichts speichern}';
protected $description = 'Changelog-Entwürfe aus den Commit-Zeilen Changelog:/Public: eines Releases anlegen.';
public function handle(ChangelogCollector $collector): int
{
try {
$result = $collector->collect(
(string) $this->argument('from'),
(string) $this->argument('to'),
$this->option('date') !== null ? Carbon::parse((string) $this->option('date')) : null,
(bool) $this->option('dry-run'),
);
} catch (Throwable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'%s%d neue Entwürfe, %d schon vorhanden, %d Commits ohne Changelog-Zeile (intern).',
$this->option('dry-run') ? '[Dry-Run] ' : '',
$result['created'],
$result['existing'],
$result['internal'],
));
if ($result['entries'] !== []) {
$this->table(
['Rubrik', 'Text', 'Hinweis'],
array_map(static fn ($entry): array => [
$entry->category->label(),
$entry->text,
$entry->review_note ?? '',
], $result['entries']),
);
$this->line(' Freigabe im Admin ▸ Changelog.');
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,145 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Spam\GibberishDetector;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Löscht Bot-Registrierungen (Spamschutz B4): 389 Konten in 30 Tagen bis
* 07.10.2026, jedes mit einer Bestätigungsmail an eine fremde Adresse.
*
* Gelöscht wird nur, was über das Registrierungsformular entstand und nie
* genutzt wurde — alle Bedingungen zugleich:
* - keine Legacy-ID (66.870 unbestätigte Altkonten bleiben unberührt),
* - E-Mail nie bestätigt, Konto inaktiv, nie angemeldet, kein Google-Login,
* - älter als die Frist (Standard 14 Tage),
* - keine Firma, kein Kontakt, keine Meldung, keine Zahlung, kein Token.
*/
class PruneBotRegistrations extends Command
{
protected $signature = 'users:prune-bot-registrations
{--days= : Frist in Tagen (Standard: config auth.prune_bot_registrations.after_days)}
{--dry-run : Nur auflisten, nichts löschen}
{--limit=2000 : Höchstzahl je Lauf}';
protected $description = 'Löscht unbestätigte, nie genutzte Konten aus dem Registrierungsformular (Bot-Registrierungen).';
/** Tabellen, deren Einträge ein Konto als genutzt ausweisen. */
private const USAGE_TABLES = [
'company_user',
'contact_user',
'press_releases',
'invoices',
'legacy_invoices',
'subscriptions',
'single_purchases',
'credit_transactions',
'credit_topups',
'booking_confirmations',
'boosts',
'billing_addresses',
];
public function handle(): int
{
$days = (int) ($this->option('days') ?? config('auth.prune_bot_registrations.after_days', 14));
if ($days < 7) {
$this->error('Die Frist muss mindestens 7 Tage betragen.');
return self::FAILURE;
}
$candidates = $this->candidates($days)->limit((int) $this->option('limit'))->get();
if ($candidates->isEmpty()) {
$this->info('Keine Bot-Registrierungen zu löschen.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
$this->table(
['ID', 'Angelegt', 'Name', 'E-Mail', 'Zufallsname'],
$candidates->map(fn (User $user): array => [
$user->id,
$user->created_at?->format('d.m.Y H:i'),
Str::limit((string) $user->name, 30),
$this->maskEmail((string) $user->email),
GibberishDetector::isRandomPersonName((string) $user->name) ? 'ja' : '',
])->all(),
);
$this->info(sprintf(
'Probelauf: %d Konten älter als %d Tage würden gelöscht, davon %d mit Zufallsnamen.',
$candidates->count(),
$days,
$candidates->filter(fn (User $user): bool => GibberishDetector::isRandomPersonName((string) $user->name))->count(),
));
return self::SUCCESS;
}
$deleted = 0;
$failed = 0;
foreach ($candidates as $user) {
try {
DB::transaction(fn () => $user->forceDelete());
$deleted++;
} catch (\Throwable $exception) {
$failed++;
Log::channel('spam')->warning('Bot-Registrierung konnte nicht gelöscht werden.', [
'user_id' => $user->id,
'error' => $exception->getMessage(),
]);
}
}
Log::channel('spam')->info('Bot-Registrierungen gelöscht.', ['deleted' => $deleted, 'failed' => $failed, 'days' => $days]);
$this->info("Bot-Registrierungen gelöscht: {$deleted}".($failed > 0 ? ", {$failed} fehlgeschlagen (siehe spam-Log)" : '').'.');
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return Builder<User>
*/
private function candidates(int $days): Builder
{
$query = User::query()
->whereNull('legacy_id')
->whereNull('email_verified_at')
->whereNull('last_login_at')
->whereNull('oauth_provider')
->where('is_active', false)
->where('is_super_admin', false)
->where('created_at', '<', now()->subDays($days))
->whereDoesntHave('roles')
->whereNotExists(fn (QueryBuilder $query) => $query
->from('personal_access_tokens')
->whereColumn('personal_access_tokens.tokenable_id', 'users.id')
->where('personal_access_tokens.tokenable_type', (new User)->getMorphClass()));
foreach (self::USAGE_TABLES as $table) {
$query->whereNotExists(fn (QueryBuilder $sub) => $sub
->from($table)
->whereColumn($table.'.user_id', 'users.id'));
}
return $query->orderBy('id');
}
private function maskEmail(string $email): string
{
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
return Str::substr($local, 0, 2).'***@'.$domain;
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands;
use App\Models\ContactMessage;
use App\Services\Spam\ContactSpamFilter;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
/**
* Einmalig für den Altbestand (Spamschutz, Entscheidung 2): offene
* Kontakt-Anliegen von Gästen nach denselben Regeln wie beim Eingang in die
* Spam-Quarantäne legen. Was die Regeln nicht erkennen, markiert der Admin
* über die Sammelaktion der Kontakt-Queue.
*/
class QuarantineContactSpam extends Command
{
protected $signature = 'contact-messages:quarantine-spam {--dry-run : Nur auflisten, nichts ändern}';
protected $description = 'Legt offene Kontakt-Anliegen von Gästen, die der Spamfilter erkennt, in die Quarantäne.';
public function handle(ContactSpamFilter $filter): int
{
$hits = ContactMessage::query()
->open()
->whereNull('user_id')
->orderBy('id')
->get()
->map(fn (ContactMessage $message): array => [
'message' => $message,
// Ohne Zeitstempel: Der Altbestand hatte kein Formular-Feld dafür.
'reason' => $filter->reasonFor((string) $message->name, (string) $message->message, null),
])
->filter(fn (array $hit): bool => $hit['reason'] !== null);
$this->table(
['Vorgang', 'Eingang', 'Grund', 'Nachricht'],
$hits->map(fn (array $hit): array => [
$hit['message']->reference(),
$hit['message']->created_at?->format('d.m.Y'),
$hit['reason'],
Str::limit(Str::squish((string) $hit['message']->message), 60),
])->all(),
);
if ($this->option('dry-run')) {
$this->info("Probelauf: {$hits->count()} Anliegen würden als Spam markiert.");
return self::SUCCESS;
}
foreach ($hits as $hit) {
$hit['message']->markAsSpam(null, $hit['reason']);
}
$this->info("{$hits->count()} Anliegen als Spam markiert. Offen bleiben: ".ContactMessage::query()->open()->count().'.');
return self::SUCCESS;
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use App\Services\Growth\GrowthReport;
use Illuminate\Console\Command;
/**
* Stand der Abbruchbedingungen für Block 1 bis 3 (Roadmap 6.1) — dieselben
* Zahlen wie Admin ▸ Reports ▸ Wachstum.
*/
class ReportGrowth extends Command
{
protected $signature = 'growth:report
{--fresh : Umami-Werte neu abfragen statt aus dem Zwischenspeicher}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Abbruchbedingungen der Wachstumsblöcke: Ist, Schwelle, Status.';
public function handle(GrowthReport $report): int
{
$data = $report->build((bool) $this->option('fresh'));
if ($this->option('json')) {
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$period = $data['period'];
$this->info(sprintf(
'Wachstum — Zeitraum %s bis %s, Zwischenblick %s, %d %% der Zeit vergangen',
$period['start']->format('d.m.Y'),
$period['end']->format('d.m.Y'),
$period['review']->format('d.m.Y'),
(int) round($period['elapsed_share'] * 100),
));
foreach ($data['blocks'] as $block) {
$this->newLine();
$this->line("<comment>{$block['title']}</comment>");
$this->table(
['Bedingung', 'Ist', 'Soll heute', 'Schwelle', 'Status'],
array_map(static fn (array $criterion): array => [
$criterion['label'],
$criterion['actual'] ?? '–',
$criterion['expected'] ?? '–',
$criterion['threshold'],
GrowthReport::statusLabel($criterion['status']),
], $block['criteria']),
);
foreach ($block['criteria'] as $criterion) {
if ($criterion['note'] !== null) {
$this->line(' '.$criterion['note']);
}
}
$this->line(' '.$block['consequence']);
}
if ($data['pages'] !== []) {
$this->newLine();
$this->line('<comment>Transparenzseiten, letzte 30 Tage (Umami)</comment>');
$this->table(
['Portal', 'Seite', 'Aufrufe', 'aus der Suche'],
array_map(static fn (array $row): array => [$row['brand'], $row['page'], $row['views'], $row['search_views']], $data['pages']),
);
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,103 @@
<?php
namespace App\Console\Commands;
use App\Services\Analytics\UmamiClient;
use App\Services\Analytics\UmamiUnavailable;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
/**
* Nur lesende Abfrage der Umami-Webanalyse (Roadmap 6.16): Kennzahlen je
* Website, dazu eine Rangliste (Seiten, Herkunft, Kanäle …), optional auf
* Pfade gefiltert. Zugangsdaten aus `UMAMI_API_*`, werden nie ausgegeben.
*/
class ReportUmami extends Command
{
protected $signature = 'umami:report
{--from= : Startdatum (Standard: vor 30 Tagen)}
{--to= : Enddatum (Standard: jetzt)}
{--site=* : pressekonto, presseecho, businessportal24 (Standard: alle)}
{--path= : nur Pfade, die diesen Text enthalten, z. B. gute-pressemitteilung}
{--metric=channel : Rangliste nach path, referrer, channel, country, device, browser, event}
{--limit=10 : Zeilen der Rangliste}
{--json : Ausgabe als JSON statt als Tabelle}';
protected $description = 'Umami-Kennzahlen je Website (nur lesend).';
private const array METRICS = ['path', 'referrer', 'channel', 'country', 'device', 'browser', 'os', 'event', 'title', 'entry', 'exit'];
public function handle(UmamiClient $umami): int
{
if (! $umami->isConfigured()) {
$this->error('Umami-API nicht konfiguriert: UMAMI_API_URL, UMAMI_API_USERNAME und UMAMI_API_PASSWORD setzen.');
return self::FAILURE;
}
$metric = (string) $this->option('metric');
if (! in_array($metric, self::METRICS, true)) {
$this->error('Unbekannte Rangliste. Möglich: '.implode(', ', self::METRICS).'.');
return self::INVALID;
}
$to = $this->option('to') !== null ? Carbon::parse((string) $this->option('to'))->endOfDay() : Carbon::now();
$from = $this->option('from') !== null ? Carbon::parse((string) $this->option('from'))->startOfDay() : $to->copy()->subDays(30);
$sites = (array) $this->option('site') ?: UmamiClient::BRANDS;
$filters = filled($this->option('path')) ? ['path' => 'c.'.$this->option('path')] : [];
$limit = max(1, min(500, (int) $this->option('limit')));
$result = [];
try {
foreach ($sites as $site) {
$websiteId = $umami->websiteId((string) $site);
if ($websiteId === null) {
$this->warn("Keine Umami-Website für „{$site}“ in config/domains.php.");
continue;
}
$result[$site] = [
'stats' => $umami->stats($websiteId, $from, $to, $filters),
$metric => $umami->metrics($websiteId, $metric, $from, $to, $filters, $limit),
];
}
} catch (UmamiUnavailable $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
if ($this->option('json')) {
$this->line((string) json_encode([
'from' => $from->toIso8601String(),
'to' => $to->toIso8601String(),
'filters' => $filters,
'sites' => $result,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->info(sprintf('Umami — %s bis %s%s', $from->format('d.m.Y'), $to->format('d.m.Y'), $filters === [] ? '' : ', Pfad enthält „'.$this->option('path').'“'));
foreach ($result as $site => $data) {
$this->newLine();
$this->line("<comment>{$site}</comment>");
$this->table(
['Aufrufe', 'Besucher', 'Besuche', 'Absprünge'],
[[$data['stats']['pageviews'], $data['stats']['visitors'], $data['stats']['visits'], $data['stats']['bounces']]],
);
$this->table(
[ucfirst($metric), 'Anzahl'],
array_map(static fn (array $row): array => [$row['x'], $row['y']], $data[$metric]),
);
}
return self::SUCCESS;
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Bewertet automatisch vergebene Firmenrechte (`granted_via` = auto bzw.
* auto-upgrade) aller betroffenen User neu (Sicherheitsaudit 07.10.2026,
* S-03). Der CompanyMembershipObserver erledigt das bei jeder Änderung über
* Eloquent; dieses Kommando bereinigt den Altbestand und repariert nach
* Massen-Updates per Query-Builder.
*
* Manuelle (`manual`) und API-Mitgliedschaften (`api`) bleiben unberührt.
*
* Sicherung (nach dem Abgleich vom 07.10.2026, v1.4.1): Der Lauf geschieht in
* einer Transaktion. `--dry-run` rollt immer zurück und zeigt nur die Zahlen.
* Würde mehr als MAX_REVOKE_SHARE der Rechte (mindestens MAX_REVOKE_ABSOLUTE)
* wegfallen, rollt das Kommando zurück, solange nicht `--force` gesetzt ist —
* ein solcher Einbruch deutet auf einen Fehler, nicht auf veraltete Rechte.
*/
class ResyncAutomaticMemberships extends Command
{
private const MAX_REVOKE_SHARE = 0.05;
private const MAX_REVOKE_ABSOLUTE = 50;
protected $signature = 'memberships:resync-auto
{--user= : Nur diesen User (ID) abgleichen}
{--dry-run : Nur zählen, nichts speichern}
{--force : Auch bei auffällig vielen Entzügen speichern}';
protected $description = 'Gleicht automatisch vergebene Firmenrechte mit den aktuellen Kontakt- und Firmenadressen ab.';
public function handle(ContactAccessService $contactAccess): int
{
$before = $this->countAutomaticGrants();
$userIds = DB::table('company_user')
->whereIn('granted_via', ['auto', 'auto-upgrade'])
->when($this->option('user'), fn ($query, $userId) => $query->where('user_id', (int) $userId))
->distinct()
->pluck('user_id');
if ($userIds->isEmpty()) {
$this->info('Keine automatisch vergebenen Firmenrechte gefunden — nichts zu tun.');
return self::SUCCESS;
}
DB::beginTransaction();
try {
$bar = $this->output->createProgressBar($userIds->count());
foreach ($userIds->chunk(200) as $chunk) {
User::query()->whereIn('id', $chunk->all())->get()->each(function (User $user) use ($contactAccess, $bar): void {
$contactAccess->syncMembershipsForEmail($user);
$bar->advance();
});
}
$bar->finish();
$this->newLine();
$after = $this->countAutomaticGrants();
$revoked = max(0, $before - $after);
$summary = "Abgeglichen: {$userIds->count()} User. Automatische Rechte vorher {$before}, nachher {$after}.";
if ($this->option('dry-run')) {
DB::rollBack();
$this->info($summary.' Probelauf — nichts gespeichert.');
return self::SUCCESS;
}
$limit = max(self::MAX_REVOKE_ABSOLUTE, (int) ceil($before * self::MAX_REVOKE_SHARE));
if ($revoked > $limit && ! $this->option('force')) {
DB::rollBack();
$this->error($summary." Es würden {$revoked} Rechte wegfallen (Grenze {$limit}) — zurückgerollt. Ursache prüfen, nur bei Absicht mit --force wiederholen.");
return self::FAILURE;
}
DB::commit();
$this->info($summary);
return self::SUCCESS;
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
}
private function countAutomaticGrants(): int
{
return DB::table('company_user')
->where('role', 'responsible')
->whereIn('granted_via', ['auto', 'auto-upgrade'])
->count();
}
}

View file

@ -20,7 +20,7 @@ use Illuminate\Support\Facades\Storage;
/** /**
* Bereinigt die Altlast aus unbezahlten Legacy-Rechnungen (Einordnung * Bereinigt die Altlast aus unbezahlten Legacy-Rechnungen (Einordnung
* 16.09.2026, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B). * 16.09.2026, dev/archive/legacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B).
* *
* `legacy:grandfather-subscriptions` hat Bestandsschutz-Posten auch aus * `legacy:grandfather-subscriptions` hat Bestandsschutz-Posten auch aus
* Rechnungen abgeleitet, die nie bezahlt wurden. Dieser Befehl löst das * Rechnungen abgeleitet, die nie bezahlt wurden. Dieser Befehl löst das

View file

@ -44,6 +44,12 @@ enum ActivityType: string
case TwoFactorFailed = 'two_factor_failed'; case TwoFactorFailed = 'two_factor_failed';
/** Cloudflare Turnstile war nicht erreichbar — Formular durchgelassen, bitte nachprüfen (Spamschutz). */
case BotCheckUnavailable = 'bot_check_unavailable';
/** Admin hat eine gebuchte Platzierung vorzeitig beendet. */
case BoostEnded = 'boost_ended';
public function label(): string public function label(): string
{ {
return match ($this) { return match ($this) {
@ -73,6 +79,8 @@ enum ActivityType: string
self::LoginFailed => 'Anmeldung fehlgeschlagen', self::LoginFailed => 'Anmeldung fehlgeschlagen',
self::LoginLockout => 'Anmeldesperre', self::LoginLockout => 'Anmeldesperre',
self::TwoFactorFailed => '2FA fehlgeschlagen', self::TwoFactorFailed => '2FA fehlgeschlagen',
self::BotCheckUnavailable => 'Ohne Bot-Prüfung',
self::BoostEnded => 'Boost beendet',
}; };
} }
@ -102,6 +110,8 @@ enum ActivityType: string
self::ManualInvoice => 'amber', self::ManualInvoice => 'amber',
self::LoginFailed, self::TwoFactorFailed => 'orange', self::LoginFailed, self::TwoFactorFailed => 'orange',
self::LoginLockout => 'red', self::LoginLockout => 'red',
self::BotCheckUnavailable => 'orange',
self::BoostEnded => 'amber',
}; };
} }
} }

View file

@ -22,6 +22,14 @@ enum AiTextDeclaration: string
case MostlyAi = 'mostly_ai'; case MostlyAi = 'mostly_ai';
case FullyAi = 'fully_ai'; case FullyAi = 'fully_ai';
/**
* Keine Angabe: Einreichung über die bisherige Service-Schnittstelle, die
* das Feld nicht kennt (Rechtspaket 10/2026, RP-09). Kein Kunde hat
* „ohne KI“ erklärt, also wird auch nichts erklärt: kein öffentliches
* Label, keine Bestätigung, im Formular nicht wählbar.
*/
case NotDeclared = 'not_declared';
/** /**
* Wortlaut der Auswahloption im Submit-Flow (zwei Gruppen). * Wortlaut der Auswahloption im Submit-Flow (zwei Gruppen).
*/ */
@ -30,6 +38,7 @@ enum AiTextDeclaration: string
return match ($this) { return match ($this) {
self::None, self::Assisted => __('Ohne generative KI erstellt – oder KI nur als Formulierungshilfe, mit anschließender menschlicher Überarbeitung und Verantwortung.'), self::None, self::Assisted => __('Ohne generative KI erstellt – oder KI nur als Formulierungshilfe, mit anschließender menschlicher Überarbeitung und Verantwortung.'),
self::MostlyAi, self::FullyAi => __('Ganz oder überwiegend mit generativer KI erstellt.'), self::MostlyAi, self::FullyAi => __('Ganz oder überwiegend mit generativer KI erstellt.'),
self::NotDeclared => __('Keine Angabe (über die bisherige Schnittstelle eingereicht).'),
}; };
} }
@ -41,7 +50,7 @@ enum AiTextDeclaration: string
public function publicLabel(): ?string public function publicLabel(): ?string
{ {
return match ($this) { return match ($this) {
self::None, self::Assisted => null, self::None, self::Assisted, self::NotDeclared => null,
self::MostlyAi, self::FullyAi => __('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.'), self::MostlyAi, self::FullyAi => __('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.'),
}; };
} }
@ -53,7 +62,7 @@ enum AiTextDeclaration: string
public function formOption(): self public function formOption(): self
{ {
return match ($this) { return match ($this) {
self::None, self::Assisted => self::None, self::None, self::Assisted, self::NotDeclared => self::None,
self::MostlyAi, self::FullyAi => self::MostlyAi, self::MostlyAi, self::FullyAi => self::MostlyAi,
}; };
} }
@ -78,6 +87,10 @@ enum AiTextDeclaration: string
$options = []; $options = [];
foreach (self::cases() as $case) { foreach (self::cases() as $case) {
if ($case === self::NotDeclared) {
continue;
}
$options[$case->value] = $case->label(); $options[$case->value] = $case->label();
} }
@ -91,4 +104,15 @@ enum AiTextDeclaration: string
{ {
return array_column(self::cases(), 'value'); return array_column(self::cases(), 'value');
} }
/**
* Werte, die ein Einreicher selbst erklären kann — ohne `not_declared`,
* das nur die Alt-Schnittstelle setzt.
*
* @return list<string>
*/
public static function declarableValues(): array
{
return array_values(array_filter(self::values(), fn (string $value): bool => $value !== self::NotDeclared->value));
}
} }

View file

@ -0,0 +1,27 @@
<?php
namespace App\Enums;
/**
* Ergebnis der Bot-Prüfung (Cloudflare Turnstile) eines öffentlichen
* Formulars, siehe TurnstileVerifier.
*/
enum BotCheckStatus: string
{
/** Cloudflare hat den Menschen bestätigt. */
case Passed = 'passed';
/** Token fehlt, ist ungültig, abgelaufen oder gehört zu einem anderen Formular. */
case Failed = 'failed';
/** Cloudflare war nicht erreichbar — durchgelassen und zur Nachprüfung markiert. */
case Unavailable = 'unavailable';
/** Prüfung abgeschaltet (keine Keys, z. B. in Tests). */
case Skipped = 'skipped';
public function blocks(): bool
{
return $this === self::Failed;
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Enums;
/**
* Rubriken des Changelogs (Decision-Update Changelog, E3). Sicherheit
* erscheint nur als Sammelzeile, nie mit Beschreibung (E5).
*/
enum ChangelogCategory: string
{
case New = 'neu';
case Improved = 'verbessert';
case Fixed = 'behoben';
case Security = 'sicherheit';
public const string SECURITY_LINE = 'Sicherheitsverbesserungen eingespielt.';
public function label(): string
{
return match ($this) {
self::New => 'Neu',
self::Improved => 'Verbessert',
self::Fixed => 'Behoben',
self::Security => 'Sicherheit',
};
}
/**
* Reihenfolge innerhalb eines Tages.
*/
public function order(): int
{
return match ($this) {
self::New => 1,
self::Improved => 2,
self::Fixed => 3,
self::Security => 4,
};
}
/**
* Wert aus der Commit-Zeile `Changelog: …` — Groß-/Kleinschreibung egal.
*/
public static function fromTrailer(string $value): ?self
{
return self::tryFrom(mb_strtolower(trim($value)));
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum ChangelogStatus: string
{
case Draft = 'draft';
case Published = 'published';
case Discarded = 'discarded';
public function label(): string
{
return match ($this) {
self::Draft => 'Entwurf',
self::Published => 'Freigegeben',
self::Discarded => 'Verworfen',
};
}
public function badgeColor(): string
{
return match ($this) {
self::Draft => 'amber',
self::Published => 'green',
self::Discarded => 'zinc',
};
}
}

View file

@ -9,6 +9,9 @@ enum ContactMessageStatus: string
case Resolved = 'resolved'; case Resolved = 'resolved';
case Rejected = 'rejected'; case Rejected = 'rejected';
/** Quarantäne des Spamschutzes: keine Mail, nicht im Zähler, nach 30 Tagen gelöscht. */
case Spam = 'spam';
public function label(): string public function label(): string
{ {
return match ($this) { return match ($this) {
@ -16,6 +19,7 @@ enum ContactMessageStatus: string
self::InProgress => 'In Bearbeitung', self::InProgress => 'In Bearbeitung',
self::Resolved => 'Erledigt', self::Resolved => 'Erledigt',
self::Rejected => 'Abgelehnt', self::Rejected => 'Abgelehnt',
self::Spam => 'Spam',
}; };
} }

View file

@ -12,11 +12,21 @@ namespace App\Enums;
* Die Wiedereinreichung nach Ablehnung gehört ausdrücklich NICHT hierher. Sie * Die Wiedereinreichung nach Ablehnung gehört ausdrücklich NICHT hierher. Sie
* misst etwas anderes, nämlich ob die Infoseite (1d) wirkt, und wird gegen den * misst etwas anderes, nämlich ob die Infoseite (1d) wirkt, und wird gegen den
* Basiswert aus 0.2 separat geführt (`press-releases:rejection-rate`). * Basiswert aus 0.2 separat geführt (`press-releases:rejection-rate`).
*
* Stufen 2 und 3 seit 13.10.2026 (Roadmap 6.16): Vorher sprang die Leiter vom
* Klick auf IRGENDEINEN Mail-Link direkt zur Einlösung. Ob jemand die
* Verwaltungsseite überhaupt geöffnet und den Link angefordert hatte, war
* nicht zu sehen — und damit nicht, ob es am Hinweis, an der Seite oder an
* der Zustellung hakt.
*/ */
enum ConversionEventType: string enum ConversionEventType: string
{ {
case MailLinkClicked = 'mail_link_clicked'; case MailLinkClicked = 'mail_link_clicked';
case ManagePageOpened = 'manage_page_opened';
case AccessRequested = 'access_requested';
case MagicLinkConsumed = 'magic_link_consumed'; case MagicLinkConsumed = 'magic_link_consumed';
case NewsroomEdited = 'newsroom_edited'; case NewsroomEdited = 'newsroom_edited';
@ -32,10 +42,12 @@ enum ConversionEventType: string
{ {
return match ($this) { return match ($this) {
self::MailLinkClicked => 1, self::MailLinkClicked => 1,
self::MagicLinkConsumed => 2, self::ManagePageOpened => 2,
self::NewsroomEdited => 3, self::AccessRequested => 3,
self::AccountCreated => 4, self::MagicLinkConsumed => 4,
self::FirstBooking => 5, self::NewsroomEdited => 5,
self::AccountCreated => 6,
self::FirstBooking => 7,
}; };
} }
@ -43,6 +55,8 @@ enum ConversionEventType: string
{ {
return match ($this) { return match ($this) {
self::MailLinkClicked => 'Mail-Link geklickt', self::MailLinkClicked => 'Mail-Link geklickt',
self::ManagePageOpened => 'Verwaltungsseite geöffnet',
self::AccessRequested => 'Anmeldelink angefordert',
self::MagicLinkConsumed => 'Magic Link eingelöst', self::MagicLinkConsumed => 'Magic Link eingelöst',
self::NewsroomEdited => 'Newsroom bearbeitet', self::NewsroomEdited => 'Newsroom bearbeitet',
self::AccountCreated => 'Konto angelegt', self::AccountCreated => 'Konto angelegt',
@ -58,7 +72,9 @@ enum ConversionEventType: string
{ {
return match ($this) { return match ($this) {
self::MailLinkClicked => 'Betreff oder Anlass trägt nicht', self::MailLinkClicked => 'Betreff oder Anlass trägt nicht',
self::MagicLinkConsumed => 'Der Verwaltungszugang wird nicht verstanden', self::ManagePageOpened => 'Der Hinweis auf die Verwaltung wird übersehen',
self::AccessRequested => 'Der Verwaltungszugang wird nicht verstanden',
self::MagicLinkConsumed => 'Der Link kommt nicht an oder wird nicht geöffnet',
self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen', self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen',
self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar', self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar',
self::FirstBooking => 'Preis oder Angebot passt nicht', self::FirstBooking => 'Preis oder Angebot passt nicht',

View file

@ -0,0 +1,51 @@
<?php
namespace App\Enums;
/**
* Sicherheitsrelevante Kontoänderung, über die der Kontoinhaber an seine
* bisherige Adresse informiert wird (Sicherheitsaudit 07.10.2026, S-06).
*/
enum SecurityChange: string
{
case PasswordChanged = 'password_changed';
case PasswordReset = 'password_reset';
case EmailChanged = 'email_changed';
case TwoFactorEnabled = 'two_factor_enabled';
case TwoFactorDisabled = 'two_factor_disabled';
case GoogleLinked = 'google_linked';
case PasskeyAdded = 'passkey_added';
case PasskeyRemoved = 'passkey_removed';
case SecondFactorsReset = 'second_factors_reset';
public function label(): string
{
return match ($this) {
self::PasswordChanged => 'Ihr Passwort wurde geändert.',
self::PasswordReset => 'Ihr Passwort wurde über „Passwort vergessen“ neu gesetzt.',
self::EmailChanged => 'Die Login-Adresse Ihres Kontos wurde geändert.',
self::TwoFactorEnabled => 'Die Zwei-Faktor-Authentifizierung wurde aktiviert.',
self::TwoFactorDisabled => 'Die Zwei-Faktor-Authentifizierung wurde deaktiviert.',
self::GoogleLinked => 'Ein Google-Konto wurde mit Ihrem Konto verknüpft.',
self::PasskeyAdded => 'Ein neuer Passkey wurde zu Ihrem Konto hinzugefügt.',
self::PasskeyRemoved => 'Ein Passkey wurde aus Ihrem Konto entfernt.',
self::SecondFactorsReset => 'Ein Administrator hat Ihre Passkeys und Ihre Zwei-Faktor-Authentifizierung zurückgesetzt.',
};
}
/**
* Ob andere angemeldete Geräte dabei abgemeldet wurden.
*/
public function endsOtherSessions(): bool
{
return in_array($this, [self::PasswordChanged, self::PasswordReset], true);
}
}

View file

@ -102,9 +102,10 @@ class LegacyPressReleaseController extends Controller
'backlink_url' => $validated['backlink_url'] ?? null, 'backlink_url' => $validated['backlink_url'] ?? null,
'keywords' => $validated['keywords'] ?? null, 'keywords' => $validated['keywords'] ?? null,
'status' => PressReleaseStatus::Draft->value, 'status' => PressReleaseStatus::Draft->value,
// Kompatibilitäts-Default (Entscheidung 10.07.): Alt-Clients // Alt-Clients kennen die KI-Kennzeichnung nicht. Seit Rechtspaket
// kennen die KI-Kennzeichnung nicht. // 10/2026 (RP-09) wird dafür nichts mehr im Namen des Kunden
'ai_text_declaration' => AiTextDeclaration::None->value, // erklärt: „keine Angabe“, kein Label, keine Bestätigung.
'ai_text_declaration' => AiTextDeclaration::NotDeclared->value,
]); ]);
$pressRelease->uuid = (string) Str::uuid(); $pressRelease->uuid = (string) Str::uuid();
$pressRelease->user_id = $user->id; $pressRelease->user_id = $user->id;
@ -114,7 +115,6 @@ class LegacyPressReleaseController extends Controller
$validated['portal'] ?? null, $validated['portal'] ?? null,
$company->portal === Portal::Both ? $user->portal : $company->portal, $company->portal === Portal::Both ? $user->portal : $company->portal,
); );
$pressRelease->ai_declaration_confirmed_at = now();
$pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [ $pressRelease->slug = $pressRelease->generateUniqueSlug($pressRelease->title, [
'portal' => $pressRelease->portal, 'portal' => $pressRelease->portal,
'language' => $language, 'language' => $language,

View file

@ -3,78 +3,21 @@
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
use App\Models\NewsletterSubscription;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* Newsletter-Anmeldung über die API — abgeschaltet am 09.10.2026
* (Roadmap 4.3). Der Endpunkt legte Abos ohne Bestätigungsweg an, obwohl die
* Spezifikation ein Double-Opt-in versprach, und wurde seit dem Go-Live nicht
* genutzt. Eine Anmeldung mit Double-Opt-in kommt mit dem Branchen-Digest
* (Roadmap 6.4); bis dahin antwortet die Route mit 410.
*/
class NewsletterSubscriptionController extends Controller class NewsletterSubscriptionController extends Controller
{ {
public function store(SubscribeNewsletterRequest $request): JsonResponse public function store(): JsonResponse
{ {
$validated = $request->validated();
$email = mb_strtolower($validated['email']);
$subscription = DB::transaction(function () use ($request, $validated, $email): NewsletterSubscription {
$subscription = NewsletterSubscription::withoutGlobalScopes()
->where('portal', $validated['portal'])
->where('email', $email)
->lockForUpdate()
->first();
// Nur ein fremder, tatsächlich gesetzter Eigentümer blockiert (409).
// Verwaiste Datensätze (user_id NULL nach Konto-Löschung/Legacy-Import)
// dürfen neu gebunden werden — sonst wäre die portal+email-Kombination
// dauerhaft nicht mehr abonnierbar.
if ($subscription !== null && $subscription->user_id !== null && $subscription->user_id !== $request->user()->id) {
abort(409, 'This email address is already subscribed.');
}
if ($subscription === null) {
return NewsletterSubscription::withoutGlobalScopes()->create([
'portal' => $validated['portal'],
'email' => $email,
'user_id' => $request->user()->id,
'salutation_key' => $validated['salutation_key'] ?? null,
'first_name' => $validated['first_name'] ?? null,
'last_name' => $validated['last_name'] ?? null,
'ip_address' => $request->ip(),
'is_confirmed' => false,
'confirmation_token' => Str::random(32),
'subscribed_at' => now(),
'unsubscribed_at' => null,
]);
}
$attributes = [
'user_id' => $request->user()->id,
'salutation_key' => $validated['salutation_key'] ?? null,
'first_name' => $validated['first_name'] ?? null,
'last_name' => $validated['last_name'] ?? null,
'ip_address' => $request->ip(),
'subscribed_at' => now(),
'unsubscribed_at' => null,
];
if (! $subscription->is_confirmed) {
$attributes['confirmation_token'] = Str::random(32);
}
$subscription->update($attributes);
return $subscription;
});
return response()->json([ return response()->json([
'message' => 'Newsletter subscription created.', 'message' => 'Newsletter subscription via the API has been discontinued.',
'data' => [ ], 410);
'id' => $subscription->id,
'portal' => $subscription->portal->value,
'email' => $subscription->email,
'is_confirmed' => $subscription->is_confirmed,
],
], 201);
} }
} }

View file

@ -76,13 +76,13 @@ class PressReleaseController extends Controller
// damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden. // damit Blacklist-/Quota-/Log-Prüfung garantiert durchlaufen werden.
'status' => PressReleaseStatus::Draft->value, 'status' => PressReleaseStatus::Draft->value,
// Legacy-/service-Clients kennen die KI-Kennzeichnung (Art. 50 // Legacy-/service-Clients kennen die KI-Kennzeichnung (Art. 50
// KI-VO) nicht — Entscheidung 10.07.: fehlende Angabe gilt als // KI-VO) nicht. Rechtspaket 10/2026 (RP-09): eine fehlende Angabe
// „ohne generative KI erstellt"; das API-Kunden-Mailing (R6.4) // wird als „keine Angabe“ gespeichert, nicht als Erklärung „ohne
// weist darauf hin, dass die weitere Nutzung diese Erklärung // KI“ im Namen des Kunden. v1-Clients deklarieren explizit beim
// darstellt. v1-Clients deklarieren explizit beim Submit. // Submit.
...($this->isLegacyServiceRequest($request) ? [ ...($this->isLegacyServiceRequest($request) ? [
'ai_text_declaration' => AiTextDeclaration::None->value, 'ai_text_declaration' => AiTextDeclaration::NotDeclared->value,
'ai_declaration_confirmed_at' => now(), 'ai_declaration_confirmed_at' => null,
] : []), ] : []),
]); ]);
$pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [ $pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [
@ -197,7 +197,7 @@ class PressReleaseController extends Controller
// Kenntnisnahme der dauerhaften Archivierung (AGB § 13) — // Kenntnisnahme der dauerhaften Archivierung (AGB § 13) —
// Entscheidung 10.07.2026. // Entscheidung 10.07.2026.
$validated = $request->validate([ $validated = $request->validate([
'ai_text_declaration' => ['required', Rule::enum(AiTextDeclaration::class)], 'ai_text_declaration' => ['required', Rule::in(AiTextDeclaration::declarableValues())],
]); ]);
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request); $pressRelease = $this->findOwnedPressRelease($pressRelease, $request);

View file

@ -2,9 +2,11 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Enums\SecurityChange;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Services\Auth\ContactAccessService; use App\Services\Auth\ContactAccessService;
use App\Services\Auth\SecurityNotifier;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -15,7 +17,7 @@ class ConfirmEmailChangeController extends Controller
* signierten Link aus der Mail an die NEUE Adresse geklickt hat. * signierten Link aus der Mail an die NEUE Adresse geklickt hat.
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern). * Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
*/ */
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess): RedirectResponse public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess, SecurityNotifier $notifier): RedirectResponse
{ {
abort_unless($request->user()->is($user), 403); abort_unless($request->user()->is($user), 403);
@ -34,6 +36,8 @@ class ConfirmEmailChangeController extends Controller
->with('security-error', __('Die neue E-Mail-Adresse wird inzwischen von einem anderen Konto verwendet. Die Änderung wurde verworfen.')); ->with('security-error', __('Die neue E-Mail-Adresse wird inzwischen von einem anderen Konto verwendet. Die Änderung wurde verworfen.'));
} }
$previousEmail = (string) $user->email;
$user->forceFill([ $user->forceFill([
'email' => $user->pending_email, 'email' => $user->pending_email,
'email_verified_at' => now(), 'email_verified_at' => now(),
@ -45,6 +49,11 @@ class ConfirmEmailChangeController extends Controller
// vergeben – nicht erst beim nächsten Login. // vergeben – nicht erst beim nächsten Login.
$contactAccess->syncMembershipsForEmail($user); $contactAccess->syncMembershipsForEmail($user);
// Die bisherige Adresse erfährt von der Änderung (Sicherheitsaudit
// 07.10.2026, S-06) — sonst bliebe eine Übernahme per fremder Session
// für den Inhaber unsichtbar.
$notifier->notify($user, SecurityChange::EmailChanged, recipient: $previousEmail, newEmail: (string) $user->email);
return redirect()->route('me.security') return redirect()->route('me.security')
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.')); ->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
} }

View file

@ -3,17 +3,24 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\LoginCompletion; use App\Services\Auth\LoginCompletion;
use App\Services\Auth\SocialAccountLinker;
use App\Services\Auth\SocialAuthService; use App\Services\Auth\SocialAuthService;
use App\Services\Auth\SocialLoginResult;
use App\Support\LoginRedirect; use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Socialite\AbstractUser;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
/** /**
* Google-Login (WS-6). Erst-Login legt – de-dupliziert über die E-Mail – einen * Google-Login (WS-6). Erst-Login legt – de-dupliziert über die E-Mail – einen
* aktiven, verifizierten customer-Account an; bestehende Accounts werden nur * aktiven, verifizierten customer-Account an; bestehende Accounts werden
* verknüpft. Deaktivierte Accounts werden nicht reaktiviert. * verknüpft, bei Adressen außerhalb von Googles Zuständigkeit erst nach
* Bestätigung per E-Mail (Sicherheitsaudit 07.10.2026, S-05). Deaktivierte
* Accounts werden nicht reaktiviert.
*/ */
class GoogleController extends Controller class GoogleController extends Controller
{ {
@ -22,29 +29,72 @@ class GoogleController extends Controller
return Socialite::driver('google')->redirect(); return Socialite::driver('google')->redirect();
} }
public function callback(Request $request, SocialAuthService $social, LoginCompletion $completion): RedirectResponse public function callback(Request $request, SocialAuthService $social, SocialAccountLinker $linker, LoginCompletion $completion): RedirectResponse
{ {
try { try {
$googleUser = Socialite::driver('google')->user(); $googleUser = Socialite::driver('google')->user();
} catch (\Throwable) { } catch (\Throwable) {
return redirect()->route('login')->withErrors([ return $this->failed(__('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'));
'email' => __('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'),
]);
} }
$user = $social->resolveUser( $raw = $googleUser instanceof AbstractUser ? $googleUser->getRaw() : [];
$providerId = (string) $googleUser->getId();
$result = $social->resolve(
'google', 'google',
(string) $googleUser->getId(), $providerId,
$googleUser->getEmail(), $googleUser->getEmail(),
$googleUser->getName(), $googleUser->getName(),
emailVerified: filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
hostedDomain: isset($raw['hd']) ? (string) $raw['hd'] : null,
); );
if (! $user || ! $user->is_active) { if ($result->outcome === SocialLoginResult::UNVERIFIED_EMAIL) {
return redirect()->route('login')->withErrors([ return $this->failed(__('Google hat keine bestätigte E-Mail-Adresse übermittelt. Bitte melden Sie sich per E-Mail-Link an.'));
'email' => __('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'),
]);
} }
if ($result->outcome === SocialLoginResult::PROVIDER_MISMATCH) {
Log::warning('google-login.provider-mismatch', ['user_id' => $result->user?->getKey()]);
return $this->failed(__('Ihr Konto ist bereits mit einem anderen Google-Konto verknüpft. Bitte melden Sie sich per E-Mail-Link an.'));
}
$user = $result->user;
if (! $user || ! $user->is_active) {
return $this->failed(__('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'));
}
if ($result->outcome === SocialLoginResult::CONFIRM_LINK) {
if (! $linker->requestConfirmation($user, 'google', $providerId)) {
return $this->failed(__('Zu viele Anfragen. Bitte warten Sie einige Minuten und versuchen Sie es erneut.'));
}
return redirect()->route('login')->with('status', __('Für Ihr bestehendes Konto ist eine einmalige Bestätigung nötig: Wir haben Ihnen einen Link an Ihre E-Mail-Adresse geschickt. Bitte öffnen Sie ihn in diesem Browser.'));
}
return $this->completeLogin($request, $user, $completion);
}
/**
* Bestätigungslink aus der Mail (SocialAccountLinker): verknüpft und
* meldet an — nur in der Browser-Session, die den Google-Login begonnen hat.
*/
public function confirmLink(Request $request, User $user, SocialAccountLinker $linker, LoginCompletion $completion): RedirectResponse
{
if (! $linker->confirm($user, (string) $request->query('provider_id', ''))) {
return $this->failed(__('Der Bestätigungslink ist ungültig oder abgelaufen. Bitte öffnen Sie ihn im selben Browser, in dem Sie die Anmeldung mit Google gestartet haben, oder starten Sie die Anmeldung erneut.'));
}
if (! $user->is_active) {
return $this->failed(__('Ihr Konto ist nicht aktiv. Bitte wenden Sie sich an den Support.'));
}
return $this->completeLogin($request, $user, $completion);
}
private function completeLogin(Request $request, User $user, LoginCompletion $completion): RedirectResponse
{
// 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen, // 2FA aktiv: wie Passwort- und Magic-Link-Login nicht direkt einloggen,
// sondern in Fortifys Challenge übergeben (Audit F02). Google belegt // sondern in Fortifys Challenge übergeben (Audit F02). Google belegt
// den Besitz des Postfachs, nicht den zweiten Faktor. // den Besitz des Postfachs, nicht den zweiten Faktor.
@ -65,4 +115,9 @@ class GoogleController extends Controller
LoginRedirect::homeFor($user), LoginRedirect::homeFor($user),
)); ));
} }
private function failed(string $message): RedirectResponse
{
return redirect()->route('login')->withErrors(['email' => $message]);
}
} }

View file

@ -2,7 +2,6 @@
namespace App\Http\Controllers\Auth; namespace App\Http\Controllers\Auth;
use App\Enums\ConversionEventType;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\MagicLink; use App\Models\MagicLink;
use App\Models\PressRelease; use App\Models\PressRelease;
@ -75,13 +74,10 @@ class MagicLinkConsumeController extends Controller
return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.')); return redirect()->route('login')->with('status', __('The magic login link has expired or was already used.'));
} }
// Stufe 2 der Konversionsleiter. Die Herkunft kommt vom Token, nicht // Stufe 4 der Konversionsleiter. Die Herkunft kommt vom Token, nicht
// aus der Session — die ist beim Weg über Mail und Domainwechsel // aus der Session — die ist beim Weg über Mail und Domainwechsel
// längst gerissen (Roadmap 0.4). // längst gerissen (Roadmap 0.4).
$this->recorder->record( $this->recorder->recordMagicLinkConsumed($magicLink);
ConversionEventType::MagicLinkConsumed,
$this->recorder->sourceOf($magicLink),
);
$user = $magicLink->user; $user = $magicLink->user;
@ -123,10 +119,7 @@ class MagicLinkConsumeController extends Controller
]); ]);
if ($claimed > 0) { if ($claimed > 0) {
$this->recorder->record( $this->recorder->recordMagicLinkConsumed($magicLink);
ConversionEventType::MagicLinkConsumed,
$this->recorder->sourceOf($magicLink),
);
} }
} }

View file

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
/**
* Nimmt CSP-Meldungen der Browser entgegen (ContentSecurityPolicy, Melde-
* Modus) und schreibt sie nach storage/logs/csp-*.log. Gleiche Meldungen
* landen höchstens einmal pro Stunde im Log. Adressen werden ohne Query und
* ohne tokenartige Pfadsegmente gespeichert — signierte Links und
* Anmeldelinks gehören nicht ins Log.
*/
class CspReportController extends Controller
{
private const MAX_BYTES = 16384;
public function __invoke(Request $request): Response
{
$body = $request->getContent();
if (! config('security.csp.report_only') || $body === '' || strlen($body) > self::MAX_BYTES) {
return response()->noContent();
}
$report = json_decode($body, true)['csp-report'] ?? null;
if (! is_array($report)) {
return response()->noContent();
}
$entry = [
'directive' => mb_substr((string) ($report['effective-directive'] ?? $report['violated-directive'] ?? ''), 0, 60),
'blocked' => $this->scrub($report['blocked-uri'] ?? ''),
'document' => $this->scrub($report['document-uri'] ?? ''),
'source' => $this->scrub($report['source-file'] ?? ''),
'line' => (int) ($report['line-number'] ?? 0),
'sample' => mb_substr((string) ($report['script-sample'] ?? ''), 0, 40),
];
if (Cache::add('csp-report.'.sha1(json_encode($entry)), true, now()->addHour())) {
Log::channel('csp')->warning('CSP-Verstoß (nur gemeldet)', $entry);
}
return response()->noContent();
}
private function scrub(mixed $value): string
{
$value = mb_substr(is_string($value) ? $value : '', 0, 500);
$parts = parse_url($value);
// Schlüsselwörter wie „inline“, „eval“, „data“ unverändert.
if (! is_array($parts) || empty($parts['host'])) {
return mb_substr(strtok($value, '?#') ?: '', 0, 60);
}
$path = preg_replace('#/[A-Za-z0-9_-]{32,}(?=/|$)#', '/[Filtered]', $parts['path'] ?? '');
return mb_substr(($parts['scheme'] ?? 'https').'://'.$parts['host'].$path, 0, 200);
}
}

View file

@ -0,0 +1,81 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\ChangelogEntry;
use App\Services\Changelog\ChangelogTimeline;
use App\Support\DomainAssetContext;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* Öffentlicher Changelog unter pressekonto.com/changelog mit RSS-Feed
* (Decision-Update Changelog, E2). Er gehört zum Hub; auf den Portalen leitet
* die Adresse dorthin weiter.
*/
class ChangelogController extends Controller
{
public function __construct(private readonly ChangelogTimeline $timeline) {}
public function index(Request $request): View|RedirectResponse
{
if (! $this->onHub($request)) {
return redirect($this->hubBase().'/changelog', 301);
}
DomainAssetContext::applyWebDomainConfig('pressekonto');
return view('web.changelog', [
'entries' => $this->timeline->entries(),
'feedUrl' => $this->hubBase().'/changelog/feed',
]);
}
public function feed(Request $request): Response|RedirectResponse
{
if (! $this->onHub($request)) {
return redirect($this->hubBase().'/changelog/feed', 301);
}
$base = $this->hubBase();
$entries = $this->timeline->entries(50);
$items = $entries->map(fn (ChangelogEntry $entry): array => [
'title' => $entry->category->label().': '.Str::limit($entry->publicText(), 90),
'link' => $base.'/changelog#eintrag-'.$entry->id,
'published_at' => $entry->released_on->copy()->setTime(12, 0)->toRfc2822String(),
'description' => $entry->publicText(),
'category' => $entry->category->label(),
'creator' => null,
])->all();
$xml = view('web.feed', [
'channel' => [
'title' => 'pressekonto – Changelog',
'link' => $base.'/changelog',
'self' => $base.'/changelog/feed',
'description' => 'Neuerungen auf pressekonto, presseecho und businessportal24.',
'language' => 'de',
'last_build' => ($entries->first()?->released_on?->copy()->setTime(12, 0) ?? Carbon::now())->toRfc2822String(),
],
'items' => $items,
])->render();
return response($xml, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
}
private function onHub(Request $request): bool
{
return str_contains($request->getHost(), 'pressekonto');
}
private function hubBase(): string
{
return rtrim((string) config('domains.domain_portal_url'), '/');
}
}

View file

@ -2,14 +2,21 @@
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Enums\ActivityType;
use App\Enums\BotCheckStatus;
use App\Enums\ContactMessageStatus;
use App\Enums\ContactMessageTopic; use App\Enums\ContactMessageTopic;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Mail\ContactRequestSubmitted;
use App\Models\ContactMessage; use App\Models\ContactMessage;
use App\Services\ActivityLog;
use App\Services\Spam\ContactSpamFilter;
use App\Services\Spam\SpamLog;
use App\Services\Spam\TurnstileVerifier;
use App\Services\Web\ContactMessageNotifier;
use App\Support\WebPortalContext; use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -20,9 +27,21 @@ use Illuminate\Validation\ValidationException;
* (contact_messages) und versendet zusätzlich eine Mail an die Brand- * (contact_messages) und versendet zusätzlich eine Mail an die Brand-
* Kontaktadresse (config domains.*.brand.contact_email, Fallback: * Kontaktadresse (config domains.*.brand.contact_email, Fallback:
* Absenderadresse der Anwendung). * Absenderadresse der Anwendung).
*
* Spamschutz (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md):
* Cloudflare Turnstile für Gäste, danach Inhaltsfilter. Treffer des Filters
* landen in der Quarantäne (Status Spam, keine Mail); war Cloudflare nicht
* erreichbar, wird angenommen und im Verlauf markiert.
*/ */
class ContactController extends Controller class ContactController extends Controller
{ {
public function __construct(
private readonly TurnstileVerifier $turnstile,
private readonly ContactSpamFilter $spamFilter,
private readonly ContactMessageNotifier $notifier,
private readonly ActivityLog $activityLog,
) {}
public function __invoke(Request $request): RedirectResponse public function __invoke(Request $request): RedirectResponse
{ {
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken, // Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
@ -53,27 +72,66 @@ class ContactController extends Controller
$this->ensureNotRateLimited($request); $this->ensureNotRateLimited($request);
RateLimiter::hit($this->throttleKey($request), 3600); RateLimiter::hit($this->throttleKey($request), 3600);
// Bestätigte Kunden sind keine Bots. Unbestätigte Konten (dort
// landen die Bot-Registrierungen) prüfen wir wie Gäste.
$trusted = $request->user()?->hasVerifiedEmail() === true;
$botCheck = $trusted ? null : $this->turnstile->verifyRequest($request, 'contact');
if ($botCheck?->blocks()) {
throw ValidationException::withMessages([
'turnstile' => __('web.launcher.bot_check_failed'),
]);
}
$spamReason = $trusted ? null : $this->spamFilter->reasonFor(
$validated['name'],
$validated['message'],
$request->string(ContactSpamFilter::STARTED_FIELD)->toString(),
);
$topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact; $topic = ContactMessageTopic::tryFrom($validated['topic'] ?? '') ?? ContactMessageTopic::Contact;
ContactMessage::create([ $message = ContactMessage::create([
'portal' => $this->portal($request), 'portal' => $this->portal($request),
'topic' => $topic, 'topic' => $topic,
'status' => $spamReason !== null ? ContactMessageStatus::Spam : ContactMessageStatus::Open,
'name' => $validated['name'], 'name' => $validated['name'],
'email' => $validated['email'], 'email' => $validated['email'],
'message' => $validated['message'], 'message' => $validated['message'],
'page_url' => $validated['page_url'] ?? null, 'page_url' => $validated['page_url'] ?? null,
'payload' => array_filter([
'spam_reason' => $spamReason,
'bot_check' => $botCheck?->status === BotCheckStatus::Skipped ? null : $botCheck?->status->value,
'bot_check_reason' => $botCheck?->needsReview() ? $botCheck->reason : null,
]) ?: null,
'requester_ip' => $request->ip(), 'requester_ip' => $request->ip(),
'user_id' => $request->user()?->id, 'user_id' => $request->user()?->id,
]); ]);
Mail::to($this->recipient())->queue((new ContactRequestSubmitted( // Spam: gespeichert zur Nachprüfung, aber keine Mail. Der Absender
senderName: $validated['name'], // bekommt dieselbe Bestätigung wie alle — der Bot lernt nichts.
senderEmail: $validated['email'], if ($spamReason !== null) {
messageText: $validated['message'], Log::channel('spam')->info('Kontaktanfrage in Quarantäne.', [
host: $request->getHost(), 'reason' => $spamReason,
topicLabel: $topic->label(), 'portal' => $message->portal,
pageUrl: $validated['page_url'] ?? null, 'ip_prefix' => SpamLog::ipPrefix($request->ip()),
))->afterCommit()); ]);
return $this->done();
}
if ($botCheck?->needsReview()) {
$this->activityLog->record(
ActivityType::BotCheckUnavailable,
sprintf('Kontaktanfrage %s ohne Bot-Prüfung angenommen (%s) — bitte nachprüfen.', $message->reference(), $botCheck->reason),
subject: $message,
actorLabel: (string) $message->email,
meta: ['form' => 'contact', 'reason' => $botCheck->reason],
);
}
$this->notifier->notify($message, $request->getHost());
return $this->done(); return $this->done();
} }
@ -96,19 +154,6 @@ class ContactController extends Controller
return WebPortalContext::portalFromHost()->value; return WebPortalContext::portalFromHost()->value;
} }
private function recipient(): string
{
// Auch die Hub-Domain (pressekonto) nutzt das Formular — sie ist kein
// Spoke-Portal, daher vor dem WebPortalContext am Host unterscheiden.
if (str_contains(request()->getHost(), 'pressekonto')) {
$brand = config('domains.domains.pressekonto.brand', []);
} else {
$brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? [];
}
return $brand['contact_email'] ?? config('mail.from.address');
}
private function ensureNotRateLimited(Request $request): void private function ensureNotRateLimited(Request $request): void
{ {
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) { if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource; use App\Enums\ConversionSource;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Company; use App\Models\Company;
@ -9,6 +10,7 @@ use App\Models\PressRelease;
use App\Scopes\PortalScope; use App\Scopes\PortalScope;
use App\Services\Company\MergedNewsroomLookup; use App\Services\Company\MergedNewsroomLookup;
use App\Services\Company\NewsroomClaimService; use App\Services\Company\NewsroomClaimService;
use App\Services\Conversion\ConversionRecorder;
use App\Support\WebPortalContext; use App\Support\WebPortalContext;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@ -29,9 +31,10 @@ class NewsroomClaimController extends Controller
public function __construct( public function __construct(
private readonly NewsroomClaimService $claims, private readonly NewsroomClaimService $claims,
private readonly MergedNewsroomLookup $mergedNewsrooms, private readonly MergedNewsroomLookup $mergedNewsrooms,
private readonly ConversionRecorder $recorder,
) {} ) {}
public function show(string $edition, string $slug): View|Response|RedirectResponse public function show(Request $request, string $edition, string $slug): View|Response|RedirectResponse
{ {
$context = WebPortalContext::fromRequest(); $context = WebPortalContext::fromRequest();
$company = $this->claims->findClaimable($context->portalValues, $slug); $company = $this->claims->findClaimable($context->portalValues, $slug);
@ -40,6 +43,8 @@ class NewsroomClaimController extends Controller
return $this->resolveMissing($context, $edition, $slug); return $this->resolveMissing($context, $edition, $slug);
} }
$this->recorder->recordManagePageOpened($request, $company, $this->sourceOf($request), $this->landingPressRelease($request, $company));
return view('web.newsroom-claim', [ return view('web.newsroom-claim', [
'company' => $company, 'company' => $company,
'recipients' => $this->claims->maskedRecipients($company), 'recipients' => $this->claims->maskedRecipients($company),
@ -70,7 +75,8 @@ class NewsroomClaimController extends Controller
// Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist // Enumeration-sicher: dieselbe Meldung, ob ein Konto deaktiviert ist
// oder nicht — die Adresse ist ohnehin nur maskiert sichtbar. // oder nicht — die Adresse ist ohnehin nur maskiert sichtbar.
$sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $this->landingPressReleaseId($request, $company)); $landing = $this->landingPressRelease($request, $company);
$sent = $this->claims->request($company, $request->ip(), $this->sourceOf($request), $landing?->getKey());
// Der Firmenzähler zählt nur tatsächlich versendete Links — sonst // Der Firmenzähler zählt nur tatsächlich versendete Links — sonst
// sperrten Dritte den Inhaber mit Fehlversuchen 24 h aus // sperrten Dritte den Inhaber mit Fehlversuchen 24 h aus
@ -78,6 +84,10 @@ class NewsroomClaimController extends Controller
// Drossel oben. // Drossel oben.
if ($sent > 0) { if ($sent > 0) {
RateLimiter::hit($this->companyKey($company->getKey()), 86400); RateLimiter::hit($this->companyKey($company->getKey()), 86400);
// Stufe 3 der Konversionsleiter (Roadmap 6.16): nur, wenn
// wirklich ein Link verschickt wurde.
$this->recorder->record(ConversionEventType::AccessRequested, $this->sourceOf($request), $company, $landing);
} }
return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent')); return back()->with('newsroom-claim-status', __('static.newsroom_claim.flash_sent'));
@ -88,7 +98,7 @@ class NewsroomClaimController extends Controller
* Link), landet er nach dem Login auf genau dieser Meldung samt Aufrufen * Link), landet er nach dem Login auf genau dieser Meldung samt Aufrufen
* (Roadmap 1c.2) — sofern sie wirklich zu dieser Firma gehört. * (Roadmap 1c.2) — sofern sie wirklich zu dieser Firma gehört.
*/ */
private function landingPressReleaseId(Request $request, Company $company): ?int private function landingPressRelease(Request $request, Company $company): ?PressRelease
{ {
$id = (int) $request->input('pr', 0); $id = (int) $request->input('pr', 0);
@ -96,7 +106,7 @@ class NewsroomClaimController extends Controller
return null; return null;
} }
return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->exists() ? $id : null; return PressRelease::query()->withoutGlobalScope(PortalScope::class)->whereKey($id)->where('company_id', $company->getKey())->first();
} }
private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse private function resolveMissing(WebPortalContext $context, string $edition, string $slug): Response|RedirectResponse

View file

@ -33,6 +33,9 @@ class BlockUnusedFortifyRoutes
'two-factor.secret-key', 'two-factor.secret-key',
'two-factor.recovery-codes', 'two-factor.recovery-codes',
'two-factor.regenerate-recovery-codes', 'two-factor.regenerate-recovery-codes',
// Passkeys löscht die Sicherheitsseite mit Identitätsnachweis
// (Entscheidung 5, 07.10.2026).
'passkey.destroy',
]; ];
public function handle(Request $request, Closure $next): Response public function handle(Request $request, Closure $next): Response

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Services\Api\ApiAccessEligibilityService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API-Zugang nur mit laufender Buchung (08.10.2026): aktives Abo,
* aktive Zahlungsoption oder Bestandsschutz — dieselbe Regel wie beim
* Erzeugen eines Tokens (ApiAccessEligibilityService), jetzt bei jedem
* Aufruf. Endet die Buchung, sperren die Tokens; sie bleiben erhalten und
* funktionieren wieder, sobald erneut gebucht ist. Gilt für API v1 und die
* Legacy-Aliasse unter /service.
*/
class EnsureApiAccessIsBooked
{
public function __construct(private readonly ApiAccessEligibilityService $eligibility) {}
/**
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user instanceof User && ! $this->eligibility->hasApiAccess($user)) {
return response()->json([
'message' => 'API access requires an active subscription or booking.',
], 403);
}
return $next($request);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Http\Middleware;
use App\Actions\Admin\UserImpersonation;
use App\Models\User;
use App\Services\Auth\SecurityConfirmation;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Passkey hinzufügen nur mit frischem Identitätsnachweis (Entscheidung 5,
* Sicherheitsaudit 07.10.2026, S-06): Ein Passkey ist dauerhafter Zugang —
* eine fremde Session allein darf keinen hinterlegen. Den Nachweis erbringt
* die Sicherheitsseite (ConfirmsIdentity), bevor sie die Registrierung im
* Browser startet. Während einer Impersonation nie.
*
* Hängt über `fortify.middleware` an allen Fortify-Routen und greift nur bei
* den Registrierungsrouten. Gelöscht wird über die Sicherheitsseite; die
* Fortify-Löschroute sperrt BlockUnusedFortifyRoutes.
*/
class EnsurePasskeyManagementIsConfirmed
{
/**
* @var list<string>
*/
public const GUARDED_ROUTES = [
'passkey.registration-options',
'passkey.store',
];
public function __construct(private readonly SecurityConfirmation $confirmation) {}
public function handle(Request $request, Closure $next): Response
{
if (! in_array($request->route()?->getName(), self::GUARDED_ROUTES, true)) {
return $next($request);
}
$user = $request->user();
if (! $user instanceof User || app(UserImpersonation::class)->isActive() || ! $this->confirmation->isFresh($user)) {
abort(403, __('Bitte bestätigen Sie zuerst Ihre Identität.'));
}
return $next($request);
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Models\User;
use App\Services\Auth\SessionRevocation; use App\Services\Auth\SessionRevocation;
use Closure; use Closure;
use Illuminate\Auth\SessionGuard; use Illuminate\Auth\SessionGuard;
@ -11,8 +12,8 @@ use Symfony\Component\HttpFoundation\Response;
/** /**
* Meldet eine Session ab, die vor der letzten Sperre ihres Kontos entstanden * Meldet eine Session ab, die vor der letzten Sperre ihres Kontos entstanden
* ist (SessionRevocation, Audit K-04). Kostet je angemeldetem Request einen * ist (SessionRevocation, Audit K-04). Die Marke steht am geladenen User;
* Cache-Zugriff; Gäste lösen keinen aus. * bis zum Release nach v1.6.0 kommt ein Cache-Zugriff dazu (Übergang).
*/ */
class EnsureSessionIsNotRevoked class EnsureSessionIsNotRevoked
{ {
@ -33,7 +34,7 @@ class EnsureSessionIsNotRevoked
$user = $guard->user(); $user = $guard->user();
if ($user === null || ! $this->revocation->isRevoked((int) $user->getAuthIdentifier(), $request->session())) { if (! $user instanceof User || ! $this->revocation->isRevoked($user, $request->session())) {
return $next($request); return $next($request);
} }

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use App\Services\Auth\StrongAuthentication;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Admin-Bereich nur mit starker Anmeldung (Entscheidung 5, 07.10.2026):
* Super-Admins und Admins bestätigen ihre Session einmalig per Passkey oder
* Authenticator-Code. Seitenaufrufe führen zur Bestätigungsseite und danach
* zurück; Livewire- und JSON-Folgeanfragen werden abgewiesen. Editoren und
* Kunden sind nicht betroffen. Läuft auch als persistente Livewire-Middleware.
*/
class EnsureStrongAdminAuthentication
{
public function __construct(private readonly StrongAuthentication $strong) {}
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user instanceof User || ! $this->strong->isRequiredFor($user) || $this->strong->isSatisfiedBy($user)) {
return $next($request);
}
if (! $request->isMethod('GET') || $request->expectsJson()) {
abort(403, __('Bitte bestätigen Sie Ihre Anmeldung mit Passkey oder Authenticator-Code.'));
}
$request->session()->put(StrongAuthentication::INTENDED_KEY, $request->fullUrl());
return redirect()->route('strong-auth.confirm');
}
}

View file

@ -7,7 +7,6 @@ use App\Enums\ConversionSource;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Scopes\PortalScope; use App\Scopes\PortalScope;
use App\Services\Conversion\ConversionRecorder; use App\Services\Conversion\ConversionRecorder;
use App\Support\CrawlerIdentifier;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@ -49,7 +48,7 @@ class RecordMailLinkClick
// Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links // Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links
// vorab per HEAD oder mit eigener Kennung; das wäre kein Klick, // vorab per HEAD oder mit eigener Kennung; das wäre kein Klick,
// sondern eine Zustellung (Umsetzungsaudit 29.09.2026, R-08). // sondern eine Zustellung (Umsetzungsaudit 29.09.2026, R-08).
if (! $request->isMethod('GET') || $this->isScanner($request->userAgent())) { if (! $request->isMethod('GET') || $this->recorder->isAutomatedVisit($request->userAgent())) {
return $next($request); return $next($request);
} }
@ -81,23 +80,6 @@ class RecordMailLinkClick
return $next($request); return $next($request);
} }
private function isScanner(?string $userAgent): bool
{
$userAgent = mb_strtolower(trim((string) $userAgent));
if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) {
return true;
}
foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) {
if (str_contains($userAgent, mb_strtolower((string) $pattern))) {
return true;
}
}
return false;
}
/** /**
* Scope-frei aufgelöst: Der Klick landet je nach Link auf dem Hub oder auf * Scope-frei aufgelöst: Der Klick landet je nach Link auf dem Hub oder auf
* einem der beiden Spokes, der Portal-Scope würde die Meldung dort * einem der beiden Spokes, der Portal-Scope würde die Meldung dort

View file

@ -2,6 +2,7 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Support\ContentSecurityPolicy;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@ -10,8 +11,8 @@ use Symfony\Component\HttpFoundation\Response;
* Applikationsseitige Security-Header (R6.2) — bewusst unabhängig vom * Applikationsseitige Security-Header (R6.2) — bewusst unabhängig vom
* Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt. * Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt.
* Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben. * Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben.
* CSP fehlt absichtlich: Livewire/Alpine/Vite brauchen eine eigene * HSTS nur auf HTTPS-Antworten und laut config/security.php.
* Inline-Script-Strategie — separat einführen, nicht nebenbei. * CSP vorerst nur im Melde-Modus (ContentSecurityPolicy, config/security.php).
*/ */
class SecurityHeaders class SecurityHeaders
{ {
@ -30,6 +31,16 @@ class SecurityHeaders
} }
} }
if (config('security.csp.report_only')
&& str_contains((string) $response->headers->get('Content-Type'), 'text/html')
&& ! $response->headers->has('Content-Security-Policy-Report-Only')) {
$response->headers->set('Content-Security-Policy-Report-Only', ContentSecurityPolicy::header());
}
if ($request->isSecure() && config('security.hsts.enabled') && ! $response->headers->has('Strict-Transport-Security')) {
$response->headers->set('Strict-Transport-Security', 'max-age='.(int) config('security.hsts.max_age'));
}
return $response; return $response;
} }
} }

View file

@ -1,38 +0,0 @@
<?php
namespace App\Http\Requests\Api\V1;
use App\Enums\Portal;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SubscribeNewsletterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user()?->tokenCan('newsletter:subscribe') === true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'portal' => ['required', Rule::in([
Portal::Presseecho->value,
Portal::Businessportal24->value,
])],
'email' => ['required', 'email', 'max:190'],
'salutation_key' => ['nullable', 'string', 'max:20'],
'first_name' => ['nullable', 'string', 'max:80'],
'last_name' => ['nullable', 'string', 'max:80'],
];
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Responses;
use App\Models\User;
use App\Services\Auth\LoginCompletion;
use App\Support\LoginRedirect;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;
/**
* Abschluss eines Passkey-Logins (Entscheidung 5, 07.10.2026): wie
* RoleAwareLoginResponse den Login protokollieren und nur auf erlaubte Ziele
* weiterleiten. Aktiv- und Verifizierungsstatus prüft bereits
* Passkeys::authorizeLoginUsing() vor der Anmeldung (FortifyServiceProvider).
* Der Browser-Client erwartet JSON mit `redirect`.
*/
class PasskeyLoginResponse implements PasskeyLoginResponseContract
{
public function toResponse($request): JsonResponse|RedirectResponse
{
/** @var User $user */
$user = $request->user();
app(LoginCompletion::class)->recordLogin($user, $request);
$target = LoginRedirect::safeTarget(
$user,
$request->session()->pull('url.intended'),
LoginRedirect::homeFor($user),
);
return $request->wantsJson()
? new JsonResponse(['redirect' => $target])
: redirect($target);
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Listeners;
use App\Services\Auth\LoginIpThrottle;
use Illuminate\Auth\Events\Failed;
/**
* Zählt jede fehlgeschlagene Web-Anmeldung für die IP-weite Obergrenze
* (LoginIpThrottle) — gleich, ob über die Livewire-Anmeldung oder Fortifys
* `POST /login`.
*/
class CountFailedLoginsPerIp
{
public function __construct(private readonly LoginIpThrottle $throttle) {}
public function handle(Failed $event): void
{
if ($event->guard !== 'web' || ! app()->bound('request')) {
return;
}
$this->throttle->recordFailure(request()->ip());
}
}

View file

@ -248,7 +248,7 @@ class ProcessStripeWebhook
return; return;
} }
// Stufe 5 der Konversionsleiter: die erste bezahlte Rechnung eines // Stufe 7 der Konversionsleiter: die erste bezahlte Rechnung eines
// Kontos. Deckt Abos und alle über Rechnung abgewickelten Zahlungen ab; // Kontos. Deckt Abos und alle über Rechnung abgewickelten Zahlungen ab;
// Einmalkäufe melden zusätzlich in fulfillSinglePurchase(). Die Sperre // Einmalkäufe melden zusätzlich in fulfillSinglePurchase(). Die Sperre
// je Firma sorgt dafür, dass trotzdem nur die erste Buchung zählt. // je Firma sorgt dafür, dass trotzdem nur die erste Buchung zählt.
@ -469,7 +469,7 @@ class ProcessStripeWebhook
if ($result['marked_paid']) { if ($result['marked_paid']) {
Log::channel('billing')->info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]); Log::channel('billing')->info('Einmalkauf als bezahlt markiert.', ['single_purchase_id' => $purchase->id]);
// Stufe 5 der Konversionsleiter. recordFirstBooking() zählt je // Stufe 7 der Konversionsleiter. recordFirstBooking() zählt je
// Firma nur den ersten Treffer, deshalb dürfen beide Zahlungspfade // Firma nur den ersten Treffer, deshalb dürfen beide Zahlungspfade
// melden, ohne sich gegenseitig zu verfälschen. // melden, ohne sich gegenseitig zu verfälschen.
$this->conversions->recordFirstBooking($purchase->user); $this->conversions->recordFirstBooking($purchase->user);

View file

@ -69,7 +69,7 @@ class RecordAuthActivity
meta: ['way' => 'form'], meta: ['way' => 'form'],
); );
// Stufe 4 der Konversionsleiter. Ohne Firmenbezug — beim Anlegen des // Stufe 6 der Konversionsleiter. Ohne Firmenbezug — beim Anlegen des
// Kontos steht noch keine fest; die Zuordnung entsteht erst, wenn ein // Kontos steht noch keine fest; die Zuordnung entsteht erst, wenn ein
// Newsroom beansprucht wird. // Newsroom beansprucht wird.
$this->recorder->record(ConversionEventType::AccountCreated, ConversionSource::Organic); $this->recorder->record(ConversionEventType::AccountCreated, ConversionSource::Organic);

View file

@ -0,0 +1,42 @@
<?php
namespace App\Listeners;
use App\Enums\SecurityChange;
use App\Models\MagicLink;
use App\Models\User;
use App\Services\Auth\SecurityNotifier;
use App\Services\Auth\SessionRevocation;
use Illuminate\Auth\Events\PasswordReset;
/**
* Passwort-Reset als Kontowiederherstellung (Sicherheitsaudit 07.10.2026,
* S-04): Alle offenen Browser-Sessions enden, noch nicht eingelöste
* Anmeldelinks verfallen. Den Remember-Token rotiert die Reset-Komponente
* selbst. API-Tokens bleiben bestehen (Entscheidung 1) — der Hinweis an die
* Kontoadresse weist darauf hin.
*/
class RevokeAccessAfterPasswordReset
{
public function __construct(
private readonly SessionRevocation $sessions,
private readonly SecurityNotifier $notifier,
) {}
public function handle(PasswordReset $event): void
{
if (! $event->user instanceof User) {
return;
}
$this->sessions->revokeAllFor($event->user);
MagicLink::query()
->where('user_id', $event->user->getKey())
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
->whereNull('consumed_at')
->update(['consumed_at' => now()]);
$this->notifier->notify($event->user, SecurityChange::PasswordReset);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Listeners;
use App\Enums\SecurityChange;
use App\Models\User;
use App\Services\Auth\SecurityConfirmation;
use App\Services\Auth\SecurityNotifier;
use App\Services\Auth\StrongAuthentication;
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
use Laravel\Passkeys\Events\PasskeyDeleted;
use Laravel\Passkeys\Events\PasskeyRegistered;
use Laravel\Passkeys\Events\PasskeyVerified;
/**
* Verbindet Passkeys und Authenticator-Codes mit den eigenen Schutzschichten
* (Entscheidung 5, 07.10.2026):
* - Ein geprüfter Passkey (Login oder Bestätigung) oder ein gültiger Code
* in der 2FA-Challenge macht die Session „stark“ (Admin-Bereich).
* - Ein geprüfter Passkey gilt zugleich als frischer Identitätsnachweis
* für Sicherheitsänderungen (S-06).
* - Hinzugefügte und entfernte Passkeys werden an die Kontoadresse gemeldet.
*/
class TrackSecondFactorEvents
{
public function __construct(
private readonly StrongAuthentication $strong,
private readonly SecurityConfirmation $confirmation,
private readonly SecurityNotifier $notifier,
) {}
public function handlePasskeyVerified(PasskeyVerified $event): void
{
if (! $event->user instanceof User) {
return;
}
$this->strong->mark($event->user, 'passkey');
$this->confirmation->markConfirmed($event->user);
// Login per Passkey: Anmeldeweg für das Aktivitätsprotokoll.
if (! auth()->guard('web')->check()) {
request()->attributes->set(RecordAuthActivity::AUTH_METHOD_ATTRIBUTE, 'passkey');
}
}
public function handleValidTwoFactorCode(ValidTwoFactorAuthenticationCodeProvided $event): void
{
$this->strong->mark($event->user, 'totp');
}
public function handlePasskeyRegistered(PasskeyRegistered $event): void
{
if (! $event->user instanceof User) {
return;
}
// Die Registrierung verlangt Benutzerverifizierung am Gerät — die
// Session ist damit ebenso stark bestätigt wie durch einen Login.
$this->strong->mark($event->user, 'passkey');
$this->notifier->notify($event->user, SecurityChange::PasskeyAdded);
}
public function handlePasskeyDeleted(PasskeyDeleted $event): void
{
if ($event->user instanceof User) {
$this->notifier->notify($event->user, SecurityChange::PasskeyRemoved);
}
}
}

View file

@ -0,0 +1,142 @@
<?php
namespace App\Livewire\Concerns;
use App\Models\User;
use App\Services\Auth\SecurityConfirmation;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\TwoFactorAuthenticationProvider;
/**
* Erneuter Nachweis vor sicherheitskritischen Aktionen (Audit F03, E3;
* Sicherheitsaudit 07.10.2026, S-06). Je nach Kontoart:
* - 2FA aktiv → aktueller App-Code oder Wiederherstellungs-Code,
* - Passwort gesetzt → Passwort,
* - sonst → Einmal-Code an die aktuelle Kontoadresse.
*
* Nach erfolgreichem Nachweis gilt die Session eine Weile als bestätigt
* (SecurityConfirmation). Für das Abschalten der 2FA und neue
* Wiederherstellungs-Codes verlangt assertSecondFactor() dagegen immer einen
* aktuellen zweiten Faktor. Das Formular dazu: `partials.security-proof`.
*/
trait ConfirmsIdentity
{
public string $securityCode = '';
public string $securityPassword = '';
public string $securityEmailCode = '';
public bool $securityEmailCodeSent = false;
public function requestSecurityEmailCode(): void
{
/** @var User $user */
$user = auth()->user();
if (! app(SecurityConfirmation::class)->sendEmailCode($user)) {
throw ValidationException::withMessages([
'securityEmailCode' => __('Zu viele Codes angefordert. Bitte warten Sie einige Minuten.'),
]);
}
$this->securityEmailCodeSent = true;
}
/**
* Art des Nachweises für die Anzeige: fresh, two_factor, password oder email_code.
*/
public function securityProofKind(User $user): string
{
return match (true) {
app(SecurityConfirmation::class)->isFresh($user) => 'fresh',
$user->hasEnabledTwoFactorAuthentication() => 'two_factor',
(string) $user->password !== '' => 'password',
default => 'email_code',
};
}
protected function assertSecurityProof(User $user): void
{
$confirmation = app(SecurityConfirmation::class);
if ($confirmation->isFresh($user)) {
return;
}
if ($user->hasEnabledTwoFactorAuthentication()) {
$this->assertSecondFactor($user);
} elseif ((string) $user->password !== '') {
try {
$this->validate(['securityPassword' => ['required', 'string', 'current_password']]);
} finally {
$this->reset('securityPassword');
}
} else {
$this->assertEmailCode($user);
}
$confirmation->markConfirmed($user);
}
/**
* Prüft `securityCode` als TOTP (Fortify-Provider, replay-geschützt) oder
* als Wiederherstellungs-Code (wird dabei verbraucht). Fünf Fehlversuche
* pro Minute, damit eine Session die sechs Stellen nicht durchprobiert.
*/
protected function assertSecondFactor(User $user): void
{
$this->validate(['securityCode' => ['required', 'string']]);
$code = trim($this->securityCode);
$this->reset('securityCode');
$throttleKey = 'security-proof:'.$user->getKey();
if (RateLimiter::tooManyAttempts($throttleKey, 5)) {
throw ValidationException::withMessages([
'securityCode' => __('Zu viele Fehlversuche. Bitte warten Sie eine Minute.'),
]);
}
$secret = decrypt($user->two_factor_secret);
if (app(TwoFactorAuthenticationProvider::class)->verify($secret, $code)) {
RateLimiter::clear($throttleKey);
return;
}
if (in_array($code, $user->recoveryCodes() ?? [], true)) {
$user->replaceRecoveryCode($code);
RateLimiter::clear($throttleKey);
return;
}
RateLimiter::hit($throttleKey, 60);
throw ValidationException::withMessages([
'securityCode' => __('Der Code ist ungültig. Geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.'),
]);
}
private function assertEmailCode(User $user): void
{
$this->validate(['securityEmailCode' => ['required', 'string']], [
'securityEmailCode.required' => __('Bitte fordern Sie einen Bestätigungscode per E-Mail an und geben Sie ihn ein.'),
]);
$code = $this->securityEmailCode;
$this->reset('securityEmailCode');
if (! app(SecurityConfirmation::class)->verifyEmailCode($user, $code)) {
throw ValidationException::withMessages([
'securityEmailCode' => __('Der Code ist ungültig oder abgelaufen. Bitte fordern Sie einen neuen Code an.'),
]);
}
$this->securityEmailCodeSent = false;
}
}

View file

@ -116,7 +116,7 @@ trait InteractsWithAiDeclaration
protected function validateAiDeclaration(): void protected function validateAiDeclaration(): void
{ {
$rules = [ $rules = [
'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())], 'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::declarableValues())],
'aiDeclarationConfirmed' => ['accepted'], 'aiDeclarationConfirmed' => ['accepted'],
]; ];
$messages = [ $messages = [

View file

@ -7,6 +7,7 @@ use App\Enums\EmailNotificationType;
use App\Mail\Concerns\Unsubscribable; use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\User; use App\Models\User;
use App\Services\Auth\MailAccessLink;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Content;
@ -35,6 +36,16 @@ class PressReleaseHeldForReview extends Mailable
public int $maxAttempts; public int $maxAttempts;
/**
* Empfänger ohne Konto (API-Meldung, Roadmap 6.17): Adresse dieser Mail
* und Link, der einen Anmeldelink an sie schickt. Der Link zum Kundenbereich
* führte sonst auf eine Anmeldeseite für ein Konto, das der Empfänger
* nicht kennt.
*/
public ?string $accessEmail = null;
public ?string $accessUrl = null;
public function __construct( public function __construct(
public readonly User $user, public readonly User $user,
public readonly PressRelease $pressRelease, public readonly PressRelease $pressRelease,
@ -55,6 +66,8 @@ class PressReleaseHeldForReview extends Mailable
]); ]);
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) { if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
$this->accessEmail = trim($recipientEmail);
$this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailReview);
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected); $this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
} else { } else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected); $this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);

View file

@ -7,6 +7,7 @@ use App\Enums\EmailNotificationType;
use App\Mail\Concerns\Unsubscribable; use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\User; use App\Models\User;
use App\Services\Auth\MailAccessLink;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Content;
@ -39,6 +40,16 @@ class PressReleasePublished extends Mailable
*/ */
public ?string $manageNewsroomUrl = null; public ?string $manageNewsroomUrl = null;
/**
* Empfänger ohne Konto (API-Meldung an Pressekontakt oder Firmen-E-Mail,
* Roadmap 6.17): Adresse, an die diese Mail geht, und der Link, der mit
* einem Klick einen Anmeldelink an genau diese Adresse schickt. Null für
* Kunden, die selbst eingereicht haben — die kennen ihr Konto.
*/
public ?string $accessEmail = null;
public ?string $accessUrl = null;
public function __construct( public function __construct(
public readonly User $user, public readonly User $user,
public readonly PressRelease $pressRelease, public readonly PressRelease $pressRelease,
@ -72,6 +83,8 @@ class PressReleasePublished extends Mailable
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet // Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers. // der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) { if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
$this->accessEmail = trim($recipientEmail);
$this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailPublish);
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleasePublished); $this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleasePublished);
} else { } else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished); $this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);

View file

@ -8,6 +8,7 @@ use App\Enums\Portal;
use App\Mail\Concerns\Unsubscribable; use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\User; use App\Models\User;
use App\Services\Auth\MailAccessLink;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Content;
@ -29,6 +30,16 @@ class PressReleaseRejected extends Mailable
*/ */
public string $recipientName; public string $recipientName;
/**
* Empfänger ohne Konto (API-Meldung, Roadmap 6.17/6.18): Adresse dieser
* Mail und Link, der einen Anmeldelink an sie schickt. Nach der Anmeldung
* kann der Pressekontakt die Meldung überarbeiten und kostenlos erneut
* einreichen (Entscheidung 14.10.2026).
*/
public ?string $accessEmail = null;
public ?string $accessUrl = null;
public function __construct( public function __construct(
public readonly User $user, public readonly User $user,
public readonly PressRelease $pressRelease, public readonly PressRelease $pressRelease,
@ -53,6 +64,8 @@ class PressReleaseRejected extends Mailable
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet // Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers. // der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) { if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
$this->accessEmail = trim($recipientEmail);
$this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailReject);
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected); $this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
} else { } else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected); $this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);

View file

@ -0,0 +1,54 @@
<?php
namespace App\Mail;
use App\Enums\SecurityChange;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
/**
* Hinweis an die (bisherige) Kontoadresse nach einer sicherheitsrelevanten
* Änderung (Sicherheitsaudit 07.10.2026, S-06). Enthält bewusst keinen Link
* mit Anmeldewirkung und keine Codes — nur Art und Zeitpunkt der Änderung.
*/
class SecurityChangeNotice extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public SecurityChange $change,
public string $occurredAt,
public ?string $newEmail = null,
) {}
public function headers(): Headers
{
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Sicherheitshinweis zu Ihrem pressekonto'
);
}
public function content(): Content
{
return new Content(
view: 'emails.auth.security-change-notice'
);
}
public function attachments(): array
{
return [];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
/**
* Einmal-Code für den Identitätsnachweis von Konten ohne Passwort und ohne
* 2FA (Sicherheitsaudit 07.10.2026, S-06).
*/
class SecurityConfirmationCode extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $code,
public string $expiresAt,
) {}
public function headers(): Headers
{
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Ihr Bestätigungscode für pressekonto'
);
}
public function content(): Content
{
return new Content(
view: 'emails.auth.security-confirmation-code'
);
}
public function attachments(): array
{
return [];
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Queue\SerializesModels;
/**
* Bestätigungslink für die Erstverknüpfung eines Google-Kontos mit einem
* bestehenden Konto (Sicherheitsaudit 07.10.2026, S-05).
*/
class SocialAccountLinkConfirmation extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $confirmUrl,
public string $expiresAt,
) {}
public function headers(): Headers
{
return new Headers(text: [(string) config('bounces.reference_header', 'X-Pk-Ref') => 'user:'.$this->user->id]);
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Google-Anmeldung für Ihr pressekonto bestätigen'
);
}
public function content(): Content
{
return new Content(
view: 'emails.auth.social-account-link-confirmation'
);
}
public function attachments(): array
{
return [];
}
}

View file

@ -3,6 +3,8 @@
namespace App\Models; namespace App\Models;
use App\Enums\ActivityType; use App\Enums\ActivityType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphTo;
@ -13,6 +15,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
*/ */
class ActivityEvent extends Model class ActivityEvent extends Model
{ {
use MassPrunable;
public $timestamps = false; public $timestamps = false;
protected $fillable = [ protected $fillable = [
@ -50,4 +54,14 @@ class ActivityEvent extends Model
{ {
return $this->morphTo(); return $this->morphTo();
} }
/**
* Einträge nach der Löschfrist (config monitoring.retention, 395 Tage).
*
* @return Builder<static>
*/
public function prunable(): Builder
{
return static::query()->where('occurred_at', '<', now()->subDays((int) config('monitoring.retention.activity_events_days')));
}
} }

View file

@ -2,12 +2,16 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Sanctum\PersonalAccessToken; use Laravel\Sanctum\PersonalAccessToken;
class ApiUsageLog extends Model class ApiUsageLog extends Model
{ {
use MassPrunable;
public $timestamps = false; public $timestamps = false;
protected $fillable = [ protected $fillable = [
@ -45,4 +49,14 @@ class ApiUsageLog extends Model
{ {
return $this->belongsTo(PersonalAccessToken::class); return $this->belongsTo(PersonalAccessToken::class);
} }
/**
* Zugriffe nach der Löschfrist (config monitoring.retention, 90 Tage).
*
* @return Builder<static>
*/
public function prunable(): Builder
{
return static::query()->where('requested_at', '<', now()->subDays((int) config('monitoring.retention.api_usage_logs_days')));
}
} }

View file

@ -0,0 +1,71 @@
<?php
namespace App\Models;
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use Database\Factories\ChangelogEntryFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Ein Changelog-Eintrag (Decision-Update Changelog): Entwurf aus dem Deploy,
* öffentlich erst nach Freigabe.
*/
class ChangelogEntry extends Model
{
/** @use HasFactory<ChangelogEntryFactory> */
use HasFactory;
protected $fillable = [
'released_on',
'release_tag',
'category',
'text',
'status',
'source_key',
'commit_subject',
'review_note',
'article_worthy',
'published_at',
'reviewed_by_user_id',
];
protected function casts(): array
{
return [
'released_on' => 'date',
'category' => ChangelogCategory::class,
'status' => ChangelogStatus::class,
'article_worthy' => 'boolean',
'published_at' => 'datetime',
];
}
/**
* @return BelongsTo<User, $this>
*/
public function reviewer(): BelongsTo
{
return $this->belongsTo(User::class, 'reviewed_by_user_id');
}
/**
* @param Builder<ChangelogEntry> $query
*/
public function scopePublished(Builder $query): void
{
$query->where('status', ChangelogStatus::Published->value);
}
/**
* Öffentlicher Text: Sicherheit immer nur als Sammelzeile (E5), egal
* was im Entwurf steht.
*/
public function publicText(): string
{
return $this->category === ChangelogCategory::Security ? ChangelogCategory::SECURITY_LINE : $this->text;
}
}

View file

@ -161,6 +161,54 @@ class Company extends Model
return $this->gone_at !== null; return $this->gone_at !== null;
} }
/**
* Öffentliche Newsroom-Seiten dieser Firma, je Portal — nur dort, wo sie
* schon erscheint: Der Newsroom ist öffentlich ab der ersten sichtbaren
* Meldung in dieser Sprache (Roadmap 1b.1). Deutsch hat Vorrang, sonst
* Englisch. Für die Verknüpfung „Firma ↔ Newsroom“ im Kunden- und
* Adminbereich.
*
* @return list<array{portal: Portal, label: string, url: string}>
*/
public function publicNewsroomLinks(): array
{
if (! $this->slug || ! $this->is_active || $this->isGone()) {
return [];
}
$portals = $this->portal === Portal::Both ? [Portal::Presseecho, Portal::Businessportal24] : [$this->portal];
$links = [];
foreach ($portals as $portal) {
$languages = PressRelease::query()
->withoutGlobalScope(PortalScope::class)
->where('company_id', $this->getKey())
->whereIn('portal', [$portal->value, Portal::Both->value])
->visibleInArchive()
->whereIn('language', ['de', 'en'])
->distinct()
->pluck('language')
->all();
if ($languages === []) {
continue;
}
$links[] = [
'portal' => $portal,
'label' => $portal->label(),
'url' => sprintf(
'%s/%s/newsroom/%s',
rtrim((string) ($portal === Portal::Presseecho ? config('domains.domain_presseecho_url') : config('domains.domain_businessportal_url')), '/'),
in_array('de', $languages, true) ? 'de' : 'en',
$this->slug,
),
];
}
return $links;
}
/** /**
* Trägt eine fremde, lebende Firma auf einem ANDEREN Portal diesen Slug? * Trägt eine fremde, lebende Firma auf einem ANDEREN Portal diesen Slug?
* Dann darf diese Firma nicht `both` werden: Die öffentliche Auflösung * Dann darf diese Firma nicht `both` werden: Die öffentliche Auflösung

View file

@ -7,6 +7,7 @@ use App\Enums\ContactMessageTopic;
use Database\Factories\ContactMessageFactory; use Database\Factories\ContactMessageFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -21,6 +22,11 @@ class ContactMessage extends Model
/** @use HasFactory<ContactMessageFactory> */ /** @use HasFactory<ContactMessageFactory> */
use HasFactory; use HasFactory;
use MassPrunable;
/** Tage, die Spam zur Nachprüfung in der Quarantäne bleibt. */
public const SPAM_RETENTION_DAYS = 30;
protected $fillable = [ protected $fillable = [
'portal', 'portal',
'topic', 'topic',
@ -47,6 +53,87 @@ class ContactMessage extends Model
]; ];
} }
/**
* Spam-Quarantäne nach Ablauf der Prüffrist löschen (model:prune).
*
* @return Builder<ContactMessage>
*/
public function prunable(): Builder
{
return self::query()
->where('status', ContactMessageStatus::Spam->value)
->where('created_at', '<', now()->subDays(self::SPAM_RETENTION_DAYS));
}
public function isSpam(): bool
{
return $this->status === ContactMessageStatus::Spam;
}
/**
* In die Spam-Quarantäne legen (Admin, Sammelaktion oder Altbestand).
* Ein vorhandener Filtergrund bleibt erhalten.
*/
public function markAsSpam(?User $by, string $reason = 'manual'): void
{
$this->update([
'status' => ContactMessageStatus::Spam->value,
'payload' => [...($this->payload ?? []), 'spam_reason' => $this->payload['spam_reason'] ?? $reason],
'resolved_by_user_id' => $by?->id,
'resolved_at' => now(),
]);
}
/**
* Fehlalarm: zurück in die offene Queue. Gibt zurück, ob die Team-Mail
* noch aussteht: Hat der Filter die Anfrage beim Eingang abgefangen, ging
* keine raus — beim ersten Zurückholen wird sie nachgeholt. Von Hand
* markierter Spam hatte seine Mail schon.
*/
public function releaseFromSpam(): bool
{
$payload = $this->payload ?? [];
$mailPending = isset($payload['spam_reason'])
&& $payload['spam_reason'] !== 'manual'
&& ! isset($payload['released_from_spam_at']);
$this->update([
'status' => ContactMessageStatus::Open->value,
'payload' => [...$payload, 'released_from_spam_at' => now()->toIso8601String()],
'resolved_by_user_id' => null,
'resolved_at' => null,
]);
return $mailPending;
}
/**
* Grund der Quarantäne für die Admin-Ansicht (ContactSpamFilter).
*/
public function spamReasonLabel(): ?string
{
$reason = $this->payload['spam_reason'] ?? null;
return match ($reason) {
null => null,
'link' => 'Link im Text',
'page_title' => 'Floskel mit Seitentitel',
'gibberish' => 'Zeichensalat',
'blacklist' => 'Begriff der Sperrliste',
'too_fast' => 'Zu schnell abgesendet',
'manual' => 'Von Hand markiert',
default => (string) $reason,
};
}
/**
* Angenommen, obwohl Cloudflare Turnstile nicht erreichbar war.
*/
public function skippedBotCheck(): bool
{
return ($this->payload['bot_check'] ?? null) === 'unavailable';
}
/** /**
* Öffentliche Vorgangsnummer — analog zu Legal-Requests ("M-{id}"). * Öffentliche Vorgangsnummer — analog zu Legal-Requests ("M-{id}").
*/ */

View file

@ -13,6 +13,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* hält fest, welcher User wann welchen KI-Grad für Text und Bilder deklariert * hält fest, welcher User wann welchen KI-Grad für Text und Bilder deklariert
* und bestätigt hat. Einträge werden nie aktualisiert oder gelöscht — sie * und bestätigt hat. Einträge werden nie aktualisiert oder gelöscht — sie
* sind der belastbare Nachweis gegenüber Art. 50 Abs. 4 KI-VO. * sind der belastbare Nachweis gegenüber Art. 50 Abs. 4 KI-VO.
*
* „Keine Angabe“ über die Alt-Schnittstelle (`not_declared`, Rechtspaket
* 10/2026) wird ebenfalls festgehalten, dann ohne `confirmed_at`: Es wurde
* nichts erklärt, also auch nichts bestätigt.
*/ */
class PressReleaseAiDeclaration extends Model class PressReleaseAiDeclaration extends Model
{ {

View file

@ -19,14 +19,16 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Cashier\Billable; use Laravel\Cashier\Billable;
use Laravel\Fortify\Contracts\PasskeyUser;
use Laravel\Fortify\PasskeyAuthenticatable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements MustVerifyEmail class User extends Authenticatable implements MustVerifyEmail, PasskeyUser
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes, TwoFactorAuthenticatable; use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, SoftDeletes, TwoFactorAuthenticatable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -85,6 +87,7 @@ class User extends Authenticatable implements MustVerifyEmail
'deleted_at' => 'datetime', 'deleted_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'press_release_quota_used_this_month' => 'integer', 'press_release_quota_used_this_month' => 'integer',
'sessions_revoked_at_ms' => 'integer',
]; ];
} }

View file

@ -3,6 +3,7 @@
namespace App\Observers; namespace App\Observers;
use App\Enums\ActivityType; use App\Enums\ActivityType;
use App\Enums\ContactMessageStatus;
use App\Models\ContactMessage; use App\Models\ContactMessage;
use App\Models\LegalRequest; use App\Models\LegalRequest;
use App\Models\NewsletterSubscription; use App\Models\NewsletterSubscription;
@ -23,6 +24,8 @@ class ActivityLogObserver
public function created(Model $model): void public function created(Model $model): void
{ {
match (true) { match (true) {
// Spam-Quarantäne gehört nicht in den Verlauf.
$model instanceof ContactMessage && $model->status === ContactMessageStatus::Spam => null,
$model instanceof ContactMessage => $this->activityLog->record( $model instanceof ContactMessage => $this->activityLog->record(
ActivityType::ContactRequest, ActivityType::ContactRequest,
sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email), sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email),

View file

@ -0,0 +1,105 @@
<?php
namespace App\Observers;
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use App\Support\EmailQuery;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
/**
* Bewertet automatisch vergebene Firmenrechte neu, sobald sich ihre
* Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03): Kontakt- oder
* Firmenadresse geändert, Kontakt in eine andere Firma verschoben, gelöscht
* oder wiederhergestellt, Login-Adresse eines Users geändert.
*
* Bisher geschah das nur beim Login — ein aktiver Nutzer behielt entzogene
* Rechte so lange, wie seine Session lief. Am Model statt in den
* Formularen: Kunden-Newsroom, Admin-CRM, API und Importer schreiben dieselben
* Felder. Läuft synchron in derselben Transaktion; ein Rollback nimmt die
* Rechteänderung mit zurück. Massen-Updates per Query-Builder lösen keine
* Events aus — dafür `php artisan memberships:resync-auto`.
*/
class CompanyMembershipObserver
{
public function __construct(private readonly ContactAccessService $contactAccess) {}
public function updated(Model $model): void
{
if ($model instanceof User) {
if ($model->wasChanged('email')) {
$this->contactAccess->syncMembershipsForEmail($model);
}
return;
}
$relevant = $model instanceof Contact ? ['email', 'company_id'] : ['email'];
if (! $model->wasChanged($relevant)) {
return;
}
$this->resync(
emails: [$model->getOriginal('email'), $model->getAttribute('email')],
companyIds: $this->companyIdsOf($model, withOriginal: true),
);
}
public function deleted(Model $model): void
{
if ($model instanceof User) {
return;
}
$this->resync([$model->getAttribute('email')], $this->companyIdsOf($model));
}
public function restored(Model $model): void
{
$this->deleted($model);
}
/**
* @return list<int>
*/
private function companyIdsOf(Company|Contact|Model $model, bool $withOriginal = false): array
{
$ids = $model instanceof Contact
? [$model->getAttribute('company_id'), $withOriginal ? $model->getOriginal('company_id') : null]
: [$model->getKey()];
return array_values(array_unique(array_map('intval', array_filter($ids))));
}
/**
* Alle User, deren automatische Rechte an den betroffenen Firmen hängen,
* sowie alle User mit einer der betroffenen Adressen.
*
* @param list<string|null> $emails
* @param list<int> $companyIds
*/
private function resync(array $emails, array $companyIds): void
{
$userIds = $companyIds === [] ? collect() : DB::table('company_user')
->whereIn('company_id', $companyIds)
->whereIn('granted_via', ['auto', 'auto-upgrade'])
->pluck('user_id');
/** @var Collection<int, User> $users */
$users = User::query()->whereIn('id', $userIds->all())->get();
foreach (array_unique(array_filter(array_map(
fn (?string $email): string => mb_strtolower(trim((string) $email)),
$emails,
))) as $email) {
$users = $users->merge(EmailQuery::whereEmail(User::query(), $email)->get());
}
$users->unique('id')->each(fn (User $user) => $this->contactAccess->syncMembershipsForEmail($user));
}
}

View file

@ -2,9 +2,16 @@
namespace App\Policies; namespace App\Policies;
use App\Authorization\Permission;
use App\Models\LegacyInvoice; use App\Models\LegacyInvoice;
use App\Models\User; use App\Models\User;
/**
* Alt-Rechnungen aus dem Legacy-Import. Kunden sehen ihre eigenen Belege,
* fremde nur mit `billing:manage` — der Panel-Zugang (Editor) allein reicht
* nicht (Sicherheitsaudit 07.10.2026, S-01). Gilt für die Kunden- und die
* Admin-Downloadroute gleichermaßen.
*/
class LegacyInvoicePolicy class LegacyInvoicePolicy
{ {
public function before(User $user): ?bool public function before(User $user): ?bool
@ -19,7 +26,7 @@ class LegacyInvoicePolicy
public function view(User $user, LegacyInvoice $legacyInvoice): bool public function view(User $user, LegacyInvoice $legacyInvoice): bool
{ {
if ($user->canAccessAdmin()) { if ($user->can(Permission::BillingManage)) {
return true; return true;
} }

View file

@ -6,6 +6,7 @@ use App\Contracts\NewsletterSyncClient;
use App\Exceptions\MailDeliveryPausedException; use App\Exceptions\MailDeliveryPausedException;
use App\Helpers\ThemeHelper; use App\Helpers\ThemeHelper;
use App\Http\Middleware\BlockAdminDuringImpersonation; use App\Http\Middleware\BlockAdminDuringImpersonation;
use App\Http\Middleware\EnsureStrongAdminAuthentication;
use App\Http\Middleware\EnsureUserIsAdmin; use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\EnsureUserIsCustomer; use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests; use App\Http\Middleware\LogSlowAdminRequests;
@ -31,6 +32,7 @@ use App\Models\User;
use App\Models\WithdrawalRequest; use App\Models\WithdrawalRequest;
use App\Observers\ActivityLogObserver; use App\Observers\ActivityLogObserver;
use App\Observers\AdminPerformanceCacheObserver; use App\Observers\AdminPerformanceCacheObserver;
use App\Observers\CompanyMembershipObserver;
use App\Observers\CompanyPublicCacheObserver; use App\Observers\CompanyPublicCacheObserver;
use App\Observers\EmailDomainObserver; use App\Observers\EmailDomainObserver;
use App\Observers\ImageRightsConfirmationObserver; use App\Observers\ImageRightsConfirmationObserver;
@ -64,6 +66,7 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier; use Laravel\Cashier\Cashier;
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed; use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
use Livewire\Livewire; use Livewire\Livewire;
@ -106,6 +109,18 @@ class AppServiceProvider extends ServiceProvider
// Ausgelieferte Version in `php artisan about` (Datei RELEASE bzw. APP_VERSION). // Ausgelieferte Version in `php artisan about` (Datei RELEASE bzw. APP_VERSION).
AboutCommand::add('Pressekonto', fn (): array => ['Version' => config('app.version') ?? '—']); AboutCommand::add('Pressekonto', fn (): array => ['Version' => config('app.version') ?? '—']);
// Passwortregeln für neue und geänderte Passwörter (Sicherheitsaudit
// 07.10.2026, Phase 4). Bestehende kürzere Passwörter bleiben gültig.
// Der Leak-Abgleich fragt Have I Been Pwned per k-Anonymität ab (nur
// die ersten 5 Zeichen des SHA-1-Hashes verlassen den Server) und
// lässt das Passwort durch, wenn der Dienst nicht antwortet — daher
// nur in Produktion, Tests und lokale Entwicklung bleiben offline.
Password::defaults(function (): Password {
$rule = Password::min(12);
return $this->app->isProduction() ? $rule->uncompromised() : $rule;
});
// Die Generations-Nummer der Seiten-Caches wird prozessintern gemerkt. // Die Generations-Nummer der Seiten-Caches wird prozessintern gemerkt.
// Ein Queue-Worker lebt über beliebig viele Jobs hinweg und würde sonst // Ein Queue-Worker lebt über beliebig viele Jobs hinweg und würde sonst
// dauerhaft mit dem Stand seines ersten Zugriffs arbeiten — Keys einer // dauerhaft mit dem Stand seines ersten Zugriffs arbeiten — Keys einer
@ -243,6 +258,12 @@ class AppServiceProvider extends ServiceProvider
Company::observe(EmailDomainObserver::class); Company::observe(EmailDomainObserver::class);
Contact::observe(EmailDomainObserver::class); Contact::observe(EmailDomainObserver::class);
// Automatische Firmenrechte sofort neu bewerten, wenn sich ihre
// E-Mail-Grundlage ändert (Sicherheitsaudit 07.10.2026, S-03).
Company::observe(CompanyMembershipObserver::class);
Contact::observe(CompanyMembershipObserver::class);
User::observe(CompanyMembershipObserver::class);
// Öffentliche Seiten-Caches bei Löschung/Deaktivierung einer Firma // Öffentliche Seiten-Caches bei Löschung/Deaktivierung einer Firma
// verwerfen — auf jedem Löschweg, nicht nur im Admin-Formular (F13). // verwerfen — auf jedem Löschweg, nicht nur im Admin-Formular (F13).
Company::observe(CompanyPublicCacheObserver::class); Company::observe(CompanyPublicCacheObserver::class);
@ -266,6 +287,7 @@ class AppServiceProvider extends ServiceProvider
Livewire::addPersistentMiddleware([ Livewire::addPersistentMiddleware([
BlockAdminDuringImpersonation::class, BlockAdminDuringImpersonation::class,
EnsureUserIsAdmin::class, EnsureUserIsAdmin::class,
EnsureStrongAdminAuthentication::class,
EnsureUserIsCustomer::class, EnsureUserIsCustomer::class,
LogSlowAdminRequests::class, LogSlowAdminRequests::class,
]); ]);

View file

@ -6,7 +6,10 @@ use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword; use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword; use App\Actions\Fortify\UpdateUserPassword;
use App\Http\Responses\FailedTwoFactorChallengeResponse; use App\Http\Responses\FailedTwoFactorChallengeResponse;
use App\Http\Responses\PasskeyLoginResponse;
use App\Http\Responses\RoleAwareLoginResponse; use App\Http\Responses\RoleAwareLoginResponse;
use App\Models\User;
use App\Services\Auth\LoginIpThrottle;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@ -17,6 +20,10 @@ use Laravel\Fortify\Contracts\FailedTwoFactorLoginResponse;
use Laravel\Fortify\Contracts\LoginResponse; use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse; use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
use Laravel\Fortify\Fortify; use Laravel\Fortify\Fortify;
use Laravel\Passkeys\Contracts\PasskeyLoginResponse as PasskeyLoginResponseContract;
use Laravel\Passkeys\Contracts\PasskeyUser;
use Laravel\Passkeys\Passkey;
use Laravel\Passkeys\Passkeys;
class FortifyServiceProvider extends ServiceProvider class FortifyServiceProvider extends ServiceProvider
{ {
@ -30,6 +37,7 @@ class FortifyServiceProvider extends ServiceProvider
// Fortifys Default leitet auf die nicht registrierte Route // Fortifys Default leitet auf die nicht registrierte Route
// `two-factor.login` (views => false) – zurück zur Livewire-Challenge. // `two-factor.login` (views => false) – zurück zur Livewire-Challenge.
$this->app->singleton(FailedTwoFactorLoginResponse::class, FailedTwoFactorChallengeResponse::class); $this->app->singleton(FailedTwoFactorLoginResponse::class, FailedTwoFactorChallengeResponse::class);
$this->app->singleton(PasskeyLoginResponseContract::class, PasskeyLoginResponse::class);
} }
/** /**
@ -46,6 +54,20 @@ class FortifyServiceProvider extends ServiceProvider
// Keine View-Definitionen, die Auth-Seiten sind Livewire-Komponenten // Keine View-Definitionen, die Auth-Seiten sind Livewire-Komponenten
RateLimiter::for('login', function (Request $request) { RateLimiter::for('login', function (Request $request) {
// IP-weite Obergrenze über alle Konten (LoginIpThrottle) — der
// Zähler ist derselbe wie in der Livewire-Anmeldung.
$ipThrottle = app(LoginIpThrottle::class);
if ($ipThrottle->isBlocked($request->ip())) {
$seconds = $ipThrottle->availableIn($request->ip());
return $request->expectsJson()
? response()->json(['message' => __('auth.throttle', ['seconds' => $seconds, 'minutes' => ceil($seconds / 60)])], 429, ['Retry-After' => $seconds])
: back()->withInput($request->only(Fortify::username()))->withErrors([
Fortify::username() => __('auth.throttle', ['seconds' => $seconds, 'minutes' => ceil($seconds / 60)]),
]);
}
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey); return Limit::perMinute(5)->by($throttleKey);
@ -54,5 +76,15 @@ class FortifyServiceProvider extends ServiceProvider
RateLimiter::for('two-factor', function (Request $request) { RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id')); return Limit::perMinute(5)->by($request->session()->get('login.id'));
}); });
RateLimiter::for('passkeys', function (Request $request) {
return Limit::perMinute(10)->by($request->user()?->getAuthIdentifier() ?? $request->ip());
});
// Passkey-Login (Entscheidung 5, 07.10.2026): nur aktive, verifizierte
// Konten — wie die übrigen Anmeldewege.
Passkeys::authorizeLoginUsing(function (Request $request, PasskeyUser $user, Passkey $passkey): bool {
return $user instanceof User && $user->is_active && $user->hasVerifiedEmail();
});
} }
} }

View file

@ -21,7 +21,7 @@ use Illuminate\Contracts\Validation\ValidationRule;
class NoPromotionalText implements ValidationRule class NoPromotionalText implements ValidationRule
{ {
/** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */ /** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */
private const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu'; public const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu'; private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu';

View file

@ -3,6 +3,8 @@
namespace App\Services\Admin; namespace App\Services\Admin;
use App\Authorization\Permission as PermissionCatalog; use App\Authorization\Permission as PermissionCatalog;
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\ArchiveEntry; use App\Models\ArchiveEntry;
use App\Models\FailedLoginTally; use App\Models\FailedLoginTally;
use App\Models\PressReleaseAttachment; use App\Models\PressReleaseAttachment;
@ -11,6 +13,7 @@ use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive; use App\Services\Archive\EvidenceArchive;
use App\Services\Monitoring\HealthProbe; use App\Services\Monitoring\HealthProbe;
use App\Services\PressRelease\Images\PressReleaseImageRemover; use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\Spam\TurnstileVerifier;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
@ -238,6 +241,45 @@ class AdminSystemHealth
: 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'), : 'SESSION_SECURE_COOKIE ist aus'.($isLocal ? ' (lokal ok).' : ' — in https-Umgebungen aktivieren.'),
]; ];
// Imagick trägt Herkunftsprüfung und KI-Vorab-Check der Bilder. Fehlt
// die Erweiterung, scheitern beide still (Bilder werden durchgelassen)
// — so geschehen auf Live bis 08.10.2026.
$precheckEnabled = (bool) config('images.precheck.enabled');
$checks[] = [
'key' => 'imagick',
'label' => 'PHP-Erweiterung Imagick',
'status' => $this->imagickAvailable() ? self::StatusOk : ($precheckEnabled ? self::StatusCritical : self::StatusWarn),
'detail' => $this->imagickAvailable()
? 'Imagick ist geladen (Herkunftsprüfung und KI-Vorab-Check der Bilder).'
: 'Imagick fehlt — Herkunftsprüfung'.($precheckEnabled ? ' und KI-Vorab-Check' : '').' der Bilder laufen ins Leere. Auf dem Server php8.4-imagick installieren und PHP-FPM neu laden.',
];
// Spamschutz (Cloudflare Turnstile): Keys in Produktion gesetzt, und
// Formulare, die bei nicht erreichbarem Cloudflare durchgelassen und
// markiert wurden (Entscheidung 09.10.2026) — die gehören nachgeprüft.
$turnstileEnabled = TurnstileVerifier::enabled();
$checks[] = [
'key' => 'turnstile',
'label' => 'Spamschutz (Cloudflare Turnstile)',
'status' => ($turnstileEnabled || ! app()->isProduction()) ? self::StatusOk : self::StatusWarn,
'detail' => $turnstileEnabled
? 'Bot-Prüfung für Kontakt-Widget und Registrierung ist aktiv.'
: 'TURNSTILE_SITE_KEY/TURNSTILE_SECRET_KEY fehlen — Kontakt-Widget und Registrierung laufen ohne Bot-Prüfung.',
];
$unchecked = ActivityEvent::query()
->where('type', ActivityType::BotCheckUnavailable->value)
->where('occurred_at', '>=', now()->subDays(7))
->count();
$checks[] = [
'key' => 'bot_check_unavailable',
'label' => 'Formulare ohne Bot-Prüfung',
'status' => $unchecked > 0 ? self::StatusWarn : self::StatusOk,
'detail' => $unchecked > 0
? sprintf('%d Formular(e) in 7 Tagen angenommen, während Cloudflare nicht erreichbar war — im Verlauf (Typ „Ohne Bot-Prüfung“) nachprüfen.', $unchecked)
: 'In den letzten 7 Tagen wurde jedes Formular geprüft.',
];
// Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler // Herkunftsprüfung der Bild-Uploads (Bildkennzeichnung E.7): Fehler
// der letzten sieben Tage zählen; Zustand D im Formular blockiert // der letzten sieben Tage zählen; Zustand D im Formular blockiert
// nichts, muss aber sichtbar bleiben. // nichts, muss aber sichtbar bleiben.
@ -341,6 +383,11 @@ class AdminSystemHealth
return $checks; return $checks;
} }
protected function imagickAvailable(): bool
{
return class_exists(\Imagick::class);
}
/** /**
* @param array<string, mixed> $queue * @param array<string, mixed> $queue
*/ */

View file

@ -0,0 +1,166 @@
<?php
namespace App\Services\Analytics;
use Carbon\CarbonInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Nur lesender Client für die selbst gehostete Umami-Instanz (Roadmap 6.16).
*
* Anmeldung mit einem Umami-Benutzer der Rolle „View only“; das Token wird
* zwischengespeichert und bei einer 401 einmal erneuert. Die Websites hängen
* am Team, nicht am Benutzer — ihre IDs stehen je Marke in
* `config/domains.php`.
*
* Eigenheiten der Umami-v3-API: Seiten heißen `path` (nicht `url`), ein
* Filter `path=c.<Teil>` sucht nach „enthält“, Zeiten in Millisekunden.
*/
class UmamiClient
{
private const string TOKEN_CACHE_KEY = 'umami.api-token';
/** Marken mit eigener Umami-Website. */
public const array BRANDS = ['pressekonto', 'presseecho', 'businessportal24'];
public function isConfigured(): bool
{
return filled(config('services.umami.api_url'))
&& filled(config('services.umami.api_username'))
&& filled(config('services.umami.api_password'));
}
public function websiteId(string $brand): ?string
{
$id = config('domains.domains.'.$brand.'.brand.umami_website_id');
return is_string($id) && $id !== '' ? $id : null;
}
/**
* Kennzahlen eines Zeitraums, optional gefiltert (z. B. `['path' => 'c.preise']`).
*
* @param array<string, string> $filters
* @return array{pageviews: int, visitors: int, visits: int, bounces: int, totaltime: int}
*/
public function stats(string $websiteId, CarbonInterface $from, CarbonInterface $to, array $filters = []): array
{
$data = $this->get('/api/websites/'.$websiteId.'/stats', $this->range($from, $to) + $filters);
return [
'pageviews' => (int) ($data['pageviews'] ?? 0),
'visitors' => (int) ($data['visitors'] ?? 0),
'visits' => (int) ($data['visits'] ?? 0),
'bounces' => (int) ($data['bounces'] ?? 0),
'totaltime' => (int) ($data['totaltime'] ?? 0),
];
}
/**
* Rangliste einer Dimension: `path`, `referrer`, `channel`, `country`,
* `browser`, `os`, `device`, `event` …
*
* @param array<string, string> $filters
* @return list<array{x: string, y: int}>
*/
public function metrics(string $websiteId, string $type, CarbonInterface $from, CarbonInterface $to, array $filters = [], int $limit = 10): array
{
$data = $this->get('/api/websites/'.$websiteId.'/metrics', $this->range($from, $to) + $filters + [
'type' => $type,
'limit' => $limit,
]);
return array_values(array_map(
static fn (array $row): array => ['x' => (string) ($row['x'] ?? ''), 'y' => (int) ($row['y'] ?? 0)],
array_filter($data, 'is_array'),
));
}
/**
* @param array<string, scalar> $query
* @return array<mixed>
*/
private function get(string $path, array $query): array
{
$response = $this->send($path, $query, $this->token());
if ($response->status() === 401) {
Cache::forget(self::TOKEN_CACHE_KEY);
$response = $this->send($path, $query, $this->token());
}
if (! $response->successful()) {
throw new UmamiUnavailable(sprintf('Umami antwortet mit %d auf %s.', $response->status(), $path));
}
$data = $response->json();
return is_array($data) ? $data : [];
}
/**
* @param array<string, scalar> $query
*/
private function send(string $path, array $query, string $token): Response
{
try {
return Http::baseUrl($this->baseUrl())
->withToken($token)
->acceptJson()
->connectTimeout(5)
->timeout((int) config('services.umami.api_timeout_seconds', 15))
->get($path, $query);
} catch (ConnectionException $e) {
throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e);
}
}
private function token(): string
{
if (! $this->isConfigured()) {
throw new UmamiUnavailable('Umami-API nicht konfiguriert (UMAMI_API_URL, UMAMI_API_USERNAME, UMAMI_API_PASSWORD).');
}
return Cache::remember(self::TOKEN_CACHE_KEY, now()->addHours(12), function (): string {
try {
$response = Http::baseUrl($this->baseUrl())
->acceptJson()
->connectTimeout(5)
->timeout((int) config('services.umami.api_timeout_seconds', 15))
->post('/api/auth/login', [
'username' => (string) config('services.umami.api_username'),
'password' => (string) config('services.umami.api_password'),
]);
} catch (ConnectionException $e) {
throw new UmamiUnavailable('Umami nicht erreichbar.', previous: $e);
}
$token = $response->json('token');
if (! $response->successful() || ! is_string($token) || $token === '') {
throw new UmamiUnavailable(sprintf('Umami-Anmeldung abgelehnt (%d).', $response->status()));
}
return $token;
});
}
private function baseUrl(): string
{
return rtrim((string) config('services.umami.api_url'), '/');
}
/**
* @return array{startAt: int, endAt: int}
*/
private function range(CarbonInterface $from, CarbonInterface $to): array
{
return [
'startAt' => $from->getTimestampMs(),
'endAt' => $to->getTimestampMs(),
];
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Services\Analytics;
use RuntimeException;
/**
* Umami war nicht erreichbar oder hat die Anfrage abgelehnt. Auswertungen
* fangen das ab und zeigen „nicht verbunden“ statt abzubrechen.
*/
class UmamiUnavailable extends RuntimeException {}

View file

@ -5,14 +5,24 @@ namespace App\Services\Api;
use App\Models\User; use App\Models\User;
/** /**
* Token-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein * API-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein
* aktives Stripe-Abo (Cashier, STR-Kreis), eine laufende Zahlungsoption * aktives Stripe-Abo (Cashier, STR-Kreis), eine laufende Zahlungsoption
* (MAN-Kreis/Bestandsschutz) oder eine bezahlte letzte Legacy-Rechnung hat — * (MAN-Kreis/Bestandsschutz) oder eine bezahlte letzte Legacy-Rechnung hat —
* spiegelbildlich zu User::hasActiveBooking(). * spiegelbildlich zu User::hasActiveBooking(). Geprüft beim Erzeugen eines
* Tokens und bei jedem API-Aufruf.
*/ */
class ApiAccessEligibilityService class ApiAccessEligibilityService
{ {
public function canCreateToken(User $user): bool public function canCreateToken(User $user): bool
{
return $this->hasApiAccess($user);
}
/**
* Gilt beim Erzeugen eines Tokens und bei jedem API-Aufruf
* (EnsureApiAccessIsBooked).
*/
public function hasApiAccess(User $user): bool
{ {
return $this->denialReason($user) === null; return $this->denialReason($user) === null;
} }

View file

@ -119,13 +119,14 @@ class ContactAccessService
* (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der * (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der
* API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist * API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist
* die Freischaltung des Einlieferers, nicht die E-Mail. * die Freischaltung des Einlieferers, nicht die E-Mail.
*
* Neben Login und E-Mail-Bestätigung ruft auch der
* CompanyMembershipObserver diese Methode auf, sobald sich Kontakt- oder
* Firmenadressen bzw. Kontaktzuordnungen ändern — der Entzug greift damit
* in der laufenden Session (Sicherheitsaudit 07.10.2026, S-03).
*/ */
public function syncMembershipsForEmail(User $user): void public function syncMembershipsForEmail(User $user): void
{ {
if (! $user->is_active || ! $user->hasVerifiedEmail()) {
return;
}
$email = mb_strtolower(trim((string) $user->email)); $email = mb_strtolower(trim((string) $user->email));
if ($email === '') { if ($email === '') {
@ -136,9 +137,15 @@ class ContactAccessService
$companies = $this->companiesFor($email); $companies = $this->companiesFor($email);
$companyIds = $this->companyIdsFrom($contacts, $companies); $companyIds = $this->companyIdsFrom($contacts, $companies);
// Entzogen wird nur, was keine E-Mail-Grundlage mehr hat — unabhängig
// vom Kontostatus (S-03). Bestandskonten aus dem Altsystem haben kein
// `email_verified_at`; ihre noch begründeten Rechte bleiben. Eine
// erste Fassung entzog Unverifizierten pauschal alles und nahm beim
// Abgleich am 07.10.2026 so 19.598 begründete Rechte weg (v1.4.1).
$this->revokeStaleAutomaticGrants($user, $companyIds); $this->revokeStaleAutomaticGrants($user, $companyIds);
if ($companyIds->isEmpty()) { // Neue Rechte nur für aktive Konten mit nachgewiesener Adresse (F01).
if (! $user->is_active || ! $user->hasVerifiedEmail() || $companyIds->isEmpty()) {
return; return;
} }

View file

@ -0,0 +1,43 @@
<?php
namespace App\Services\Auth;
use Illuminate\Support\Facades\RateLimiter;
/**
* Obergrenze für fehlgeschlagene Anmeldungen je IP-Adresse über alle Konten
* hinweg (Sicherheitsaudit 07.10.2026, Phase 4). Das Limit je Adresse und IP
* (5 pro Minute) bremst kein Durchprobieren vieler fremder Adressen von einer
* Quelle — davon kamen laut Monitoring rund 250 Versuche am Tag. Gezählt
* wird über das `Failed`-Event, damit Livewire-Anmeldung und Fortifys
* `POST /login` denselben Zähler teilen. Erfolgreiche Anmeldungen setzen ihn
* bewusst nicht zurück — sonst reichte ein eigenes Konto zum Entsperren.
* Wirkt erst, seit gefälschte X-Forwarded-For-Header ignoriert werden
* (config/trustedproxy.php).
*/
class LoginIpThrottle
{
public const MAX_FAILURES = 20;
public const DECAY_SECONDS = 900;
public function recordFailure(?string $ip): void
{
RateLimiter::hit($this->key($ip), self::DECAY_SECONDS);
}
public function isBlocked(?string $ip): bool
{
return RateLimiter::tooManyAttempts($this->key($ip), self::MAX_FAILURES);
}
public function availableIn(?string $ip): int
{
return RateLimiter::availableIn($this->key($ip));
}
private function key(?string $ip): string
{
return 'login-failures-ip|'.($ip ?? 'unknown');
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Services\Auth;
use App\Enums\ConversionSource;
use App\Models\PressRelease;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\URL;
/**
* „Anmeldelink an diese Adresse“ aus einer Kundenmail (Roadmap 6.17).
*
* Veröffentlichungs-, Ablehnungs- und Prüfmails zu API-Meldungen gehen an den
* Pressekontakt oder die Firmen-E-Mail — Empfänger, die aus ihrer Sicht kein
* Konto haben. Der Link führt auf eine Seite, die mit einem Klick einen
* Anmeldelink an genau die Adresse schickt, an die die Mail ging, und nach
* der Anmeldung auf der Meldung landet.
*
* Der Link meldet niemanden an: Er löst nur einen Anmeldelink an eine
* Adresse aus, die ihn ohnehin bekommen könnte (`/anmeldelink`). Signiert und
* befristet, die Adresse verschlüsselt — sie steht nicht lesbar in der URL,
* in Logs oder in der Webanalyse. `src` und `pr` stehen zusätzlich im Klartext
* für die Klickmessung (RecordMailLinkClick).
*/
class MailAccessLink
{
public const int VALID_DAYS = 30;
public function url(string $email, PressRelease $pressRelease, ConversionSource $source): string
{
return URL::temporarySignedRoute('mail-access', now()->addDays(self::VALID_DAYS), [
't' => Crypt::encryptString((string) json_encode([
'e' => mb_strtolower(trim($email)),
'p' => $pressRelease->getKey(),
])),
'src' => $source->value,
'pr' => $pressRelease->getKey(),
]);
}
/**
* @return array{email: string, press_release_id: int}|null
*/
public function decode(?string $token): ?array
{
if (blank($token)) {
return null;
}
try {
$data = json_decode(Crypt::decryptString((string) $token), true);
} catch (DecryptException) {
return null;
}
if (! is_array($data) || ! is_string($data['e'] ?? null) || ! filter_var($data['e'], FILTER_VALIDATE_EMAIL) || (int) ($data['p'] ?? 0) <= 0) {
return null;
}
return ['email' => $data['e'], 'press_release_id' => (int) $data['p']];
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace App\Services\Auth;
use App\Enums\SecurityChange;
use App\Models\User;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Einziger Weg für den Passwortwechsel eines angemeldeten Kontos
* (Sicherheitsaudit 07.10.2026, S-04). Neben dem neuen Hash wird der
* Remember-Token rotiert und jede andere Browser-Session beendet — die
* aktuelle bleibt angemeldet. API-Tokens bleiben bewusst bestehen
* (Entscheidung 1: Integrationen sollen nicht unangekündigt brechen); der
* Hinweis an die Kontoadresse nennt sie.
*/
class PasswordChanger
{
public function __construct(
private readonly SessionRevocation $sessions,
private readonly SecurityNotifier $notifier,
) {}
public function change(User $user, string $plainPassword, ?Session $currentSession = null): void
{
$user->forceFill([
'password' => Hash::make($plainPassword),
'remember_token' => Str::random(60),
])->save();
if ($currentSession !== null) {
$this->sessions->revokeOthersFor($user, $currentSession);
} else {
$this->sessions->revokeAllFor($user);
}
$this->notifier->notify($user, SecurityChange::PasswordChanged);
}
}

View file

@ -0,0 +1,121 @@
<?php
namespace App\Services\Auth;
use App\Mail\SecurityConfirmationCode;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Session;
/**
* Frischer Identitätsnachweis vor dauerhaften Sicherheitsänderungen
* (Sicherheitsaudit 07.10.2026, S-06): Login-Adresse ändern, 2FA erstmals
* einrichten, API-Token erzeugen. Eine bestehende Session allein reicht dafür
* nicht.
*
* Konten ohne Passwort und ohne 2FA (Magic-Link-/Google-Konten) bestätigen
* per Einmal-Code an die aktuelle Kontoadresse — wer nur die Session, nicht
* aber das Postfach hat, kommt damit nicht weiter. Nach erfolgreichem Nachweis
* gilt die Session für FRESH_FOR_SECONDS als bestätigt.
*/
class SecurityConfirmation
{
public const SESSION_KEY = 'auth.security_confirmed';
public const FRESH_FOR_SECONDS = 900;
public const CODE_VALID_MINUTES = 10;
public const MAX_CODE_ATTEMPTS = 5;
public function isFresh(User $user): bool
{
$confirmed = Session::get(self::SESSION_KEY);
return is_array($confirmed)
&& (int) ($confirmed['user_id'] ?? 0) === (int) $user->getKey()
&& (int) ($confirmed['at'] ?? 0) >= now()->getTimestamp() - self::FRESH_FOR_SECONDS;
}
public function markConfirmed(User $user): void
{
Session::put(self::SESSION_KEY, [
'user_id' => $user->getKey(),
'at' => now()->getTimestamp(),
]);
}
/**
* Schickt einen 6-stelligen Code an die aktuelle Kontoadresse. Liefert
* false, wenn zu viele Codes angefordert wurden (3 je 10 Minuten).
*/
public function sendEmailCode(User $user): bool
{
$throttleKey = 'security-email-code-send:'.$user->getKey();
if (RateLimiter::tooManyAttempts($throttleKey, 3)) {
return false;
}
RateLimiter::hit($throttleKey, 600);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$expiresAt = now()->addMinutes(self::CODE_VALID_MINUTES);
Cache::put($this->codeKey($user), [
'hash' => $this->hash($code),
'attempts' => 0,
'expires_at' => $expiresAt->getTimestamp(),
], $expiresAt);
Mail::to($user->email)->queue((new SecurityConfirmationCode(
user: $user,
code: $code,
expiresAt: $expiresAt->format('H:i'),
))->afterCommit());
return true;
}
/**
* Prüft den Code; er ist nur einmal gültig und nach
* MAX_CODE_ATTEMPTS Fehlversuchen verbraucht.
*/
public function verifyEmailCode(User $user, string $code): bool
{
$key = $this->codeKey($user);
$entry = Cache::get($key);
if (! is_array($entry) || (int) ($entry['expires_at'] ?? 0) < now()->getTimestamp()) {
return false;
}
if (hash_equals((string) $entry['hash'], $this->hash(trim($code)))) {
Cache::forget($key);
return true;
}
$entry['attempts'] = (int) $entry['attempts'] + 1;
if ($entry['attempts'] >= self::MAX_CODE_ATTEMPTS) {
Cache::forget($key);
} else {
Cache::put($key, $entry, now()->setTimestamp((int) $entry['expires_at']));
}
return false;
}
private function codeKey(User $user): string
{
return 'security-email-code:'.$user->getKey();
}
private function hash(string $code): string
{
return hash_hmac('sha256', $code, (string) config('app.key'));
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Services\Auth;
use App\Enums\SecurityChange;
use App\Mail\SecurityChangeNotice;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
/**
* Informiert den Kontoinhaber über sicherheitsrelevante Änderungen
* (Sicherheitsaudit 07.10.2026, S-06). Wer eine fremde Session nutzt, soll
* nicht unbemerkt Passwort, Login-Adresse oder zweiten Faktor ändern können.
*/
class SecurityNotifier
{
/**
* @param string|null $recipient Abweichender Empfänger, z. B. die
* bisherige Adresse nach einem E-Mail-Wechsel.
*/
public function notify(User $user, SecurityChange $change, ?string $recipient = null, ?string $newEmail = null): void
{
$recipient = trim((string) ($recipient ?? $user->email));
if ($recipient === '') {
return;
}
Mail::to($recipient)->queue((new SecurityChangeNotice(
user: $user,
change: $change,
occurredAt: now()->format('d.m.Y H:i'),
newEmail: $newEmail,
))->afterCommit());
}
}

View file

@ -5,7 +5,7 @@ namespace App\Services\Auth;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Events\Login; use Illuminate\Auth\Events\Login;
use Illuminate\Contracts\Session\Session; use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str;
/** /**
* Beendet alle offenen Browser-Sessions eines Kontos (Umsetzungsaudit * Beendet alle offenen Browser-Sessions eines Kontos (Umsetzungsaudit
@ -16,10 +16,12 @@ use Illuminate\Support\Facades\Cache;
* ein Zeitstempel je Konto: Jede Anmeldung schreibt ihren Zeitpunkt in die * ein Zeitstempel je Konto: Jede Anmeldung schreibt ihren Zeitpunkt in die
* Session (`Login`-Event), `revokeAllFor()` merkt sich „ungültig vor jetzt", * Session (`Login`-Event), `revokeAllFor()` merkt sich „ungültig vor jetzt",
* und die Middleware EnsureSessionIsNotRevoked meldet jede ältere Session * und die Middleware EnsureSessionIsNotRevoked meldet jede ältere Session
* beim nächsten Request ab. Die Marke lebt so lange wie eine Session * beim nächsten Request ab. Remember-Cookies deckt das Rotieren von
* (`session.lifetime`) — danach ist jede ältere Session ohnehin abgelaufen. * `remember_token` ab, API-Tokens löscht der Aufrufer.
* Remember-Cookies deckt das Rotieren von `remember_token` ab, API-Tokens *
* löscht der Aufrufer. * Die Marke steht seit v1.6.0 in `users.sessions_revoked_at_ms` und übersteht
* so ein Leeren des Caches (Sicherheitsaudit 07.10.2026, Phase 4). Die
* Middleware liest sie am ohnehin geladenen User — kein zusätzlicher Zugriff.
*/ */
class SessionRevocation class SessionRevocation
{ {
@ -31,13 +33,41 @@ class SessionRevocation
/** /**
* Alle bis jetzt entstandenen Sessions des Kontos werden ungültig. * Alle bis jetzt entstandenen Sessions des Kontos werden ungültig.
*/ */
public function revokeAllFor(User $user): void public function revokeAllFor(User $user): int
{ {
Cache::put( $revokedAt = now()->getTimestampMs();
$this->cacheKey((int) $user->getKey()),
now()->getTimestampMs(), // Ohne Model-Events und ohne updated_at: Die Marke ist keine
now()->addMinutes(max(1, (int) config('session.lifetime', 120)) + 5), // inhaltliche Änderung am Konto und soll keine Observer auslösen.
); $user->newQuery()->whereKey($user->getKey())->update(['sessions_revoked_at_ms' => $revokedAt]);
$user->forceFill(['sessions_revoked_at_ms' => $revokedAt])->syncOriginalAttribute('sessions_revoked_at_ms');
return $revokedAt;
}
/**
* Alle anderen Sessions des Kontos werden ungültig, die übergebene
* (aktuelle) bleibt angemeldet: Sie erhält einen Anmeldezeitpunkt nach
* der Sperre und eine neue Session-ID (Sicherheitsaudit 07.10.2026, S-04).
*/
public function revokeOthersFor(User $user, Session $current): void
{
$revokedAt = $this->revokeAllFor($user);
$current->put(self::SESSION_KEY, $revokedAt + 1);
$current->migrate(true);
}
/**
* „Andere Geräte abmelden“ auf der Sicherheitsseite: Remember-Cookies
* anderer Geräte werden ungültig (neuer `remember_token`), alle anderen
* Sessions enden beim nächsten Request, die aktuelle bleibt angemeldet.
*/
public function logoutOtherDevices(User $user, Session $current): void
{
$user->forceFill(['remember_token' => Str::random(60)])->save();
$this->revokeOthersFor($user, $current);
} }
/** /**
@ -57,19 +87,14 @@ class SessionRevocation
* Session stammt aus der Zeit vor der letzten Sperre dieses Kontos. * Session stammt aus der Zeit vor der letzten Sperre dieses Kontos.
* Ohne Anmeldezeitpunkt (Session von vor dem Deploy) gilt sie als alt. * Ohne Anmeldezeitpunkt (Session von vor dem Deploy) gilt sie als alt.
*/ */
public function isRevoked(int $userId, Session $session): bool public function isRevoked(User $user, Session $session): bool
{ {
$revokedAt = Cache::get($this->cacheKey($userId)); $revokedAt = (int) $user->sessions_revoked_at_ms;
if ($revokedAt === null) { if ($revokedAt === 0) {
return false; return false;
} }
return (int) $session->get(self::SESSION_KEY, 0) < (int) $revokedAt; return (int) $session->get(self::SESSION_KEY, 0) < $revokedAt;
}
private function cacheKey(int $userId): string
{
return 'auth.sessions-revoked-at.'.$userId;
} }
} }

View file

@ -0,0 +1,90 @@
<?php
namespace App\Services\Auth;
use App\Mail\SocialAccountLinkConfirmation;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\URL;
/**
* Bestätigte Erstverknüpfung eines Social-Logins mit einem bestehenden Konto
* (Sicherheitsaudit 07.10.2026, S-05, Entscheidung 3).
*
* Die Provider-Identität wird zunächst nur in der Session vorgemerkt, die
* den Login gestartet hat; an die Kontoadresse geht ein signierter Link.
* Verknüpft wird erst, wenn dieser Link in DERSELBEN Browser-Session geöffnet
* wird. So belegt der Klick den Postfachbesitz, und ein Postfachinhaber, der
* eine fremd ausgelöste Mail anklickt, verknüpft nichts.
*/
class SocialAccountLinker
{
public const SESSION_KEY = 'oauth.pending_link';
public const EXPIRES_IN_MINUTES = 30;
public function __construct(private readonly SocialAuthService $social) {}
/**
* Merkt die Verknüpfung vor und verschickt den Bestätigungslink.
* Liefert false, wenn für das Konto zu viele Anfragen kamen.
*/
public function requestConfirmation(User $user, string $provider, string $providerId): bool
{
$throttleKey = 'social-link-mail:'.$user->getKey();
if (RateLimiter::tooManyAttempts($throttleKey, 3)) {
return false;
}
RateLimiter::hit($throttleKey, 600);
$expiresAt = now()->addMinutes(self::EXPIRES_IN_MINUTES);
Session::put(self::SESSION_KEY, [
'user_id' => $user->getKey(),
'provider' => $provider,
'provider_id' => $providerId,
'expires_at' => $expiresAt->getTimestamp(),
]);
$confirmUrl = URL::temporarySignedRoute('oauth.google.confirm-link', $expiresAt, [
'user' => $user->getKey(),
'provider_id' => $providerId,
]);
Mail::to($user->email)->queue((new SocialAccountLinkConfirmation(
user: $user,
confirmUrl: $confirmUrl,
expiresAt: $expiresAt->format('H:i'),
))->afterCommit());
return true;
}
/**
* Speichert die vorgemerkte Verknüpfung, wenn sie zu dieser Session,
* diesem Konto und dieser Provider-Identität gehört.
*/
public function confirm(User $user, string $providerId): bool
{
$pending = Session::pull(self::SESSION_KEY);
if (! is_array($pending)
|| (int) ($pending['user_id'] ?? 0) !== (int) $user->getKey()
|| ! hash_equals((string) ($pending['provider_id'] ?? ''), $providerId)
|| (int) ($pending['expires_at'] ?? 0) < now()->getTimestamp()) {
return false;
}
if (filled($user->oauth_provider_id)) {
return hash_equals((string) $user->oauth_provider_id, $providerId);
}
$this->social->link($user, (string) $pending['provider'], $providerId);
return true;
}
}

View file

@ -2,47 +2,79 @@
namespace App\Services\Auth; namespace App\Services\Auth;
use App\Enums\SecurityChange;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Str; use Illuminate\Support\Str;
/** /**
* Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User. * Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User.
* *
* Identität ist die E-Mail (De-Dup darüber, keine Dubletten). Der Provider * Reihenfolge (Sicherheitsaudit 07.10.2026, S-05):
* bestätigt die E-Mail, daher gilt die Verifizierung über den Kanal als erfüllt * 1. Ohne vom Provider bestätigte E-Mail (`email_verified`) kein Login.
* (Entscheidung 15.06.) – kein zusätzlicher E-Mail-Verifizierungsschritt. * 2. Eine bereits verknüpfte Provider-Identität (`oauth_provider_id`) ist
* die stabile Bindung und gewinnt vor der E-Mail.
* 3. Sonst De-Dup über die E-Mail. Ein Konto, das schon mit einer ANDEREN
* Identität desselben Providers verknüpft ist, wird nicht still
* umgehängt.
* 4. Die Erstverknüpfung eines bestehenden, verifizierten Kontos erfolgt
* direkt nur, wenn Google für das Postfach zuständig ist (gmail.com bzw.
* Workspace-Domain `hd`). Bei sonstigen Adressen belegt Google den
* aktuellen Postfachbesitz nicht — dann erst nach Bestätigungslink an die
* Adresse (SocialAccountLinker).
*
* Neue Konten und noch unverifizierte Selbst-Registrierer werden wie bisher
* direkt angelegt bzw. übernommen (Entscheidung 15.06., Audit K-04).
*/ */
class SocialAuthService class SocialAuthService
{ {
/**
* Domains, deren Postfächer Google selbst betreibt.
*/
private const GOOGLE_MAIL_DOMAINS = ['gmail.com', 'googlemail.com'];
public function __construct( public function __construct(
private readonly UserRolePermissionSyncService $roleSync, private readonly UserRolePermissionSyncService $roleSync,
private readonly CustomerAccountProvisioner $accountProvisioner, private readonly CustomerAccountProvisioner $accountProvisioner,
private readonly SessionRevocation $sessions, private readonly SessionRevocation $sessions,
private readonly SecurityNotifier $notifier,
) {} ) {}
/** /**
* Liefert den (ggf. neu angelegten) User zur Social-Identität. Ein * Ein deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer
* deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer prüft * prüft danach is_active und blockiert.
* danach is_active und blockiert.
*/ */
public function resolveUser(string $provider, string $providerId, ?string $email, ?string $name): ?User public function resolve(
{ string $provider,
string $providerId,
?string $email,
?string $name,
bool $emailVerified,
?string $hostedDomain = null,
): SocialLoginResult {
$email = $email ? mb_strtolower(trim($email)) : null; $email = $email ? mb_strtolower(trim($email)) : null;
if ($email === null || $email === '') { if ($email === null || $email === '' || ! $emailVerified || $providerId === '') {
return null; return SocialLoginResult::unverifiedEmail();
}
$linked = User::query()
->where('oauth_provider', $provider)
->where('oauth_provider_id', $providerId)
->first();
if ($linked) {
return SocialLoginResult::login($linked);
} }
$user = User::findByEmail($email); $user = User::findByEmail($email);
if (! $user) { if (! $user) {
return $this->createUser($provider, $providerId, $email, $name); return SocialLoginResult::login($this->createUser($provider, $providerId, $email, $name));
} }
$user->forceFill([ if (filled($user->oauth_provider_id)) {
'oauth_provider' => $provider, return SocialLoginResult::providerMismatch($user);
'oauth_provider_id' => $providerId, }
]);
// Noch nicht verifiziert (offener Selbst-Registrierer): der Provider // Noch nicht verifiziert (offener Selbst-Registrierer): der Provider
// bestätigt die E-Mail → Onboarding abschließen (aktiv + customer). // bestätigt die E-Mail → Onboarding abschließen (aktiv + customer).
@ -54,6 +86,8 @@ class SocialAuthService
// oder Magic Link an und setzt bei Bedarf selbst ein Passwort. // oder Magic Link an und setzt bei Bedarf selbst ein Passwort.
if ($user->email_verified_at === null) { if ($user->email_verified_at === null) {
$user->forceFill([ $user->forceFill([
'oauth_provider' => $provider,
'oauth_provider_id' => $providerId,
'email_verified_at' => now(), 'email_verified_at' => now(),
'is_active' => true, 'is_active' => true,
'password' => null, 'password' => null,
@ -68,14 +102,47 @@ class SocialAuthService
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer'); $this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
} }
return $user; return SocialLoginResult::login($user);
}
if (! $this->providerOwnsMailbox($email, $hostedDomain)) {
return SocialLoginResult::confirmLink($user);
} }
// Bereits verifiziert: nur Provider verknüpfen. is_active bleibt // Bereits verifiziert: nur Provider verknüpfen. is_active bleibt
// unangetastet (deaktivierte Accounts werden nicht reaktiviert). // unangetastet (deaktivierte Accounts werden nicht reaktiviert).
$user->save(); $this->link($user, $provider, $providerId);
return $user; return SocialLoginResult::login($user);
}
/**
* Verknüpft die Provider-Identität mit einem bestehenden Konto und
* informiert den Kontoinhaber darüber.
*/
public function link(User $user, string $provider, string $providerId): void
{
$user->forceFill([
'oauth_provider' => $provider,
'oauth_provider_id' => $providerId,
])->save();
$this->notifier->notify($user, SecurityChange::GoogleLinked);
}
/**
* Google ist für das Postfach zuständig: eigene Mail-Domain oder eine
* Workspace-Domain (`hd`), zu der die Adresse gehört.
*/
private function providerOwnsMailbox(string $email, ?string $hostedDomain): bool
{
$domain = Str::afterLast($email, '@');
if (in_array($domain, self::GOOGLE_MAIL_DOMAINS, true)) {
return true;
}
return filled($hostedDomain) && strcasecmp(trim($hostedDomain), $domain) === 0;
} }
private function createUser(string $provider, string $providerId, string $email, ?string $name): User private function createUser(string $provider, string $providerId, string $email, ?string $name): User

View file

@ -0,0 +1,47 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
/**
* Ergebnis der Auflösung eines Social-Logins (SocialAuthService::resolve()).
*/
final readonly class SocialLoginResult
{
public const LOGIN = 'login';
/** Bestehendes Konto, Verknüpfung erst nach Bestätigung per E-Mail (S-05). */
public const CONFIRM_LINK = 'confirm_link';
/** Keine bzw. eine vom Provider nicht bestätigte E-Mail-Adresse. */
public const UNVERIFIED_EMAIL = 'unverified_email';
/** Konto ist bereits mit einer anderen Identität desselben Providers verknüpft. */
public const PROVIDER_MISMATCH = 'provider_mismatch';
private function __construct(
public string $outcome,
public ?User $user = null,
) {}
public static function login(User $user): self
{
return new self(self::LOGIN, $user);
}
public static function confirmLink(User $user): self
{
return new self(self::CONFIRM_LINK, $user);
}
public static function unverifiedEmail(): self
{
return new self(self::UNVERIFIED_EMAIL);
}
public static function providerMismatch(User $user): self
{
return new self(self::PROVIDER_MISMATCH, $user);
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Session;
/**
* Starke Anmeldung für den Admin-Bereich (Entscheidung 5, 07.10.2026).
*
* Super-Admins und Admins brauchen eine Session, die mit einem zweiten Faktor
* bestätigt wurde: Passkey (Login oder Bestätigung, Benutzerverifizierung per
* Biometrie/PIN ist Pflicht) oder Authenticator-Code (2FA-Challenge bzw.
* Bestätigung). Magic Link, Google und Passwort allein genügen nicht — sonst
* ließe sich die Pflicht über den bequemsten Anmeldeweg umgehen. Die Marke
* gilt für die Lebensdauer der Session.
*/
class StrongAuthentication
{
public const SESSION_KEY = 'auth.strong';
public const INTENDED_KEY = 'auth.strong_intended';
public function isRequiredFor(User $user): bool
{
if (! config('auth.require_strong_admin_authentication', true)) {
return false;
}
return $user->is_super_admin || $user->hasRole('admin');
}
public function isSatisfiedBy(User $user): bool
{
$strong = Session::get(self::SESSION_KEY);
return is_array($strong) && (int) ($strong['user_id'] ?? 0) === (int) $user->getKey();
}
/**
* @param string $method passkey oder totp
*/
public function mark(User $user, string $method): void
{
Session::put(self::SESSION_KEY, [
'user_id' => $user->getKey(),
'method' => $method,
'at' => now()->getTimestamp(),
]);
}
public function hasPasskeys(User $user): bool
{
return $user->passkeys()->exists();
}
}

View file

@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Mail;
/** /**
* Bearbeitungsweg einer MAN-Rechnung nach der Ausstellung (Legacy- * Bearbeitungsweg einer MAN-Rechnung nach der Ausstellung (Legacy-
* Überweisungskunden, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md): * Überweisungskunden, dev/archive/legacy-rechnungen/entwicklungsplan-2026-09-16.md):
* *
* senden → (erinnern) → als bezahlt markieren * senden → (erinnern) → als bezahlt markieren
* → Bestandsschutz beenden (Rechnung storniert, Bündel gekündigt) * → Bestandsschutz beenden (Rechnung storniert, Bündel gekündigt)

View file

@ -0,0 +1,209 @@
<?php
namespace App\Services\Changelog;
use App\Enums\ChangelogCategory;
use App\Enums\ChangelogStatus;
use App\Models\ChangelogEntry;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Process;
use RuntimeException;
/**
* Stufe 2 des Changelogs (Decision-Update Changelog): Beim Deploy werden die
* Commits zwischen dem vorigen und dem neuen Tag gelesen und aus den Zeilen
* `Changelog:` / `Public:` Entwürfe angelegt. Veröffentlicht wird nichts —
* das macht der Betreiber im Admin (Stufe 3).
*
* Die Zeilen werden aus der ganzen Commit-Nachricht gelesen, nicht über
* `git interpret-trailers`: Git zählt nur den LETZTEN Absatz als Trailer, und
* dort steht meist `Co-Authored-By`.
*
* Regeln aus dem Decision-Update:
* - Ohne `Changelog:` bleibt ein Commit intern.
* - Ohne `Public:` wird ein Vorschlag aus dem Titel gemacht und markiert.
* - Sicherheit ergibt je Release genau eine Sammelzeile (E5).
* - Klingt der Titel nach Sicherheit, die Rubrik aber nicht, wird der
* Eintrag auf Sicherheit gesetzt und markiert — zurückstufen nur bewusst.
*/
class ChangelogCollector
{
/** Begriffe im Commit-Titel, die auf eine Sicherheitskorrektur deuten. */
private const array SECURITY_TERMS = [
'auth', 'session', 'token', 'csrf', 'xss', 'injection', 'permission', 'escape', 'sanitize', 'rate limit', 'rate-limit',
'sicherheit', 'security',
];
/**
* @param string|null $repositoryPath Git-Repository (Standard: die Anwendung selbst)
*/
public function __construct(private readonly ?string $repositoryPath = null) {}
/**
* @return array{created: int, existing: int, internal: int, entries: list<ChangelogEntry>}
*/
public function collect(string $from, string $to, ?Carbon $releasedOn = null, bool $dryRun = false): array
{
$releasedOn ??= Carbon::today();
$created = 0;
$existing = 0;
$internal = 0;
$entries = [];
$hasSecurity = false;
foreach ($this->commits($from, $to) as $commit) {
$trailers = self::parse($commit['message']);
if ($trailers === null) {
$internal++;
continue;
}
[$category, $note] = $this->categoryFor($trailers['category'], $commit['subject']);
if ($category === ChangelogCategory::Security) {
$hasSecurity = true;
continue;
}
$text = $trailers['public'];
if ($text === null) {
$text = self::suggestionFrom($commit['subject']);
$note ??= 'Kein Public-Satz im Commit — Vorschlag aus dem Commit-Titel.';
}
$result = $this->store($commit['hash'], $releasedOn, $to, $category, $text, $commit['subject'], $note, $dryRun);
$result['created'] ? $created++ : $existing++;
$entries[] = $result['entry'];
}
if ($hasSecurity) {
$result = $this->store('security:'.$to, $releasedOn, $to, ChangelogCategory::Security, ChangelogCategory::SECURITY_LINE, null, null, $dryRun);
$result['created'] ? $created++ : $existing++;
$entries[] = $result['entry'];
}
return ['created' => $created, 'existing' => $existing, 'internal' => $internal, 'entries' => $entries];
}
/**
* Zeilen `Changelog:` und `Public:` aus einer Commit-Nachricht. Null, wenn
* es keine gültige `Changelog:`-Zeile gibt.
*
* @return array{category: ChangelogCategory, public: string|null}|null
*/
public static function parse(string $message): ?array
{
if (! preg_match('/^Changelog:[ \t]*(.+)$/mi', $message, $categoryMatch)) {
return null;
}
$category = ChangelogCategory::fromTrailer($categoryMatch[1]);
if ($category === null) {
return null;
}
$public = null;
// Public darf umbrechen: bis zur nächsten Leerzeile oder Trailer-Zeile.
if (preg_match('/^Public:[ \t]*(.+(?:\n(?![ \t]*\n)(?![A-Za-z-]+:[ \t]).+)*)/mi', $message, $publicMatch)) {
$public = trim((string) preg_replace('/\s+/', ' ', $publicMatch[1]));
$public = $public === '' ? null : $public;
}
return ['category' => $category, 'public' => $public];
}
/**
* Vorschlag aus dem Commit-Titel: ohne Bereichspräfix (`web: …`), erster
* Buchstabe groß, mit Punkt.
*/
public static function suggestionFrom(string $subject): string
{
$text = trim((string) preg_replace('/^[a-z][\w-]*(\([^)]*\))?!?:\s*/i', '', $subject));
return rtrim(mb_strtoupper(mb_substr($text, 0, 1)).mb_substr($text, 1), '.').'.';
}
/**
* @return array{0: ChangelogCategory, 1: string|null}
*/
private function categoryFor(ChangelogCategory $category, string $subject): array
{
if ($category === ChangelogCategory::Security) {
return [$category, null];
}
$lower = mb_strtolower($subject);
foreach (self::SECURITY_TERMS as $term) {
if (preg_match('/\b'.preg_quote($term, '/').'\b/u', $lower)) {
return [ChangelogCategory::Security, sprintf('Automatisch auf Sicherheit gesetzt („%s“ im Titel, Rubrik war %s) — zurückstufen nur bewusst.', $term, $category->label())];
}
}
return [$category, null];
}
/**
* @return array{created: bool, entry: ChangelogEntry}
*/
private function store(string $key, Carbon $releasedOn, string $tag, ChangelogCategory $category, string $text, ?string $subject, ?string $note, bool $dryRun): array
{
$existing = ChangelogEntry::query()->where('source_key', $key)->first();
if ($existing !== null) {
return ['created' => false, 'entry' => $existing];
}
$entry = new ChangelogEntry([
'released_on' => $releasedOn->toDateString(),
'release_tag' => mb_substr($tag, 0, 20),
'category' => $category,
'text' => mb_substr($text, 0, 500),
'status' => ChangelogStatus::Draft,
'source_key' => $key,
'commit_subject' => $subject === null ? null : mb_substr($subject, 0, 255),
'review_note' => $note === null ? null : mb_substr($note, 0, 255),
]);
if (! $dryRun) {
$entry->save();
}
return ['created' => true, 'entry' => $entry];
}
/**
* Commits von `$from` (ausschließlich) bis `$to`, älteste zuerst, ohne
* Merges.
*
* @return list<array{hash: string, subject: string, message: string}>
*/
private function commits(string $from, string $to): array
{
$result = Process::path($this->repositoryPath ?? base_path())->run([
'git', 'log', '--no-merges', '--reverse', '--format=%H%x1f%s%x1f%B%x1e', $from.'..'.$to,
]);
if (! $result->successful()) {
throw new RuntimeException(sprintf('git log %s..%s fehlgeschlagen: %s', $from, $to, trim($result->errorOutput())));
}
$commits = [];
foreach (explode("\x1e", $result->output()) as $record) {
$parts = explode("\x1f", trim($record), 3);
if (count($parts) === 3 && $parts[0] !== '') {
$commits[] = ['hash' => $parts[0], 'subject' => $parts[1], 'message' => $parts[2]];
}
}
return $commits;
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Services\Changelog;
use App\Enums\ChangelogCategory;
use App\Models\ChangelogEntry;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* Öffentliche Sicht auf den Changelog: nur freigegebene Einträge, jüngste
* zuerst, je Tag in der Reihenfolge der Rubriken. Sicherheit erscheint je Tag
* höchstens einmal als Sammelzeile (E5) — auch wenn mehrere Releases an einem
* Tag eine mitbringen.
*/
class ChangelogTimeline
{
public const string RECENT_CACHE_KEY = 'changelog.has-recent';
/**
* @return Collection<int, ChangelogEntry>
*/
public function entries(int $limit = 200): Collection
{
$entries = ChangelogEntry::query()
->published()
->orderByDesc('released_on')
->orderBy('id')
->limit($limit)
->get();
$seenSecurityDays = [];
return $entries
->filter(function (ChangelogEntry $entry) use (&$seenSecurityDays): bool {
if ($entry->category !== ChangelogCategory::Security) {
return true;
}
$day = $entry->released_on->toDateString();
if (isset($seenSecurityDays[$day])) {
return false;
}
$seenSecurityDays[$day] = true;
return true;
})
->sortBy([
fn (ChangelogEntry $a, ChangelogEntry $b): int => $b->released_on <=> $a->released_on,
fn (ChangelogEntry $a, ChangelogEntry $b): int => $a->category->order() <=> $b->category->order(),
fn (ChangelogEntry $a, ChangelogEntry $b): int => $a->id <=> $b->id,
])
->values();
}
/**
* Gibt es einen freigegebenen Eintrag der letzten Tage? Für den
* „Neu“-Hinweis im Kundenbereich (E11).
*/
public function hasRecent(int $days = 14): bool
{
return ChangelogEntry::query()
->published()
->where('released_on', '>=', Carbon::today()->subDays($days)->toDateString())
->exists();
}
}

View file

@ -10,6 +10,9 @@ use App\Models\MagicLink;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\User; use App\Models\User;
use App\Scopes\PortalScope; use App\Scopes\PortalScope;
use App\Support\CrawlerIdentifier;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
/** /**
* Schreibt die Ereignisse der Konversionsleiter (Roadmap 0.5). * Schreibt die Ereignisse der Konversionsleiter (Roadmap 0.5).
@ -73,6 +76,83 @@ class ConversionRecorder
$this->record($type, $source, $company, $pressRelease); $this->record($type, $source, $company, $pressRelease);
} }
/**
* Stufe 2: die Seite „Diesen Newsroom verwalten“ wurde geöffnet.
*
* Aus einer Mail (`src`) oder über den Hinweis auf der Newsroom- bzw.
* Meldungsseite. Wie beim Mail-Klick zählen nur echte Aufrufe, und
* dieselbe Adresse zählt je Firma höchstens einmal im Zeitfenster.
*/
public function recordManagePageOpened(Request $request, Company $company, ConversionSource $source, ?PressRelease $pressRelease = null): void
{
try {
if (! $request->isMethod('GET') || $this->isAutomatedVisit($request->userAgent())) {
return;
}
$key = 'manage-page:'.hash('sha256', (string) $request->ip()).':'.$company->getKey();
$decay = max(1, (int) config('conversion.mail_clicks.throttle_minutes', 30)) * 60;
if (! RateLimiter::attempt($key, 1, static fn (): bool => true, $decay)) {
return;
}
} catch (\Throwable $e) {
report($e);
return;
}
$this->record(ConversionEventType::ManagePageOpened, $source, $company, $pressRelease);
}
/**
* Stufe 4: Magic Link eingelöst — mit der Firma und Meldung, die das
* Token trägt („Diesen Newsroom verwalten“, Veröffentlichungsmail).
* Ohne diesen Bezug landete jede Einlösung in „ohne Firma“, und die
* Stufe zeigte bis 13.10.2026 immer null Firmen (Roadmap 6.16).
*/
public function recordMagicLinkConsumed(MagicLink $magicLink): void
{
try {
$companyId = (int) ($magicLink->payload['company_id'] ?? 0);
$pressReleaseId = (int) ($magicLink->payload['press_release_id'] ?? 0);
$company = $companyId > 0
? Company::query()->withoutGlobalScope(PortalScope::class)->find($companyId)
: null;
$pressRelease = $pressReleaseId > 0
? PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($pressReleaseId)
: null;
} catch (\Throwable $e) {
report($e);
$company = null;
$pressRelease = null;
}
$this->record(ConversionEventType::MagicLinkConsumed, $this->sourceOf($magicLink), $company, $pressRelease);
}
/**
* Mailserver-Scanner, Vorschau-Dienste und Crawler — kein Mensch, also
* kein Schritt in der Leiter (Umsetzungsaudit 29.09.2026, R-08).
*/
public function isAutomatedVisit(?string $userAgent): bool
{
$userAgent = mb_strtolower(trim((string) $userAgent));
if ($userAgent === '' || CrawlerIdentifier::identify($userAgent) !== null) {
return true;
}
foreach ((array) config('conversion.mail_clicks.scanner_agents', []) as $pattern) {
if (str_contains($userAgent, mb_strtolower((string) $pattern))) {
return true;
}
}
return false;
}
/** /**
* Herkunft eines Magic-Link-Tokens. * Herkunft eines Magic-Link-Tokens.
* *
@ -102,13 +182,17 @@ class ConversionRecorder
} }
/** /**
* Stufe 5 der Konversionsleiter. * Stufe 7 der Konversionsleiter.
* *
* Der Trichter läuft über die FIRMA, gezahlt wird aber vom Konto. Die * Der Trichter läuft über die FIRMA, gezahlt wird aber vom Konto. Die
* Brücke ist die Firma des Kontos — bevorzugt die eigene, sonst die erste * Brücke ist die Firma des Kontos — bevorzugt die eigene, sonst die erste
* zugeordnete. Ohne Firma bleibt der Bezug leer; das Ereignis ist dann * zugeordnete. Ohne Firma bleibt der Bezug leer; das Ereignis ist dann
* für die Trichter-Auswertung wertlos, aber immer noch eine ehrliche Zeile * für die Trichter-Auswertung wertlos, aber immer noch eine ehrliche Zeile
* im Log. * im Log.
*
* Die Herkunft ist wie bei Stufe 5 die des letzten eingelösten Magic
* Links; bis 13.10.2026 stand hier immer `organic`, auch nach einem
* Claim aus der Veröffentlichungsmail (Roadmap 6.16).
*/ */
public function recordFirstBooking(?User $user): void public function recordFirstBooking(?User $user): void
{ {
@ -125,11 +209,11 @@ class ConversionRecorder
return; return;
} }
$this->recordOnce(ConversionEventType::FirstBooking, ConversionSource::Organic, $company); $this->recordOnce(ConversionEventType::FirstBooking, $this->lastMagicLinkSource($user), $company);
} }
/** /**
* Stufe 3 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3). * Stufe 5 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3).
* *
* Einmal je Firma. Die Herkunft ist die des letzten eingelösten Magic * Einmal je Firma. Die Herkunft ist die des letzten eingelösten Magic
* Links dieses Kontos — wer über „Diesen Newsroom verwalten" aus der Mail * Links dieses Kontos — wer über „Diesen Newsroom verwalten" aus der Mail
@ -138,8 +222,17 @@ class ConversionRecorder
*/ */
public function recordNewsroomEdited(Company $company, ?User $user): void public function recordNewsroomEdited(Company $company, ?User $user): void
{ {
$this->recordOnce(ConversionEventType::NewsroomEdited, $this->lastMagicLinkSource($user), $company);
}
private function lastMagicLinkSource(?User $user): ConversionSource
{
if ($user === null) {
return ConversionSource::Organic;
}
try { try {
$source = $user === null ? null : MagicLink::query() $source = MagicLink::query()
->where('user_id', $user->getKey()) ->where('user_id', $user->getKey())
->whereIn('purpose', MagicLink::LOGIN_PURPOSES) ->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
->whereNotNull('consumed_at') ->whereNotNull('consumed_at')
@ -150,7 +243,7 @@ class ConversionRecorder
$source = null; $source = null;
} }
$this->recordOnce(ConversionEventType::NewsroomEdited, ConversionSource::parse($source), $company); return ConversionSource::parse($source);
} }
private function alreadyRecorded(ConversionEventType $type, Company $company): bool private function alreadyRecorded(ConversionEventType $type, Company $company): bool

View file

@ -0,0 +1,271 @@
<?php
namespace App\Services\Growth;
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Models\ConversionEvent;
use App\Services\Analytics\UmamiClient;
use App\Services\Analytics\UmamiUnavailable;
use App\Services\PressRelease\FeedFetchReport;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* Stand der Abbruchbedingungen für Block 1 bis 3 (Roadmap 6.1, 6.16).
*
* Je Bedingung: Ist-Wert, Schwelle, Status. Für Bedingungen, die über den
* Zeitraum hinweg zusammenkommen (Block 1), zusätzlich „auf Kurs“: Liegt der
* Ist-Wert mindestens beim zeitanteiligen Soll? Block 2 und 3 sind
* Momentaufnahmen der letzten 30 Tage und haben kein Soll unterwegs.
*
* Quellen: eigene Ereignisdaten (Trichter, Feed-Abrufe) und Umami. Fällt
* Umami aus, bleibt der Rest stehen; die Umami-Werte zeigen dann den Grund.
*/
class GrowthReport
{
/** Herkünfte, die als „über Mail oder Claim“ zählen. */
private const array CLAIM_SOURCES = [
ConversionSource::MailPublish,
ConversionSource::MailReject,
ConversionSource::MailReview,
ConversionSource::ProfileClaim,
];
public function __construct(
private readonly UmamiClient $umami,
private readonly FeedFetchReport $feeds,
) {}
/**
* @return array{
* period: array{start: Carbon, review: Carbon, end: Carbon, elapsed_share: float},
* blocks: list<array{key: string, title: string, consequence: string, criteria: list<array<string, mixed>>}>,
* umami: array{configured: bool, error: string|null},
* pages: list<array{brand: string, page: string, views: int, search_views: int}>,
* }
*/
public function build(bool $fresh = false, ?Carbon $now = null): array
{
$now ??= Carbon::now();
$start = Carbon::parse((string) config('growth.period.start'))->startOfDay();
$review = Carbon::parse((string) config('growth.period.review'))->startOfDay();
$end = Carbon::parse((string) config('growth.period.end'))->endOfDay();
$elapsedShare = $now->lessThanOrEqualTo($start) ? 0.0 : min(1.0, $start->diffInSeconds($now) / max(1, $start->diffInSeconds($end)));
$ended = $now->greaterThan($end);
$umami = $this->searchViews($fresh, $now);
return [
'period' => ['start' => $start, 'review' => $review, 'end' => $end, 'elapsed_share' => $elapsedShare],
'blocks' => [
[
'key' => 'block1',
'title' => 'Block 1 — Konversion aus dem Bestand',
'consequence' => 'Wenn verfehlt: Block 1 einfrieren, Gewicht auf Block 3.',
'criteria' => [
$this->criterion('claims', 'Firmen mit eingelöstem Verwaltungslink', $this->claimedCompanies($start), (int) config('growth.block1.claims_min'), $elapsedShare, $ended, cumulative: true,
note: $this->claimsWithoutCompanyNote($start)),
$this->criterion('bookings', 'Erstbuchungen über Mail oder Claim', $this->companiesWith(ConversionEventType::FirstBooking, $start), (int) config('growth.block1.bookings_min'), $elapsedShare, $ended, cumulative: true),
$this->criterion('reject_clicks', 'Klicks aus der Ablehnungsmail', $this->rejectMailClicks($start), (int) config('growth.block1.reject_clicks_min'), $elapsedShare, $ended, cumulative: true),
],
],
[
'key' => 'block2',
'title' => 'Block 2 — Organische Sichtbarkeit',
'consequence' => 'Wenn verfehlt: Marketing-Teil nicht weiter ausbauen, nur 2.1 bis 2.3 behalten.',
'criteria' => [
$this->criterion('search_views', 'Aufrufe der Transparenzseiten aus der Suche (30 Tage)', $umami['total'], (int) config('growth.block2.search_views_min'), $elapsedShare, $ended, cumulative: false,
note: $umami['error'] ?? 'Gewertet wird der Monat '.Carbon::parse((string) config('growth.block2.evaluation_month').'-01')->locale('de')->isoFormat('MMMM YYYY').'.'),
],
],
[
'key' => 'block3',
'title' => 'Block 3 — Reichweite',
'consequence' => 'Wenn verfehlt: RSS ist kein Verkaufsargument; der Digest muss sich eigenständig begründen.',
'criteria' => [
$this->criterion('feed_subscribers', 'Gemeldete Feed-Abonnenten (30 Tage)', $this->feedSubscribers(), (int) config('growth.block3.feed_subscribers_min'), $elapsedShare, $ended, cumulative: false,
note: 'Nur Reader-Dienste, die Abonnenten im User-Agent melden (Feedly, Inoreader …).'),
],
],
],
'umami' => ['configured' => $this->umami->isConfigured(), 'error' => $umami['error']],
'pages' => $umami['pages'],
];
}
public static function statusLabel(string $status): string
{
return match ($status) {
'reached' => 'erreicht',
'on_track' => 'auf Kurs',
'behind' => 'hinter Plan',
'open' => 'noch nicht erreicht',
'missed' => 'verfehlt',
default => 'keine Daten',
};
}
/**
* Flux-Badge-Farbe je Status.
*/
public static function statusColor(string $status): string
{
return match ($status) {
'reached', 'on_track' => 'green',
'behind', 'open' => 'amber',
'missed' => 'red',
default => 'zinc',
};
}
/**
* @return array<string, mixed>
*/
private function criterion(string $key, string $label, ?int $actual, int $threshold, float $elapsedShare, bool $ended, bool $cumulative, ?string $note = null): array
{
$expected = $cumulative ? (int) ceil($threshold * $elapsedShare) : null;
$status = match (true) {
$actual === null => 'unknown',
$actual >= $threshold => 'reached',
$ended => 'missed',
$cumulative && $actual >= $expected => 'on_track',
$cumulative => 'behind',
default => 'open',
};
return [
'key' => $key,
'label' => $label,
'actual' => $actual,
'threshold' => $threshold,
'expected' => $expected,
'status' => $status,
'note' => $note,
];
}
private function claimedCompanies(Carbon $start): int
{
return $this->companiesWith(ConversionEventType::MagicLinkConsumed, $start);
}
private function companiesWith(ConversionEventType $type, Carbon $start): int
{
return ConversionEvent::query()
->where('type', $type->value)
->whereIn('source', array_map(static fn (ConversionSource $source): string => $source->value, self::CLAIM_SOURCES))
->whereNotNull('company_id')
->where('created_at', '>=', $start)
->distinct()
->count('company_id');
}
/**
* Einlösungen vor v1.10 tragen keine Firma (Roadmap 6.16) und fehlen
* deshalb in der Zählung — der Hinweis macht die Lücke sichtbar.
*/
private function claimsWithoutCompanyNote(Carbon $start): ?string
{
$count = ConversionEvent::query()
->where('type', ConversionEventType::MagicLinkConsumed->value)
->whereIn('source', array_map(static fn (ConversionSource $source): string => $source->value, self::CLAIM_SOURCES))
->whereNull('company_id')
->where('created_at', '>=', $start)
->count();
return $count > 0
? sprintf('Dazu %d %s ohne Firmenbezug (vor der Korrektur am 13.10.2026 erfasst).', $count, $count === 1 ? 'Einlösung' : 'Einlösungen')
: null;
}
private function rejectMailClicks(Carbon $start): int
{
return ConversionEvent::query()
->where('type', ConversionEventType::MailLinkClicked->value)
->where('source', ConversionSource::MailReject->value)
->where('created_at', '>=', $start)
->count();
}
private function feedSubscribers(): int
{
return (int) collect($this->feeds->byReader(30))->sum(static fn (array $reader): int => (int) $reader['subscribers']);
}
/**
* Aufrufe der Transparenzseiten der letzten 30 Tage, gesamt und aus der
* Suche, je Marke und Seite. Deutsch und Englisch zusammen
* (Filter „enthält“ auf den Slug).
*
* @return array{total: int|null, error: string|null, pages: list<array{brand: string, page: string, views: int, search_views: int}>}
*/
private function searchViews(bool $fresh, Carbon $now): array
{
if (! $this->umami->isConfigured()) {
return ['total' => null, 'error' => 'Umami-API nicht verbunden (UMAMI_API_* in der .env).', 'pages' => []];
}
$key = 'growth.block2.'.$now->toDateString();
if ($fresh) {
Cache::forget($key);
}
try {
/** @var list<array{brand: string, page: string, views: int, search_views: int}> $pages */
$pages = Cache::remember($key, now()->addMinutes((int) config('growth.cache_minutes', 360)), function () use ($now): array {
$from = $now->copy()->subDays(30);
$engines = (array) config('growth.block2.search_referrers', []);
$rows = [];
foreach ((array) config('growth.block2.brands', []) as $brand) {
$websiteId = $this->umami->websiteId((string) $brand);
if ($websiteId === null) {
continue;
}
foreach ((array) config('growth.block2.pages', []) as $page) {
$filter = ['path' => 'c.'.$page];
$views = $this->umami->stats($websiteId, $from, $now, $filter)['pageviews'];
$search = 0;
if ($views > 0) {
foreach ($this->umami->metrics($websiteId, 'referrer', $from, $now, $filter, 100) as $referrer) {
if (self::isSearchReferrer($referrer['x'], $engines)) {
$search += $referrer['y'];
}
}
}
$rows[] = ['brand' => (string) $brand, 'page' => (string) $page, 'views' => $views, 'search_views' => $search];
}
}
return $rows;
});
} catch (UmamiUnavailable $e) {
return ['total' => null, 'error' => $e->getMessage(), 'pages' => []];
}
return ['total' => (int) array_sum(array_column($pages, 'search_views')), 'error' => null, 'pages' => $pages];
}
/**
* @param array<int, mixed> $engines
*/
private static function isSearchReferrer(string $referrer, array $engines): bool
{
$referrer = mb_strtolower($referrer);
foreach ($engines as $engine) {
if ($engine !== '' && str_contains($referrer, mb_strtolower((string) $engine))) {
return true;
}
}
return false;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Services\PressRelease; namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\BoostPlacement; use App\Enums\BoostPlacement;
use App\Enums\Portal; use App\Enums\Portal;
use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseClassification;
@ -11,6 +12,7 @@ use App\Models\Boost;
use App\Models\PressRelease; use App\Models\PressRelease;
use App\Models\User; use App\Models\User;
use App\Scopes\PortalScope; use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Billing\CreditPricingService; use App\Services\Billing\CreditPricingService;
use App\Services\Billing\CreditWalletService; use App\Services\Billing\CreditWalletService;
use App\Support\WebPageCache; use App\Support\WebPageCache;
@ -35,6 +37,7 @@ class BoostService
public function __construct( public function __construct(
private readonly CreditPricingService $pricing, private readonly CreditPricingService $pricing,
private readonly CreditWalletService $wallet, private readonly CreditWalletService $wallet,
private readonly ActivityLog $activityLog,
) {} ) {}
/** /**
@ -180,6 +183,52 @@ class BoostService
return $boost; return $boost;
} }
/**
* Beendet alle laufenden und bereits angeschlossenen Boosts einer PM
* sofort (Admin ▸ Pressemitteilung), etwa nach einer Test-Buchung oder
* wenn die Platzierung nicht mehr vertretbar ist. Die Zeilen bleiben als
* Buchungsnachweis erhalten; Credits werden nicht zurückgebucht —
* Erstattungen laufen getrennt.
*
* @return int Anzahl der beendeten Boosts
*/
public function endActive(PressRelease $pressRelease, User $endedBy, string $reason): int
{
$now = now();
$boosts = $pressRelease->boosts()->active()->get();
if ($boosts->isEmpty()) {
return 0;
}
foreach ($boosts as $boost) {
$boost->update([
'starts_at' => $boost->starts_at->isFuture() ? $now : $boost->starts_at,
'ends_at' => $now,
]);
}
$boosts->pluck('placement')->unique()->each(
fn (BoostPlacement $placement) => $this->forgetPlacementCaches($pressRelease, $placement),
);
$this->activityLog->record(
ActivityType::BoostEnded,
sprintf(
'%s hat %d gebuchte Platzierung(en) von PM #%d vorzeitig beendet. Grund: %s',
$endedBy->name,
$boosts->count(),
$pressRelease->id,
$reason,
),
user: $endedBy,
subject: $pressRelease,
meta: ['boost_ids' => $boosts->modelKeys(), 'ended_by' => $endedBy->id, 'reason' => $reason],
);
return $boosts->count();
}
/** /**
* Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern. * Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern.
* *

View file

@ -3,6 +3,7 @@
namespace App\Services\PressRelease; namespace App\Services\PressRelease;
use App\Enums\ActivityType; use App\Enums\ActivityType;
use App\Enums\AiTextDeclaration;
use App\Enums\ArchiveEvent; use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory; use App\Enums\ArchiveReasonCategory;
use App\Enums\EmailNotificationType; use App\Enums\EmailNotificationType;
@ -1012,7 +1013,15 @@ class PressReleaseService
*/ */
private function assertAiDeclarationComplete(PressRelease $pressRelease): void private function assertAiDeclarationComplete(PressRelease $pressRelease): void
{ {
if ($pressRelease->ai_text_declaration === null || $pressRelease->ai_declaration_confirmed_at === null) { if ($pressRelease->ai_text_declaration === null) {
throw new AiDeclarationRequiredException;
}
// „Keine Angabe“ (Alt-Schnittstelle, RP-09) ist bewusst ohne
// Bestätigung: Es gibt nichts zu bestätigen. Alle anderen Werte
// brauchen die verbindliche Bestätigung des Einreichers.
if ($pressRelease->ai_text_declaration !== AiTextDeclaration::NotDeclared
&& $pressRelease->ai_declaration_confirmed_at === null) {
throw new AiDeclarationRequiredException; throw new AiDeclarationRequiredException;
} }
@ -1107,14 +1116,18 @@ class PressReleaseService
// API-Einlieferer (z. B. connektar) nicht zuspammen (Entscheidung // API-Einlieferer (z. B. connektar) nicht zuspammen (Entscheidung
// 10.07.2026): Statt-Mail an den Pressekontakt der PM, sonst an die // 10.07.2026): Statt-Mail an den Pressekontakt der PM, sonst an die
// Firmen-E-Mail; nur ohne beides an den Einlieferer selbst. Marker // Firmen-E-Mail; nur ohne beides an den Einlieferer selbst. Marker
// ist die Quelle des letzten Review-Uebergangs im Status-Log — der // ist ein Review-Uebergang aus der API im Status-Log — der Kontotyp
// Kontotyp ist nicht verlaesslich (connektar ist z. B. 'agency'). // ist nicht verlaesslich (connektar ist z. B. 'agency'). Bewusst
// irgendeiner, nicht der letzte: Reicht der Pressekontakt eine
// abgelehnte API-Meldung im Kundenbereich neu ein (Roadmap 6.18),
// bleibt es eine API-Meldung, und das Ergebnis geht an ihn statt an
// das Konto des Einlieferers.
$recipient = $user->email; $recipient = $user->email;
$recipientName = $user->name; $recipientName = $user->name;
$isApiSubmitter = $pressRelease->statusLogs() $isApiSubmitter = $pressRelease->statusLogs()
->where('to_status', PressReleaseStatus::Review->value) ->where('to_status', PressReleaseStatus::Review->value)
->orderByDesc('id') ->where('source', 'api')
->value('source') === 'api'; ->exists();
if ($isApiSubmitter) { if ($isApiSubmitter) {
// Scope-frei auflösen: der Mail-Versand läuft auch aus Jobs/ // Scope-frei auflösen: der Mail-Versand läuft auch aus Jobs/

View file

@ -0,0 +1,27 @@
<?php
namespace App\Services\Spam;
use App\Enums\BotCheckStatus;
/**
* Ergebnis einer Turnstile-Prüfung samt technischem Grund (für Log und
* Markierung, nie für den Nutzer).
*/
final readonly class BotCheck
{
public function __construct(
public BotCheckStatus $status,
public string $reason = '',
) {}
public function blocks(): bool
{
return $this->status->blocks();
}
public function needsReview(): bool
{
return $this->status === BotCheckStatus::Unavailable;
}
}

View file

@ -0,0 +1,136 @@
<?php
namespace App\Services\Spam;
use App\Rules\NoPromotionalText;
use App\Services\PressRelease\BlacklistService;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
/**
* Inhaltsfilter des Kontaktformulars für nicht angemeldete Absender
* (Spamschutz B2). Gemessen an 141 Anfragen aus 30 Tagen bis 07.10.2026:
* 56 mit Link, 43 mit Floskel plus Seitentitel, dazu Zeichensalat.
*
* Ein Treffer blockiert nicht, sondern legt die Anfrage in die Quarantäne
* (ContactMessageStatus::Spam) — Fehlalarme holt der Admin zurück.
*/
class ContactSpamFilter
{
/** Feld mit dem verschlüsselten Zeitpunkt, zu dem das Formular ausgeliefert wurde. */
public const STARTED_FIELD = 'form_started';
/** Schneller füllt kein Mensch Name, E-Mail und Nachricht aus. */
private const MIN_SECONDS = 3;
/** HTML-Links und BBCode, die das Link-Muster nicht abdeckt. */
private const MARKUP_PATTERN = '~<a\s|\[url[\]=]~iu';
public function __construct(private readonly BlacklistService $blacklist) {}
public static function startedToken(): string
{
return Crypt::encryptString((string) now()->getTimestamp());
}
/**
* Grund (technischer Schlüssel) oder null, wenn die Anfrage sauber ist.
*/
public function reasonFor(string $name, string $message, ?string $startedToken): ?string
{
if ($this->containsForeignLink($message)) {
return 'link';
}
if ($this->quotesPageTitle($message)) {
return 'page_title';
}
if (GibberishDetector::isRandomToken($name) || GibberishDetector::isRandomToken($message)) {
return 'gibberish';
}
if ($this->blacklist->find($name.' '.$message) !== null) {
return 'blacklist';
}
if ($this->submittedTooFast($startedToken)) {
return 'too_fast';
}
return null;
}
/**
* Gäste dürfen keine Links schicken (Entscheidung 5) — außer die Nachricht
* verweist auf eine unserer Seiten: Korrekturwünsche nennen die eigene
* Pressemitteilung und oft daneben die Firmen-Website (KT-32, KT-161).
*/
private function containsForeignLink(string $message): bool
{
if (preg_match(self::MARKUP_PATTERN, $message) === 1) {
return true;
}
if (preg_match(NoPromotionalText::LINK_PATTERN, $message) !== 1) {
return false;
}
$ownHosts = collect((array) config('domains.domains', []))
->pluck('domain_name')
->filter()
->map(fn (string $host): string => preg_quote(Str::after($host, 'www.'), '~'))
->unique()
->implode('|');
if ($ownHosts === '') {
return true;
}
// Mit Schema („https://presseecho.de“) oder mit Pfad („presseecho.de/de/…“).
$ownLink = '~https?://(?:www\.)?(?:'.$ownHosts.')\b|\b(?:www\.)?(?:'.$ownHosts.')/~i';
return preg_match($ownLink, $message) !== 1;
}
/**
* Formular-Bots hängen den Seitentitel an eine Floskel: „Ich wünsche weitere
* Informationen … — aktuelle pressemitteilungen aus der deutschen wirtschaft.“
*/
private function quotesPageTitle(string $message): bool
{
$haystack = Str::lower($message);
foreach ((array) config('domains.domains', []) as $domain) {
foreach (preg_split('/\s[–-]\s/u', (string) ($domain['brand']['meta_title'] ?? '')) ?: [] as $segment) {
$segment = Str::lower(trim($segment));
if (mb_strlen($segment) >= 20 && str_contains($haystack, $segment)) {
return true;
}
}
}
return false;
}
/**
* Fehlt der Zeitstempel oder ist er manipuliert, entscheidet Turnstile —
* dieser Filter greift nur bei nachweislich zu schnellem Absenden.
*/
private function submittedTooFast(?string $startedToken): bool
{
if (blank($startedToken)) {
return false;
}
try {
$startedAt = (int) Crypt::decryptString($startedToken);
} catch (DecryptException) {
return false;
}
return now()->getTimestamp() - $startedAt < self::MIN_SECONDS;
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Services\Spam;
/**
* Erkennt Zufallszeichenketten, wie sie Bots in Namensfelder schreiben
* („ruLrlotwaCoVStmZetSxD“, „NAEWTRER2720160NEYRTHYT“).
*
* Beide Regeln sind am Bestand vom 07.10.2026 gemessen: keine einzige
* Person unter rund 67.000 Konten und 0 Kontakte getroffen.
*/
final class GibberishDetector
{
/**
* Personenname (Registrierung): ein einziges Wort aus mindestens 12
* Buchstaben mit mindestens drei Großbuchstaben nach dem ersten Zeichen.
* Trifft im Bestand 511 Bot-Konten und keinen echten Namen. Firmennamen
* wie „StammZellenPowerTeam“ fielen darunter — darum nur für Personen.
*/
public static function isRandomPersonName(string $name): bool
{
$name = trim($name);
return preg_match('/^[A-Za-z]{12,}$/', $name) === 1
&& preg_match_all('/[A-Z]/', substr($name, 1)) >= 3
&& preg_match_all('/[a-z]/', $name) >= 3;
}
/**
* Freies Namensfeld, das auch Firmen tragen kann (Kontaktformular):
* mindestens vier Wechsel von klein auf groß („…aCoVStmZet…“), oder ein
* Block Großbuchstaben + Ziffern + Großbuchstaben.
*/
public static function isRandomToken(string $value): bool
{
$value = trim($value);
if (preg_match('/^[A-Za-z]{12,}$/', $value) === 1 && preg_match_all('/[a-z][A-Z]/', $value) >= 4) {
return true;
}
return preg_match('/[A-Z]{5,}\d{4,}[A-Z]{5,}/', $value) === 1;
}
}

View file

@ -0,0 +1,29 @@
<?php
namespace App\Services\Spam;
/**
* Hilfen für den Log-Kanal `spam`: nur Netz-Präfix statt voller IP, nie
* Inhalte — der Kanal zählt abgewiesene Anfragen, er archiviert sie nicht.
*/
final class SpamLog
{
public static function ipPrefix(?string $ip): ?string
{
if ($ip === null || $ip === '') {
return null;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return implode('.', array_slice(explode('.', $ip), 0, 3)).'.x';
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$expanded = explode(':', (string) inet_ntop((string) inet_pton($ip)));
return implode(':', array_slice($expanded, 0, 3)).'::/48';
}
return null;
}
}

View file

@ -0,0 +1,153 @@
<?php
namespace App\Services\Spam;
use App\Enums\BotCheckStatus;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Serverseitige Prüfung des Cloudflare-Turnstile-Tokens
* (dev/sicherheit/ENTWICKLUNGSPLAN-SPAMSCHUTZ-2026-10-09.md, B1).
*
* Entscheidung 09.10.2026: Ist Cloudflare nicht erreichbar, wird nicht
* blockiert, sondern durchgelassen und markiert (BotCheckStatus::Unavailable).
* Das gilt für drei Fälle:
* - Siteverify antwortet nicht (Timeout, Netzfehler, 5xx, internal-error),
* - unsere Keys werden abgelehnt (Konfigurationsfehler, nicht Schuld des Nutzers),
* - das Widget konnte im Browser nicht laden (Feld `turnstile_unavailable`).
* Der letzte Fall ist vom Absender behauptet — darum bleibt er sichtbar
* markiert und zählt in der Betriebsüberwachung. Wird er missbraucht,
* schaltet TURNSTILE_FAIL_OPEN=false auf Ablehnen um.
*
* Die Hostname-Bindung prüft Cloudflare selbst (Widget-Einstellungen);
* hier zählt zusätzlich die Aktion, damit ein Token nur für das Formular
* gilt, für das es gelöst wurde.
*/
class TurnstileVerifier
{
public const ENDPOINT = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
/** Name des Token-Felds, das das Widget im Formular anlegt. */
public const TOKEN_FIELD = 'cf-turnstile-response';
/** Vom Widget gesetzt, wenn das Skript im Browser nicht lud. */
public const UNAVAILABLE_FIELD = 'turnstile_unavailable';
private const MAX_TOKEN_LENGTH = 2048;
public static function enabled(): bool
{
return filled(config('services.turnstile.site_key')) && filled(config('services.turnstile.secret_key'));
}
/**
* Prüft ein klassisches Formular (Token und Ausfall-Flag im Request).
*/
public function verifyRequest(Request $request, string $action): BotCheck
{
return $this->verify(
$request->string(self::TOKEN_FIELD)->toString(),
$request->boolean(self::UNAVAILABLE_FIELD),
$action,
$request->ip(),
);
}
public function verify(?string $token, bool $clientUnavailable, string $action, ?string $ip): BotCheck
{
if (! self::enabled()) {
return new BotCheck(BotCheckStatus::Skipped);
}
$token = trim((string) $token);
if ($token === '') {
return $clientUnavailable
? $this->unavailable('widget_not_loaded', $action, $ip)
: $this->failed('missing_token', $action, $ip);
}
if (strlen($token) > self::MAX_TOKEN_LENGTH) {
return $this->failed('token_too_long', $action, $ip);
}
try {
$response = Http::asForm()
->timeout((int) config('services.turnstile.timeout', 3))
->post(self::ENDPOINT, array_filter([
'secret' => config('services.turnstile.secret_key'),
// Kein `remoteip`: Cloudflare braucht die IP für die
// Token-Prüfung nicht (optional laut Doku); Rechtspaket
// 10/2026 verzichtet auf die zusätzliche Übermittlung.
'response' => $token,
]));
} catch (ConnectionException) {
return $this->unavailable('siteverify_unreachable', $action, $ip);
}
if (! $response->successful()) {
return $this->unavailable('siteverify_http_'.$response->status(), $action, $ip);
}
$errors = (array) $response->json('error-codes', []);
if ($response->json('success') !== true) {
if (in_array('internal-error', $errors, true)) {
return $this->unavailable('siteverify_internal_error', $action, $ip);
}
if (array_intersect(['missing-input-secret', 'invalid-input-secret'], $errors) !== []) {
// Unser Fehler, nicht der des Absenders — laut melden, nicht blockieren.
Log::error('Turnstile lehnt den Secret-Key ab — TURNSTILE_SECRET_KEY prüfen.', ['errors' => $errors]);
return $this->unavailable('invalid_secret', $action, $ip);
}
return $this->failed(implode(',', $errors) ?: 'rejected', $action, $ip);
}
$returnedAction = (string) $response->json('action', '');
// Die Cloudflare-Testkeys (1x…/2x…/3x…) liefern keine echte Aktion.
if (! $this->usesTestSecret() && $returnedAction !== $action) {
return $this->failed('action_mismatch', $action, $ip);
}
return new BotCheck(BotCheckStatus::Passed);
}
private function unavailable(string $reason, string $action, ?string $ip): BotCheck
{
$this->log('Bot-Prüfung nicht verfügbar — durchgelassen und markiert.', $reason, $action, $ip);
if (! config('services.turnstile.fail_open', true)) {
return new BotCheck(BotCheckStatus::Failed, $reason);
}
return new BotCheck(BotCheckStatus::Unavailable, $reason);
}
private function failed(string $reason, string $action, ?string $ip): BotCheck
{
$this->log('Bot-Prüfung nicht bestanden.', $reason, $action, $ip);
return new BotCheck(BotCheckStatus::Failed, $reason);
}
private function log(string $message, string $reason, string $action, ?string $ip): void
{
Log::channel('spam')->info($message, [
'form' => $action,
'reason' => $reason,
'ip_prefix' => SpamLog::ipPrefix($ip),
]);
}
private function usesTestSecret(): bool
{
return (bool) preg_match('/^[123]x0{20,}/', (string) config('services.turnstile.secret_key'));
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Services\Web;
use App\Mail\ContactRequestSubmitted;
use App\Models\ContactMessage;
use Illuminate\Support\Facades\Mail;
/**
* Benachrichtigt die Brand-Kontaktadresse über ein Kontakt-Anliegen
* (config domains.domains.{portal}.brand.contact_email, Fallback:
* Absenderadresse der Anwendung). Läuft beim Eingang und beim Zurückholen
* eines Fehlalarms aus der Spam-Quarantäne.
*/
class ContactMessageNotifier
{
public function notify(ContactMessage $message, ?string $host = null): void
{
Mail::to($this->recipient($message))->queue((new ContactRequestSubmitted(
senderName: (string) $message->name,
senderEmail: (string) $message->email,
messageText: (string) $message->message,
host: $host ?? $this->hostOf($message),
topicLabel: $message->topic->label(),
pageUrl: $message->page_url,
))->afterCommit());
}
private function recipient(ContactMessage $message): string
{
return config('domains.domains.'.$message->portal.'.brand.contact_email')
?? config('mail.from.address');
}
private function hostOf(ContactMessage $message): string
{
return parse_url((string) $message->page_url, PHP_URL_HOST)
?: (string) config('domains.domains.'.$message->portal.'.domain_name', $message->portal);
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Support;
/**
* Content Security Policy, vorerst nur im Melde-Modus
* (`Content-Security-Policy-Report-Only`, Sicherheitsaudit 07.10.2026,
* Phase 4). Browser blockieren nichts, sondern melden Verstöße an
* `/csp-report` (storage/logs/csp-*.log). Erst wenn die Meldungen zeigen,
* dass die Liste vollständig ist, wird sie scharf geschaltet.
*
* `'unsafe-inline'` und `'unsafe-eval'` sind für Livewire/Alpine und die
* Inline-Skripte der Layouts vorerst nötig; die Policy begrenzt daher vor
* allem, von wo Skripte geladen und wohin Daten geschickt werden dürfen,
* sowie Einbettung, Formularziele und `<base>`.
*/
class ContentSecurityPolicy
{
public const REPORT_PATH = '/csp-report';
public static function header(): string
{
$own = array_merge(["'self'"], self::ownOrigins());
$umami = config('services.umami.enabled') ? self::originOf((string) config('services.umami.script_url')) : null;
$errors = self::originOf((string) config('monitoring.browser.dsn'));
// Cloudflare Turnstile (Spamschutz): Skript und Challenge-iframe.
$turnstile = 'https://challenges.cloudflare.com';
$directives = [
'default-src' => ["'self'"],
'script-src' => [...$own, "'unsafe-inline'", "'unsafe-eval'", $umami, $turnstile],
'style-src' => [...$own, "'unsafe-inline'"],
'img-src' => [...$own, 'data:', 'blob:', 'https:'],
'font-src' => [...$own, 'data:'],
'connect-src' => [...$own, $umami, $errors],
'media-src' => [...$own],
'object-src' => ["'none'"],
'frame-src' => ["'self'", $turnstile],
'frame-ancestors' => ["'self'"],
'base-uri' => ["'self'"],
'form-action' => [...$own, 'https://accounts.google.com', 'https://checkout.stripe.com', 'https://billing.stripe.com'],
'report-uri' => [self::REPORT_PATH],
];
return collect($directives)
->map(fn (array $sources, string $directive): string => trim($directive.' '.implode(' ', array_unique(array_filter($sources)))))
->implode('; ');
}
/**
* Die drei Domains der Anwendung — Formulare und Assets können
* domainübergreifend auf die anderen zeigen.
*
* @return list<string>
*/
private static function ownOrigins(): array
{
return collect([
config('domains.domain_portal'),
config('domains.domain_presseecho'),
config('domains.domain_businessportal'),
])->filter()->map(fn (string $host): string => 'https://'.$host)->values()->all();
}
private static function originOf(string $url): ?string
{
$parts = parse_url($url);
if (! is_array($parts) || empty($parts['host'])) {
return null;
}
return ($parts['scheme'] ?? 'https').'://'.$parts['host'].(isset($parts['port']) ? ':'.$parts['port'] : '');
}
}

View file

@ -4,6 +4,7 @@ namespace App\Support\Monitoring;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Sentry\Breadcrumb;
use Sentry\Event; use Sentry\Event;
use Sentry\EventHint; use Sentry\EventHint;
use Sentry\UserDataBag; use Sentry\UserDataBag;
@ -41,6 +42,8 @@ class ErrorReportScrubber
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null); $event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
$event->setRequest(self::scrubRequest($event->getRequest())); $event->setRequest(self::scrubRequest($event->getRequest()));
$event->setBreadcrumb(array_map(self::scrubBreadcrumb(...), $event->getBreadcrumbs()));
foreach ($event->getExceptions() as $exception) { foreach ($event->getExceptions() as $exception) {
$exception->setValue(self::scrubText($exception->getValue())); $exception->setValue(self::scrubText($exception->getValue()));
} }
@ -58,6 +61,20 @@ class ErrorReportScrubber
return $event; return $event;
} }
public static function scrubBreadcrumb(Breadcrumb $breadcrumb): Breadcrumb
{
$message = $breadcrumb->getMessage();
return new Breadcrumb(
$breadcrumb->getLevel(),
$breadcrumb->getType(),
$breadcrumb->getCategory(),
$message !== null ? self::scrubText($message) : null,
[],
$breadcrumb->getTimestamp(),
);
}
/** /**
* @param array<string, mixed> $request * @param array<string, mixed> $request
* @return array<string, mixed> * @return array<string, mixed>

View file

@ -1,5 +1,6 @@
<?php <?php
use App\Http\Controllers\CspReportController;
use App\Http\Controllers\HealthController; use App\Http\Controllers\HealthController;
use App\Http\Middleware\BasicAuthMiddleware; use App\Http\Middleware\BasicAuthMiddleware;
use App\Http\Middleware\EnsureSessionIsNotRevoked; use App\Http\Middleware\EnsureSessionIsNotRevoked;
@ -12,6 +13,7 @@ use App\Http\Middleware\ReportErrorSignals;
use App\Http\Middleware\SecurityHeaders; use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SetCurrentPortal; use App\Http\Middleware\SetCurrentPortal;
use App\Http\Middleware\SetEdition; use App\Http\Middleware\SetEdition;
use App\Support\ContentSecurityPolicy;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@ -19,8 +21,11 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Laravel\Sanctum\Http\Middleware\CheckAbilities; use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility; use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
use Livewire\Features\SupportFileUploads\MissingFileUploadsTraitException;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Sentry\Laravel\Integration; use Sentry\Laravel\Integration;
use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@ -38,6 +43,12 @@ return Application::configure(basePath: dirname(__DIR__))
Route::get('/health', HealthController::class) Route::get('/health', HealthController::class)
->middleware('throttle:30,1') ->middleware('throttle:30,1')
->name('health.deep'); ->name('health.deep');
// CSP-Meldungen der Browser (Melde-Modus, Sicherheitsaudit
// 07.10.2026) — ohne web-Gruppe: keine Session, kein CSRF.
Route::post(ContentSecurityPolicy::REPORT_PATH, CspReportController::class)
->middleware('throttle:60,1')
->name('csp.report');
}, },
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
@ -118,16 +129,18 @@ return Application::configure(basePath: dirname(__DIR__))
// ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand // ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand
// von config('auth.basic.*') (greift auch unter `config:cache`). // von config('auth.basic.*') (greift auch unter `config:cache`).
$middleware->append(BasicAuthMiddleware::class); $middleware->append(BasicAuthMiddleware::class);
// Trust all proxies (for Traefik/Docker setup) // Welchen Proxies vertraut wird, steht in config/trustedproxy.php:
// lokal hinter Traefik allen, auf Live (nginx direkt, kein Proxy)
// keinem — sonst könnte jeder Client per X-Forwarded-For seine IP
// fälschen und damit IP-Limits und Protokolle umgehen.
$middleware->trustProxies( $middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR | headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO Request::HEADER_X_FORWARDED_PROTO
); );
// Host-Whitelist: Da allen Proxies inkl. X-Forwarded-Host vertraut wird, // Host-Whitelist: Wo Proxies inkl. X-Forwarded-Host vertraut wird,
// muss die App fremde Host-Header verwerfen — sonst kann ein Client per // muss die App fremde Host-Header verwerfen — sonst kann ein Client per
// X-Forwarded-Host die URL-Generierung vergiften (Passwort-Reset-/Verify- // X-Forwarded-Host die URL-Generierung vergiften (Passwort-Reset-/Verify-
// Mails, signierte Links, Cache-Poisoning). Nur die konfigurierten // Mails, signierte Links, Cache-Poisoning). Nur die konfigurierten
@ -167,4 +180,18 @@ return Application::configure(basePath: dirname(__DIR__))
// Vertippte artisan-Befehle sind kein Anwendungsfehler (GlitchTip // Vertippte artisan-Befehle sind kein Anwendungsfehler (GlitchTip
// server-pressekonto, Issue 9: „config:cachels“ auf Live). // server-pressekonto, Issue 9: „config:cachels“ auf Live).
$exceptions->dontReport(CommandNotFoundException::class); $exceptions->dontReport(CommandNotFoundException::class);
// Manipulierte Livewire-Anfragen (Scanner, die gesperrte Properties
// setzen oder Uploads an Komponenten ohne Upload schicken) als 400
// beantworten statt als 500 bzw. 419 — Livewire weist sie korrekt ab,
// sie sind kein Anwendungsfehler und kein abgelaufenes Formular
// (GlitchTip server-pressekonto, Issues 38 und 39, 08.10.2026).
$exceptions->map(
CannotUpdateLockedPropertyException::class,
fn (CannotUpdateLockedPropertyException $e): BadRequestHttpException => new BadRequestHttpException('Ungültige Livewire-Anfrage.', $e),
);
$exceptions->map(
MissingFileUploadsTraitException::class,
fn (MissingFileUploadsTraitException $e): BadRequestHttpException => new BadRequestHttpException('Ungültige Livewire-Anfrage.', $e),
);
})->create(); })->create();

Some files were not shown because too many files have changed in this diff Show more