$payload z. B. `company_id`, auf dessen Firmenseite der Login landen soll (Roadmap 1b.2) * @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon} */ public function createLoginLink( User $user, ?string $requestedIp = null, int $ttlMinutes = 15, ?ConversionSource $source = null, array $payload = [], ): 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', // Herkunft gehört auf das Token, nicht in die Session: Die Strecke // führt über zwei Domains und einen Mailclient, die Sitzung reißt // dabei genau an der interessanten Stelle (Roadmap 0.4). 'source' => $source?->value, 'payload' => $payload ?: null, 'expires_at' => $expiresAt, 'ip_requested' => $requestedIp, ]); return [ 'magic_link' => $magicLink, 'plain_token' => $plainToken, 'expires_at' => $expiresAt, ]; } /** * Link „Bild bestätigen" für den Kontoinhaber (Decision-Update E10, * Umsetzungsaudit 29.09.2026 B-10): meldet wie ein Login-Link an und * führt auf die Meldung, hat aber einen eigenen Zweck. Spätere * Login-Links entwerten ihn deshalb nicht; ein neuer Bestätigungslink * für dasselbe Bild entwertet nur den vorherigen für dieses Bild. * * @param array $payload Landeziel (`company_id`, `press_release_id`) * @return array{magic_link: MagicLink, plain_token: string, expires_at: Carbon} */ public function createImageConfirmationLink( User $user, int $imageId, array $payload, ?string $requestedIp, int $ttlMinutes, ?ConversionSource $source = null, ): array { $plainToken = Str::random(64); $expiresAt = now()->addMinutes($ttlMinutes); MagicLink::query() ->where('user_id', $user->id) ->where('purpose', MagicLink::PURPOSE_IMAGE_CONFIRMATION) ->whereNull('consumed_at') ->where('payload->image_confirmation', $imageId) ->update([ 'consumed_at' => now(), 'ip_consumed' => $requestedIp, ]); $magicLink = $user->magicLinks()->create([ 'token_hash' => hash('sha256', $plainToken), 'purpose' => MagicLink::PURPOSE_IMAGE_CONFIRMATION, 'source' => $source?->value, 'payload' => ['image_confirmation' => $imageId] + $payload, '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, ]; } }