Phase 1: sync_locked_at auf press_releases (Importer überspringt gesperrte PMs auch bei --force), LegalRequest um Verifikation/Ausführung erweitert, verschlüsselte Vorher-Snapshots mit Löschfrist (snapshots:purge, täglich). Phase 2: AnonymizationService (Unicode-Wortgrenzen-Schwärzung, Pflicht-Diff, transaktionale Ausführung mit Snapshot, Kontakt-Detach, Bild-Löschung, Sitemap-Invalidierung, Verlauf ohne Klartext-Altwerte); Admin-Queue kann DSGVO-Anfragen anonymisieren oder depublizieren, inkl. Snapshot-Restore. Phase 3: Deflektions-Trichter „PM melden/ändern" (melden/pm) mit Kosten- Tabelle vorab; Magic-Link geht nur an die hinterlegte Pressekontaktadresse (neuer purpose press_release_report), atomare Verifikation, Anliegen-Auswahl erzeugt verifizierte Queue-Anfragen; toter-Kanal-Fallback; Owner-Einstieg. Phase 4: Panel-Abschnitt „Änderung & Korrektur" — Levenshtein-Gate (5 %, Mini-Edit-Ausnahme ≤ 2 Zeichen) stuft klein/groß ein; kleine Korrektur kostenlos sofort live (Snapshot, Sperre, Verlauf, Re-Score), größere als Change-Anfrage (Interim bis 10-€-Checkout, Phase 7). Entwicklungsplan mit Entscheidungen E1-E8 und offenen Punkten in docs/Decision-Update; 41 Pest-Tests (tests/Feature/Legal) grün. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
113 lines
3.8 KiB
PHP
113 lines
3.8 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Auth;
|
|
|
|
use App\Models\MagicLink;
|
|
use App\Models\PressRelease;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Str;
|
|
|
|
class MagicLinkGenerator
|
|
{
|
|
/**
|
|
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
|
|
*/
|
|
public function createLoginLink(User $user, ?string $requestedIp = null, int $ttlMinutes = 15): array
|
|
{
|
|
$plainToken = Str::random(64);
|
|
$tokenHash = hash('sha256', $plainToken);
|
|
$expiresAt = now()->addMinutes($ttlMinutes);
|
|
|
|
MagicLink::query()
|
|
->where('user_id', $user->id)
|
|
->where('purpose', 'login')
|
|
->whereNull('consumed_at')
|
|
->update([
|
|
'consumed_at' => now(),
|
|
'ip_consumed' => $requestedIp,
|
|
]);
|
|
|
|
$magicLink = $user->magicLinks()->create([
|
|
'token_hash' => $tokenHash,
|
|
'purpose' => 'login',
|
|
'expires_at' => $expiresAt,
|
|
'ip_requested' => $requestedIp,
|
|
]);
|
|
|
|
return [
|
|
'magic_link' => $magicLink,
|
|
'plain_token' => $plainToken,
|
|
'expires_at' => $expiresAt,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Meldeverfahren (Phase 3): Verifikations-Link an die hinterlegte
|
|
* Pressekontaktadresse. Wer den Link öffnet, ist strukturell verifiziert —
|
|
* kein Ermessen, kein Ausweis. Ältere unverbrauchte Report-Links derselben
|
|
* PM werden entwertet (nur der jüngste Link zählt).
|
|
*
|
|
* @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon}
|
|
*/
|
|
public function createReportLink(PressRelease $pressRelease, string $contactEmail, ?int $ttlHours = null): array
|
|
{
|
|
$plainToken = Str::random(64);
|
|
$tokenHash = hash('sha256', $plainToken);
|
|
$expiresAt = now()->addHours($ttlHours ?? (int) config('legal.report_link_ttl_hours'));
|
|
|
|
MagicLink::query()
|
|
->where('purpose', 'press_release_report')
|
|
->whereNull('consumed_at')
|
|
->where('payload->press_release_id', $pressRelease->id)
|
|
->update(['consumed_at' => now()]);
|
|
|
|
$magicLink = MagicLink::query()->create([
|
|
'user_id' => $pressRelease->user_id,
|
|
'token_hash' => $tokenHash,
|
|
'purpose' => 'press_release_report',
|
|
'payload' => [
|
|
'press_release_id' => $pressRelease->id,
|
|
'contact_email' => $contactEmail,
|
|
],
|
|
'expires_at' => $expiresAt,
|
|
]);
|
|
|
|
return [
|
|
'magic_link' => $magicLink,
|
|
'plain_token' => $plainToken,
|
|
'expires_at' => $expiresAt,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Public read-only share link for a press release. Token holder can view
|
|
* the press release in any state (draft/review/rejected/published) until
|
|
* the link expires. Default TTL: 7 days.
|
|
*
|
|
* @return array{magic_link: MagicLink, plain_token: string, url: string, expires_at: Carbon}
|
|
*/
|
|
public function createPressReleaseShareLink(PressRelease $pressRelease, ?User $issuer = null, int $ttlDays = 7): array
|
|
{
|
|
$plainToken = Str::random(64);
|
|
$tokenHash = hash('sha256', $plainToken);
|
|
$expiresAt = now()->addDays($ttlDays);
|
|
|
|
$magicLink = MagicLink::query()->create([
|
|
'user_id' => $issuer?->id ?? $pressRelease->user_id,
|
|
'token_hash' => $tokenHash,
|
|
'purpose' => 'press_release_access',
|
|
'payload' => [
|
|
'press_release_id' => $pressRelease->id,
|
|
],
|
|
'expires_at' => $expiresAt,
|
|
]);
|
|
|
|
return [
|
|
'magic_link' => $magicLink,
|
|
'plain_token' => $plainToken,
|
|
'url' => route('press-releases.preview', ['token' => $plainToken]),
|
|
'expires_at' => $expiresAt,
|
|
];
|
|
}
|
|
}
|