Compare commits

..

No commits in common. "main" and "v1.3.2" have entirely different histories.
main ... v1.3.2

367 changed files with 983 additions and 23264 deletions

View file

@ -1,21 +0,0 @@
---
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.

View file

@ -1,7 +0,0 @@
# 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,14 +278,6 @@ UMAMI_ENABLED=false
# erst nach Ergänzung der Datenschutzerklärung einschalten.
UMAMI_PORTAL_ENABLED=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,
# Meldeverfahren, Richtlinien) — auf allen drei Domains identisch.
@ -354,24 +346,3 @@ IMAGE_PRECHECK=false
IMAGE_PRECHECK_STAGE=1
# IMAGE_PRECHECK_MODEL=
# 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,8 +83,3 @@ config/files/
# Produktions-Logs zur Auswertung (können personenbezogene Daten enthalten)
/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,24 +172,12 @@ look at GlitchTip or at production errors, follow
workflow and the privacy rules for error reporting. Monitoring decisions:
`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
- **Pest PHP** (v3.8+) - Modern testing framework built on PHPUnit
- Configuration: `phpunit.xml` and `tests/Pest.php`
- Test database: Uses `DB_DATABASE=testing` environment (configured in phpunit.xml)
- 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

View file

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

View file

@ -3,7 +3,7 @@
namespace App\Actions\Fortify;
use App\Models\User;
use App\Services\Auth\PasswordChanger;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
@ -12,10 +12,7 @@ class UpdateUserPassword implements UpdatesUserPasswords
use PasswordValidationRules;
/**
* 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.
* Validate and update the user's password.
*
* @param array<string, string> $input
*/
@ -28,6 +25,8 @@ class UpdateUserPassword implements UpdatesUserPasswords
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
app(PasswordChanger::class)->change($user, $input['password'], request()->hasSession() ? request()->session() : null);
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View file

@ -1,151 +0,0 @@
<?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

@ -1,54 +0,0 @@
<?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

@ -1,61 +0,0 @@
<?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

@ -1,145 +0,0 @@
<?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

@ -1,60 +0,0 @@
<?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

@ -1,74 +0,0 @@
<?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

@ -1,103 +0,0 @@
<?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

@ -1,107 +0,0 @@
<?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
* 16.09.2026, dev/archive/legacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B).
* 16.09.2026, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md, Phase B).
*
* `legacy:grandfather-subscriptions` hat Bestandsschutz-Posten auch aus
* Rechnungen abgeleitet, die nie bezahlt wurden. Dieser Befehl löst das

View file

@ -44,12 +44,6 @@ enum ActivityType: string
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
{
return match ($this) {
@ -79,8 +73,6 @@ enum ActivityType: string
self::LoginFailed => 'Anmeldung fehlgeschlagen',
self::LoginLockout => 'Anmeldesperre',
self::TwoFactorFailed => '2FA fehlgeschlagen',
self::BotCheckUnavailable => 'Ohne Bot-Prüfung',
self::BoostEnded => 'Boost beendet',
};
}
@ -110,8 +102,6 @@ enum ActivityType: string
self::ManualInvoice => 'amber',
self::LoginFailed, self::TwoFactorFailed => 'orange',
self::LoginLockout => 'red',
self::BotCheckUnavailable => 'orange',
self::BoostEnded => 'amber',
};
}
}

View file

@ -22,14 +22,6 @@ enum AiTextDeclaration: string
case MostlyAi = 'mostly_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).
*/
@ -38,7 +30,6 @@ enum AiTextDeclaration: string
return match ($this) {
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::NotDeclared => __('Keine Angabe (über die bisherige Schnittstelle eingereicht).'),
};
}
@ -50,7 +41,7 @@ enum AiTextDeclaration: string
public function publicLabel(): ?string
{
return match ($this) {
self::None, self::Assisted, self::NotDeclared => null,
self::None, self::Assisted => null,
self::MostlyAi, self::FullyAi => __('KI-generiert: Nach Angaben des einreichenden Unternehmens wurde diese Pressemitteilung ganz oder überwiegend mit generativer KI erstellt.'),
};
}
@ -62,7 +53,7 @@ enum AiTextDeclaration: string
public function formOption(): self
{
return match ($this) {
self::None, self::Assisted, self::NotDeclared => self::None,
self::None, self::Assisted => self::None,
self::MostlyAi, self::FullyAi => self::MostlyAi,
};
}
@ -87,10 +78,6 @@ enum AiTextDeclaration: string
$options = [];
foreach (self::cases() as $case) {
if ($case === self::NotDeclared) {
continue;
}
$options[$case->value] = $case->label();
}
@ -104,15 +91,4 @@ enum AiTextDeclaration: string
{
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

@ -1,27 +0,0 @@
<?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

@ -1,48 +0,0 @@
<?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

@ -1,28 +0,0 @@
<?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,9 +9,6 @@ enum ContactMessageStatus: string
case Resolved = 'resolved';
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
{
return match ($this) {
@ -19,7 +16,6 @@ enum ContactMessageStatus: string
self::InProgress => 'In Bearbeitung',
self::Resolved => 'Erledigt',
self::Rejected => 'Abgelehnt',
self::Spam => 'Spam',
};
}

View file

@ -12,21 +12,11 @@ namespace App\Enums;
* 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
* 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
{
case MailLinkClicked = 'mail_link_clicked';
case ManagePageOpened = 'manage_page_opened';
case AccessRequested = 'access_requested';
case MagicLinkConsumed = 'magic_link_consumed';
case NewsroomEdited = 'newsroom_edited';
@ -42,12 +32,10 @@ enum ConversionEventType: string
{
return match ($this) {
self::MailLinkClicked => 1,
self::ManagePageOpened => 2,
self::AccessRequested => 3,
self::MagicLinkConsumed => 4,
self::NewsroomEdited => 5,
self::AccountCreated => 6,
self::FirstBooking => 7,
self::MagicLinkConsumed => 2,
self::NewsroomEdited => 3,
self::AccountCreated => 4,
self::FirstBooking => 5,
};
}
@ -55,8 +43,6 @@ enum ConversionEventType: string
{
return match ($this) {
self::MailLinkClicked => 'Mail-Link geklickt',
self::ManagePageOpened => 'Verwaltungsseite geöffnet',
self::AccessRequested => 'Anmeldelink angefordert',
self::MagicLinkConsumed => 'Magic Link eingelöst',
self::NewsroomEdited => 'Newsroom bearbeitet',
self::AccountCreated => 'Konto angelegt',
@ -72,9 +58,7 @@ enum ConversionEventType: string
{
return match ($this) {
self::MailLinkClicked => 'Betreff oder Anlass trägt nicht',
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::MagicLinkConsumed => 'Der Verwaltungszugang wird nicht verstanden',
self::NewsroomEdited => 'Kein Anreiz, das Profil zu pflegen',
self::AccountCreated => 'Der Nutzen des Kontos ist nicht erkennbar',
self::FirstBooking => 'Preis oder Angebot passt nicht',

View file

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

View file

@ -3,21 +3,78 @@
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\SubscribeNewsletterRequest;
use App\Models\NewsletterSubscription;
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
{
public function store(): JsonResponse
public function store(SubscribeNewsletterRequest $request): 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([
'message' => 'Newsletter subscription via the API has been discontinued.',
], 410);
'message' => 'Newsletter subscription created.',
'data' => [
'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.
'status' => PressReleaseStatus::Draft->value,
// Legacy-/service-Clients kennen die KI-Kennzeichnung (Art. 50
// KI-VO) nicht. Rechtspaket 10/2026 (RP-09): eine fehlende Angabe
// wird als „keine Angabe“ gespeichert, nicht als Erklärung „ohne
// KI“ im Namen des Kunden. v1-Clients deklarieren explizit beim
// Submit.
// KI-VO) nicht — Entscheidung 10.07.: fehlende Angabe gilt als
// „ohne generative KI erstellt"; das API-Kunden-Mailing (R6.4)
// weist darauf hin, dass die weitere Nutzung diese Erklärung
// darstellt. v1-Clients deklarieren explizit beim Submit.
...($this->isLegacyServiceRequest($request) ? [
'ai_text_declaration' => AiTextDeclaration::NotDeclared->value,
'ai_declaration_confirmed_at' => null,
'ai_text_declaration' => AiTextDeclaration::None->value,
'ai_declaration_confirmed_at' => now(),
] : []),
]);
$pressRelease->slug = $pressRelease->generateUniqueSlug($validated['title'], [
@ -197,7 +197,7 @@ class PressReleaseController extends Controller
// Kenntnisnahme der dauerhaften Archivierung (AGB § 13) —
// Entscheidung 10.07.2026.
$validated = $request->validate([
'ai_text_declaration' => ['required', Rule::in(AiTextDeclaration::declarableValues())],
'ai_text_declaration' => ['required', Rule::enum(AiTextDeclaration::class)],
]);
$pressRelease = $this->findOwnedPressRelease($pressRelease, $request);

View file

@ -2,11 +2,9 @@
namespace App\Http\Controllers\Auth;
use App\Enums\SecurityChange;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use App\Services\Auth\SecurityNotifier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -17,7 +15,7 @@ class ConfirmEmailChangeController extends Controller
* signierten Link aus der Mail an die NEUE Adresse geklickt hat.
* Bis dahin bleibt die alte Adresse aktiv (kein Aussperren bei Tippfehlern).
*/
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess, SecurityNotifier $notifier): RedirectResponse
public function __invoke(Request $request, User $user, string $hash, ContactAccessService $contactAccess): RedirectResponse
{
abort_unless($request->user()->is($user), 403);
@ -36,8 +34,6 @@ class ConfirmEmailChangeController extends Controller
->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([
'email' => $user->pending_email,
'email_verified_at' => now(),
@ -49,11 +45,6 @@ class ConfirmEmailChangeController extends Controller
// vergeben – nicht erst beim nächsten Login.
$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')
->with('security-status', __('Ihre E-Mail-Adresse wurde erfolgreich geändert.'));
}

View file

@ -3,24 +3,17 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\LoginCompletion;
use App\Services\Auth\SocialAccountLinker;
use App\Services\Auth\SocialAuthService;
use App\Services\Auth\SocialLoginResult;
use App\Support\LoginRedirect;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Laravel\Socialite\AbstractUser;
use Laravel\Socialite\Facades\Socialite;
/**
* Google-Login (WS-6). Erst-Login legt – de-dupliziert über die E-Mail – einen
* aktiven, verifizierten customer-Account an; bestehende Accounts werden
* 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.
* aktiven, verifizierten customer-Account an; bestehende Accounts werden nur
* verknüpft. Deaktivierte Accounts werden nicht reaktiviert.
*/
class GoogleController extends Controller
{
@ -29,72 +22,29 @@ class GoogleController extends Controller
return Socialite::driver('google')->redirect();
}
public function callback(Request $request, SocialAuthService $social, SocialAccountLinker $linker, LoginCompletion $completion): RedirectResponse
public function callback(Request $request, SocialAuthService $social, LoginCompletion $completion): RedirectResponse
{
try {
$googleUser = Socialite::driver('google')->user();
} catch (\Throwable) {
return $this->failed(__('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'));
return redirect()->route('login')->withErrors([
'email' => __('Die Anmeldung mit Google ist fehlgeschlagen. Bitte versuchen Sie es erneut.'),
]);
}
$raw = $googleUser instanceof AbstractUser ? $googleUser->getRaw() : [];
$providerId = (string) $googleUser->getId();
$result = $social->resolve(
$user = $social->resolveUser(
'google',
$providerId,
(string) $googleUser->getId(),
$googleUser->getEmail(),
$googleUser->getName(),
emailVerified: filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
hostedDomain: isset($raw['hd']) ? (string) $raw['hd'] : null,
);
if ($result->outcome === SocialLoginResult::UNVERIFIED_EMAIL) {
return $this->failed(__('Google hat keine bestätigte E-Mail-Adresse übermittelt. Bitte melden Sie sich per E-Mail-Link an.'));
}
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.'));
return redirect()->route('login')->withErrors([
'email' => __('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,
// sondern in Fortifys Challenge übergeben (Audit F02). Google belegt
// den Besitz des Postfachs, nicht den zweiten Faktor.
@ -115,9 +65,4 @@ class GoogleController extends Controller
LoginRedirect::homeFor($user),
));
}
private function failed(string $message): RedirectResponse
{
return redirect()->route('login')->withErrors(['email' => $message]);
}
}

View file

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

View file

@ -1,65 +0,0 @@
<?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

@ -1,81 +0,0 @@
<?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,21 +2,14 @@
namespace App\Http\Controllers\Web;
use App\Enums\ActivityType;
use App\Enums\BotCheckStatus;
use App\Enums\ContactMessageStatus;
use App\Enums\ContactMessageTopic;
use App\Http\Controllers\Controller;
use App\Mail\ContactRequestSubmitted;
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 Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
@ -27,21 +20,9 @@ use Illuminate\Validation\ValidationException;
* (contact_messages) und versendet zusätzlich eine Mail an die Brand-
* Kontaktadresse (config domains.*.brand.contact_email, Fallback:
* 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
{
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
{
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
@ -72,66 +53,27 @@ class ContactController extends Controller
$this->ensureNotRateLimited($request);
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;
$message = ContactMessage::create([
ContactMessage::create([
'portal' => $this->portal($request),
'topic' => $topic,
'status' => $spamReason !== null ? ContactMessageStatus::Spam : ContactMessageStatus::Open,
'name' => $validated['name'],
'email' => $validated['email'],
'message' => $validated['message'],
'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(),
'user_id' => $request->user()?->id,
]);
// Spam: gespeichert zur Nachprüfung, aber keine Mail. Der Absender
// bekommt dieselbe Bestätigung wie alle — der Bot lernt nichts.
if ($spamReason !== null) {
Log::channel('spam')->info('Kontaktanfrage in Quarantäne.', [
'reason' => $spamReason,
'portal' => $message->portal,
'ip_prefix' => SpamLog::ipPrefix($request->ip()),
]);
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());
Mail::to($this->recipient())->queue((new ContactRequestSubmitted(
senderName: $validated['name'],
senderEmail: $validated['email'],
messageText: $validated['message'],
host: $request->getHost(),
topicLabel: $topic->label(),
pageUrl: $validated['page_url'] ?? null,
))->afterCommit());
return $this->done();
}
@ -154,6 +96,19 @@ class ContactController extends Controller
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
{
if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) {

View file

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

View file

@ -33,9 +33,6 @@ class BlockUnusedFortifyRoutes
'two-factor.secret-key',
'two-factor.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

View file

@ -1,38 +0,0 @@
<?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

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

View file

@ -1,38 +0,0 @@
<?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,6 +7,7 @@ use App\Enums\ConversionSource;
use App\Models\PressRelease;
use App\Scopes\PortalScope;
use App\Services\Conversion\ConversionRecorder;
use App\Support\CrawlerIdentifier;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
@ -48,7 +49,7 @@ class RecordMailLinkClick
// Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links
// vorab per HEAD oder mit eigener Kennung; das wäre kein Klick,
// sondern eine Zustellung (Umsetzungsaudit 29.09.2026, R-08).
if (! $request->isMethod('GET') || $this->recorder->isAutomatedVisit($request->userAgent())) {
if (! $request->isMethod('GET') || $this->isScanner($request->userAgent())) {
return $next($request);
}
@ -80,6 +81,23 @@ class RecordMailLinkClick
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
* einem der beiden Spokes, der Portal-Scope würde die Meldung dort

View file

@ -2,7 +2,6 @@
namespace App\Http\Middleware;
use App\Support\ContentSecurityPolicy;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@ -11,8 +10,8 @@ use Symfony\Component\HttpFoundation\Response;
* Applikationsseitige Security-Header (R6.2) — bewusst unabhängig vom
* Reverse-Proxy gesetzt, damit der Schutz auch bei Proxy-Wechsel bleibt.
* Bereits vorhandene Header (z. B. vom Proxy) werden nicht überschrieben.
* HSTS nur auf HTTPS-Antworten und laut config/security.php.
* CSP vorerst nur im Melde-Modus (ContentSecurityPolicy, config/security.php).
* CSP fehlt absichtlich: Livewire/Alpine/Vite brauchen eine eigene
* Inline-Script-Strategie — separat einführen, nicht nebenbei.
*/
class SecurityHeaders
{
@ -31,16 +30,6 @@ 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;
}
}

View file

@ -0,0 +1,38 @@
<?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

@ -1,38 +0,0 @@
<?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

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

View file

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

View file

@ -1,42 +0,0 @@
<?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

@ -1,71 +0,0 @@
<?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

@ -1,142 +0,0 @@
<?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
{
$rules = [
'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::declarableValues())],
'aiTextDeclaration' => ['required', Rule::in(AiTextDeclaration::values())],
'aiDeclarationConfirmed' => ['accepted'],
];
$messages = [

View file

@ -7,7 +7,6 @@ use App\Enums\EmailNotificationType;
use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\MailAccessLink;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
@ -36,16 +35,6 @@ class PressReleaseHeldForReview extends Mailable
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 readonly User $user,
public readonly PressRelease $pressRelease,
@ -66,8 +55,6 @@ class PressReleaseHeldForReview extends Mailable
]);
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);
} else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);

View file

@ -7,7 +7,6 @@ use App\Enums\EmailNotificationType;
use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\MailAccessLink;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
@ -40,16 +39,6 @@ class PressReleasePublished extends Mailable
*/
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 readonly User $user,
public readonly PressRelease $pressRelease,
@ -83,8 +72,6 @@ class PressReleasePublished extends Mailable
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
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);
} else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);

View file

@ -8,7 +8,6 @@ use App\Enums\Portal;
use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\MailAccessLink;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
@ -30,16 +29,6 @@ class PressReleaseRejected extends Mailable
*/
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 readonly User $user,
public readonly PressRelease $pressRelease,
@ -64,8 +53,6 @@ class PressReleaseRejected extends Mailable
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
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);
} else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);

View file

@ -1,54 +0,0 @@
<?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

@ -1,51 +0,0 @@
<?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

@ -1,51 +0,0 @@
<?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,8 +3,6 @@
namespace App\Models;
use App\Enums\ActivityType;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
@ -15,8 +13,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
*/
class ActivityEvent extends Model
{
use MassPrunable;
public $timestamps = false;
protected $fillable = [
@ -54,14 +50,4 @@ class ActivityEvent extends Model
{
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,16 +2,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Sanctum\PersonalAccessToken;
class ApiUsageLog extends Model
{
use MassPrunable;
public $timestamps = false;
protected $fillable = [
@ -49,14 +45,4 @@ class ApiUsageLog extends Model
{
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

@ -1,71 +0,0 @@
<?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,54 +161,6 @@ class Company extends Model
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?
* Dann darf diese Firma nicht `both` werden: Die öffentliche Auflösung

View file

@ -7,7 +7,6 @@ use App\Enums\ContactMessageTopic;
use Database\Factories\ContactMessageFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -22,11 +21,6 @@ class ContactMessage extends Model
/** @use HasFactory<ContactMessageFactory> */
use HasFactory;
use MassPrunable;
/** Tage, die Spam zur Nachprüfung in der Quarantäne bleibt. */
public const SPAM_RETENTION_DAYS = 30;
protected $fillable = [
'portal',
'topic',
@ -53,87 +47,6 @@ 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}").
*/

View file

@ -13,10 +13,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* 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
* 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
{

View file

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

View file

@ -3,7 +3,6 @@
namespace App\Observers;
use App\Enums\ActivityType;
use App\Enums\ContactMessageStatus;
use App\Models\ContactMessage;
use App\Models\LegalRequest;
use App\Models\NewsletterSubscription;
@ -24,8 +23,6 @@ class ActivityLogObserver
public function created(Model $model): void
{
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(
ActivityType::ContactRequest,
sprintf('Kontaktanfrage „%s“ von %s (%s).', $model->topic->label(), $model->name, $model->email),

View file

@ -1,105 +0,0 @@
<?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,16 +2,9 @@
namespace App\Policies;
use App\Authorization\Permission;
use App\Models\LegacyInvoice;
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
{
public function before(User $user): ?bool
@ -26,7 +19,7 @@ class LegacyInvoicePolicy
public function view(User $user, LegacyInvoice $legacyInvoice): bool
{
if ($user->can(Permission::BillingManage)) {
if ($user->canAccessAdmin()) {
return true;
}

View file

@ -6,7 +6,6 @@ use App\Contracts\NewsletterSyncClient;
use App\Exceptions\MailDeliveryPausedException;
use App\Helpers\ThemeHelper;
use App\Http\Middleware\BlockAdminDuringImpersonation;
use App\Http\Middleware\EnsureStrongAdminAuthentication;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests;
@ -32,7 +31,6 @@ use App\Models\User;
use App\Models\WithdrawalRequest;
use App\Observers\ActivityLogObserver;
use App\Observers\AdminPerformanceCacheObserver;
use App\Observers\CompanyMembershipObserver;
use App\Observers\CompanyPublicCacheObserver;
use App\Observers\EmailDomainObserver;
use App\Observers\ImageRightsConfirmationObserver;
@ -66,7 +64,6 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
use Livewire\Livewire;
@ -109,18 +106,6 @@ class AppServiceProvider extends ServiceProvider
// Ausgelieferte Version in `php artisan about` (Datei RELEASE bzw. 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.
// Ein Queue-Worker lebt über beliebig viele Jobs hinweg und würde sonst
// dauerhaft mit dem Stand seines ersten Zugriffs arbeiten — Keys einer
@ -258,12 +243,6 @@ class AppServiceProvider extends ServiceProvider
Company::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
// verwerfen — auf jedem Löschweg, nicht nur im Admin-Formular (F13).
Company::observe(CompanyPublicCacheObserver::class);
@ -287,7 +266,6 @@ class AppServiceProvider extends ServiceProvider
Livewire::addPersistentMiddleware([
BlockAdminDuringImpersonation::class,
EnsureUserIsAdmin::class,
EnsureStrongAdminAuthentication::class,
EnsureUserIsCustomer::class,
LogSlowAdminRequests::class,
]);

View file

@ -6,10 +6,7 @@ use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Http\Responses\FailedTwoFactorChallengeResponse;
use App\Http\Responses\PasskeyLoginResponse;
use App\Http\Responses\RoleAwareLoginResponse;
use App\Models\User;
use App\Services\Auth\LoginIpThrottle;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
@ -20,10 +17,6 @@ use Laravel\Fortify\Contracts\FailedTwoFactorLoginResponse;
use Laravel\Fortify\Contracts\LoginResponse;
use Laravel\Fortify\Contracts\TwoFactorLoginResponse;
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
{
@ -37,7 +30,6 @@ class FortifyServiceProvider extends ServiceProvider
// Fortifys Default leitet auf die nicht registrierte Route
// `two-factor.login` (views => false) – zurück zur Livewire-Challenge.
$this->app->singleton(FailedTwoFactorLoginResponse::class, FailedTwoFactorChallengeResponse::class);
$this->app->singleton(PasskeyLoginResponseContract::class, PasskeyLoginResponse::class);
}
/**
@ -54,20 +46,6 @@ class FortifyServiceProvider extends ServiceProvider
// Keine View-Definitionen, die Auth-Seiten sind Livewire-Komponenten
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());
return Limit::perMinute(5)->by($throttleKey);
@ -76,15 +54,5 @@ class FortifyServiceProvider extends ServiceProvider
RateLimiter::for('two-factor', function (Request $request) {
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
{
/** Schema, www. — oder eine nackte Domain mit Pfad („spam.example/deal"). */
public const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
private const LINK_PATTERN = '~(?:https?://|www\.|\b[a-z0-9-]+\.[a-z]{2,}/\S)~iu';
private const EMAIL_PATTERN = '~[^\s@]+@[^\s@]+\.[a-z]{2,}~iu';

View file

@ -3,8 +3,6 @@
namespace App\Services\Admin;
use App\Authorization\Permission as PermissionCatalog;
use App\Enums\ActivityType;
use App\Models\ActivityEvent;
use App\Models\ArchiveEntry;
use App\Models\FailedLoginTally;
use App\Models\PressReleaseAttachment;
@ -13,7 +11,6 @@ use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive;
use App\Services\Monitoring\HealthProbe;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\Spam\TurnstileVerifier;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@ -241,45 +238,6 @@ class AdminSystemHealth
: '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
// der letzten sieben Tage zählen; Zustand D im Formular blockiert
// nichts, muss aber sichtbar bleiben.
@ -383,11 +341,6 @@ class AdminSystemHealth
return $checks;
}
protected function imagickAvailable(): bool
{
return class_exists(\Imagick::class);
}
/**
* @param array<string, mixed> $queue
*/

View file

@ -1,166 +0,0 @@
<?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

@ -1,11 +0,0 @@
<?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,24 +5,14 @@ namespace App\Services\Api;
use App\Models\User;
/**
* API-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein
* Token-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein
* aktives Stripe-Abo (Cashier, STR-Kreis), eine laufende Zahlungsoption
* (MAN-Kreis/Bestandsschutz) oder eine bezahlte letzte Legacy-Rechnung hat —
* spiegelbildlich zu User::hasActiveBooking(). Geprüft beim Erzeugen eines
* Tokens und bei jedem API-Aufruf.
* spiegelbildlich zu User::hasActiveBooking().
*/
class ApiAccessEligibilityService
{
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;
}

View file

@ -119,14 +119,13 @@ class ContactAccessService
* (`granted_via = manual`) bleiben, ebenso Mitgliedschaften aus der
* API-Zuordnung (`granted_via = api`, Audit R-01) — ihre Grundlage ist
* 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
{
if (! $user->is_active || ! $user->hasVerifiedEmail()) {
return;
}
$email = mb_strtolower(trim((string) $user->email));
if ($email === '') {
@ -137,15 +136,9 @@ class ContactAccessService
$companies = $this->companiesFor($email);
$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);
// Neue Rechte nur für aktive Konten mit nachgewiesener Adresse (F01).
if (! $user->is_active || ! $user->hasVerifiedEmail() || $companyIds->isEmpty()) {
if ($companyIds->isEmpty()) {
return;
}

View file

@ -1,43 +0,0 @@
<?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

@ -1,63 +0,0 @@
<?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

@ -1,41 +0,0 @@
<?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

@ -1,121 +0,0 @@
<?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

@ -1,36 +0,0 @@
<?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 Illuminate\Auth\Events\Login;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cache;
/**
* Beendet alle offenen Browser-Sessions eines Kontos (Umsetzungsaudit
@ -16,12 +16,10 @@ use Illuminate\Support\Str;
* ein Zeitstempel je Konto: Jede Anmeldung schreibt ihren Zeitpunkt in die
* Session (`Login`-Event), `revokeAllFor()` merkt sich „ungültig vor jetzt",
* und die Middleware EnsureSessionIsNotRevoked meldet jede ältere Session
* beim nächsten Request ab. 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.
* beim nächsten Request ab. Die Marke lebt so lange wie eine Session
* (`session.lifetime`) — danach ist jede ältere Session ohnehin abgelaufen.
* Remember-Cookies deckt das Rotieren von `remember_token` ab, API-Tokens
* löscht der Aufrufer.
*/
class SessionRevocation
{
@ -33,41 +31,13 @@ class SessionRevocation
/**
* Alle bis jetzt entstandenen Sessions des Kontos werden ungültig.
*/
public function revokeAllFor(User $user): int
public function revokeAllFor(User $user): void
{
$revokedAt = now()->getTimestampMs();
// Ohne Model-Events und ohne updated_at: Die Marke ist keine
// 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);
Cache::put(
$this->cacheKey((int) $user->getKey()),
now()->getTimestampMs(),
now()->addMinutes(max(1, (int) config('session.lifetime', 120)) + 5),
);
}
/**
@ -87,14 +57,19 @@ class SessionRevocation
* Session stammt aus der Zeit vor der letzten Sperre dieses Kontos.
* Ohne Anmeldezeitpunkt (Session von vor dem Deploy) gilt sie als alt.
*/
public function isRevoked(User $user, Session $session): bool
public function isRevoked(int $userId, Session $session): bool
{
$revokedAt = (int) $user->sessions_revoked_at_ms;
$revokedAt = Cache::get($this->cacheKey($userId));
if ($revokedAt === 0) {
if ($revokedAt === null) {
return false;
}
return (int) $session->get(self::SESSION_KEY, 0) < $revokedAt;
return (int) $session->get(self::SESSION_KEY, 0) < (int) $revokedAt;
}
private function cacheKey(int $userId): string
{
return 'auth.sessions-revoked-at.'.$userId;
}
}

View file

@ -1,90 +0,0 @@
<?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,79 +2,47 @@
namespace App\Services\Auth;
use App\Enums\SecurityChange;
use App\Models\User;
use Illuminate\Support\Str;
/**
* Auflösung eines Social-Logins (Google, ggf. weitere) auf einen lokalen User.
*
* Reihenfolge (Sicherheitsaudit 07.10.2026, S-05):
* 1. Ohne vom Provider bestätigte E-Mail (`email_verified`) kein Login.
* 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).
* Identität ist die E-Mail (De-Dup darüber, keine Dubletten). Der Provider
* bestätigt die E-Mail, daher gilt die Verifizierung über den Kanal als erfüllt
* (Entscheidung 15.06.) – kein zusätzlicher E-Mail-Verifizierungsschritt.
*/
class SocialAuthService
{
/**
* Domains, deren Postfächer Google selbst betreibt.
*/
private const GOOGLE_MAIL_DOMAINS = ['gmail.com', 'googlemail.com'];
public function __construct(
private readonly UserRolePermissionSyncService $roleSync,
private readonly CustomerAccountProvisioner $accountProvisioner,
private readonly SessionRevocation $sessions,
private readonly SecurityNotifier $notifier,
) {}
/**
* Ein deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer
* prüft danach is_active und blockiert.
* Liefert den (ggf. neu angelegten) User zur Social-Identität. Ein
* deaktivierter Bestands-Account wird NICHT reaktiviert – der Aufrufer prüft
* danach is_active und blockiert.
*/
public function resolve(
string $provider,
string $providerId,
?string $email,
?string $name,
bool $emailVerified,
?string $hostedDomain = null,
): SocialLoginResult {
public function resolveUser(string $provider, string $providerId, ?string $email, ?string $name): ?User
{
$email = $email ? mb_strtolower(trim($email)) : null;
if ($email === null || $email === '' || ! $emailVerified || $providerId === '') {
return SocialLoginResult::unverifiedEmail();
}
$linked = User::query()
->where('oauth_provider', $provider)
->where('oauth_provider_id', $providerId)
->first();
if ($linked) {
return SocialLoginResult::login($linked);
if ($email === null || $email === '') {
return null;
}
$user = User::findByEmail($email);
if (! $user) {
return SocialLoginResult::login($this->createUser($provider, $providerId, $email, $name));
return $this->createUser($provider, $providerId, $email, $name);
}
if (filled($user->oauth_provider_id)) {
return SocialLoginResult::providerMismatch($user);
}
$user->forceFill([
'oauth_provider' => $provider,
'oauth_provider_id' => $providerId,
]);
// Noch nicht verifiziert (offener Selbst-Registrierer): der Provider
// bestätigt die E-Mail → Onboarding abschließen (aktiv + customer).
@ -86,8 +54,6 @@ class SocialAuthService
// oder Magic Link an und setzt bei Bedarf selbst ein Passwort.
if ($user->email_verified_at === null) {
$user->forceFill([
'oauth_provider' => $provider,
'oauth_provider_id' => $providerId,
'email_verified_at' => now(),
'is_active' => true,
'password' => null,
@ -102,47 +68,14 @@ class SocialAuthService
$this->roleSync->assignRoleAndSyncPermissions($user, 'customer');
}
return SocialLoginResult::login($user);
}
if (! $this->providerOwnsMailbox($email, $hostedDomain)) {
return SocialLoginResult::confirmLink($user);
return $user;
}
// Bereits verifiziert: nur Provider verknüpfen. is_active bleibt
// unangetastet (deaktivierte Accounts werden nicht reaktiviert).
$this->link($user, $provider, $providerId);
$user->save();
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;
return $user;
}
private function createUser(string $provider, string $providerId, string $email, ?string $name): User

View file

@ -1,47 +0,0 @@
<?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

@ -1,56 +0,0 @@
<?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-
* Überweisungskunden, dev/archive/legacy-rechnungen/entwicklungsplan-2026-09-16.md):
* Überweisungskunden, dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md):
*
* senden → (erinnern) → als bezahlt markieren
* → Bestandsschutz beenden (Rechnung storniert, Bündel gekündigt)

View file

@ -1,209 +0,0 @@
<?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

@ -1,69 +0,0 @@
<?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,9 +10,6 @@ use App\Models\MagicLink;
use App\Models\PressRelease;
use App\Models\User;
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).
@ -76,83 +73,6 @@ class ConversionRecorder
$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.
*
@ -182,17 +102,13 @@ class ConversionRecorder
}
/**
* Stufe 7 der Konversionsleiter.
* Stufe 5 der Konversionsleiter.
*
* 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
* zugeordnete. Ohne Firma bleibt der Bezug leer; das Ereignis ist dann
* für die Trichter-Auswertung wertlos, aber immer noch eine ehrliche Zeile
* 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
{
@ -209,11 +125,11 @@ class ConversionRecorder
return;
}
$this->recordOnce(ConversionEventType::FirstBooking, $this->lastMagicLinkSource($user), $company);
$this->recordOnce(ConversionEventType::FirstBooking, ConversionSource::Organic, $company);
}
/**
* Stufe 5 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3).
* Stufe 3 der Konversionsleiter: Newsroom gepflegt (Roadmap 1b.3).
*
* Einmal je Firma. Die Herkunft ist die des letzten eingelösten Magic
* Links dieses Kontos — wer über „Diesen Newsroom verwalten" aus der Mail
@ -222,17 +138,8 @@ class ConversionRecorder
*/
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 {
$source = MagicLink::query()
$source = $user === null ? null : MagicLink::query()
->where('user_id', $user->getKey())
->whereIn('purpose', MagicLink::LOGIN_PURPOSES)
->whereNotNull('consumed_at')
@ -243,7 +150,7 @@ class ConversionRecorder
$source = null;
}
return ConversionSource::parse($source);
$this->recordOnce(ConversionEventType::NewsroomEdited, ConversionSource::parse($source), $company);
}
private function alreadyRecorded(ConversionEventType $type, Company $company): bool

View file

@ -1,271 +0,0 @@
<?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,7 +2,6 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\BoostPlacement;
use App\Enums\Portal;
use App\Enums\PressReleaseClassification;
@ -12,7 +11,6 @@ use App\Models\Boost;
use App\Models\PressRelease;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use App\Services\Billing\CreditPricingService;
use App\Services\Billing\CreditWalletService;
use App\Support\WebPageCache;
@ -37,7 +35,6 @@ class BoostService
public function __construct(
private readonly CreditPricingService $pricing,
private readonly CreditWalletService $wallet,
private readonly ActivityLog $activityLog,
) {}
/**
@ -183,52 +180,6 @@ class BoostService
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.
*

View file

@ -3,7 +3,6 @@
namespace App\Services\PressRelease;
use App\Enums\ActivityType;
use App\Enums\AiTextDeclaration;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\EmailNotificationType;
@ -1013,15 +1012,7 @@ class PressReleaseService
*/
private function assertAiDeclarationComplete(PressRelease $pressRelease): void
{
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) {
if ($pressRelease->ai_text_declaration === null || $pressRelease->ai_declaration_confirmed_at === null) {
throw new AiDeclarationRequiredException;
}
@ -1116,18 +1107,14 @@ class PressReleaseService
// API-Einlieferer (z. B. connektar) nicht zuspammen (Entscheidung
// 10.07.2026): Statt-Mail an den Pressekontakt der PM, sonst an die
// Firmen-E-Mail; nur ohne beides an den Einlieferer selbst. Marker
// ist ein Review-Uebergang aus der API im Status-Log — der Kontotyp
// 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.
// ist die Quelle des letzten Review-Uebergangs im Status-Log — der
// Kontotyp ist nicht verlaesslich (connektar ist z. B. 'agency').
$recipient = $user->email;
$recipientName = $user->name;
$isApiSubmitter = $pressRelease->statusLogs()
->where('to_status', PressReleaseStatus::Review->value)
->where('source', 'api')
->exists();
->orderByDesc('id')
->value('source') === 'api';
if ($isApiSubmitter) {
// Scope-frei auflösen: der Mail-Versand läuft auch aus Jobs/

View file

@ -1,27 +0,0 @@
<?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

@ -1,136 +0,0 @@
<?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

@ -1,44 +0,0 @@
<?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

@ -1,29 +0,0 @@
<?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

@ -1,153 +0,0 @@
<?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

@ -1,40 +0,0 @@
<?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

@ -1,75 +0,0 @@
<?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,7 +4,6 @@ namespace App\Support\Monitoring;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Sentry\Breadcrumb;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\UserDataBag;
@ -42,8 +41,6 @@ class ErrorReportScrubber
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
$event->setRequest(self::scrubRequest($event->getRequest()));
$event->setBreadcrumb(array_map(self::scrubBreadcrumb(...), $event->getBreadcrumbs()));
foreach ($event->getExceptions() as $exception) {
$exception->setValue(self::scrubText($exception->getValue()));
}
@ -61,20 +58,6 @@ class ErrorReportScrubber
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
* @return array<string, mixed>

View file

@ -1,6 +1,5 @@
<?php
use App\Http\Controllers\CspReportController;
use App\Http\Controllers\HealthController;
use App\Http\Middleware\BasicAuthMiddleware;
use App\Http\Middleware\EnsureSessionIsNotRevoked;
@ -13,7 +12,6 @@ use App\Http\Middleware\ReportErrorSignals;
use App\Http\Middleware\SecurityHeaders;
use App\Http\Middleware\SetCurrentPortal;
use App\Http\Middleware\SetEdition;
use App\Support\ContentSecurityPolicy;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -21,11 +19,8 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
use Livewire\Features\SupportFileUploads\MissingFileUploadsTraitException;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Sentry\Laravel\Integration;
use Symfony\Component\Console\Exception\CommandNotFoundException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@ -43,12 +38,6 @@ return Application::configure(basePath: dirname(__DIR__))
Route::get('/health', HealthController::class)
->middleware('throttle:30,1')
->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) {
@ -129,18 +118,16 @@ return Application::configure(basePath: dirname(__DIR__))
// ob erzwungen wird, entscheidet die Middleware zur Laufzeit anhand
// von config('auth.basic.*') (greift auch unter `config:cache`).
$middleware->append(BasicAuthMiddleware::class);
// 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.
// Trust all proxies (for Traefik/Docker setup)
$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO
);
// Host-Whitelist: Wo Proxies inkl. X-Forwarded-Host vertraut wird,
// Host-Whitelist: Da allen Proxies inkl. X-Forwarded-Host vertraut wird,
// muss die App fremde Host-Header verwerfen — sonst kann ein Client per
// X-Forwarded-Host die URL-Generierung vergiften (Passwort-Reset-/Verify-
// Mails, signierte Links, Cache-Poisoning). Nur die konfigurierten
@ -180,18 +167,4 @@ return Application::configure(basePath: dirname(__DIR__))
// Vertippte artisan-Befehle sind kein Anwendungsfehler (GlitchTip
// server-pressekonto, Issue 9: „config:cachels“ auf Live).
$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();

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