presseportale/app/Providers/AppServiceProvider.php
Kevin Adametz 214aea8910 images: Datenmodell, Textarchiv, Herkunftsprüfung und gemeinsamer Uploader für Formular v2 (Bildkennzeichnung Block A)
- press_release_images: Kundenangabe/Portalkennzeichnung getrennt, Herkunftsbefund, Beweisoriginal, Snapshot-Pfade, published_ever_at, rules_version, Fokus/Letterbox; Bestand unangetastet
- legal_texts (insert-only) mit img-confirm-v1 (alter Wortlaut), v2 (AGB 0.6), v3 (AGB 0.7, noch ohne Datum); image-rights:backfill-confirmations
- image_rights_confirmations polymorph, insert-only, IP als versionierter HMAC; Löschung nur für nie veröffentlichte Uploads
- ImageProvenanceInspector (Imagick/exif): IPTC, XMP, EXIF; algorithmicallyEnhanced nur Vermerk; unlesbare Datei = error
- PressReleaseImageUploader: Original vor Derivaten, Hash-Bindung, Titelbild-Regel unter Sperre, Dateien bei Fehlern zurückgerollt; Admin-Upload ohne Bestätigung
- ImageService: kein Rückfall auf die Quelldatei, og-Variante, Fokuspunkt und no_crop

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:22:05 +00:00

270 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Providers;
use App\Contracts\NewsletterSyncClient;
use App\Exceptions\MailDeliveryPausedException;
use App\Helpers\ThemeHelper;
use App\Http\Middleware\BlockAdminDuringImpersonation;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Http\Middleware\EnsureUserIsCustomer;
use App\Http\Middleware\LogSlowAdminRequests;
use App\Http\Middleware\SetEdition;
use App\Listeners\ActivateUserAfterVerification;
use App\Listeners\GateOutgoingMail;
use App\Listeners\RecordAuthActivity;
use App\Listeners\RecordMailActivity;
use App\Listeners\RecordSmtpFailure;
use App\Listeners\SyncCompanyMembershipsOnLogin;
use App\Models\AdminPreset;
use App\Models\Category;
use App\Models\CategoryTranslation;
use App\Models\Company;
use App\Models\Contact;
use App\Models\ContactMessage;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalRequest;
use App\Models\NewsletterSubscription;
use App\Models\PressRelease;
use App\Models\TerminationRequest;
use App\Models\User;
use App\Models\WithdrawalRequest;
use App\Observers\ActivityLogObserver;
use App\Observers\AdminPerformanceCacheObserver;
use App\Observers\CompanyPublicCacheObserver;
use App\Observers\EmailDomainObserver;
use App\Observers\ImageRightsConfirmationObserver;
use App\Observers\PressReleaseSearchObserver;
use App\Services\Admin\AdminRequestPerformanceMetrics;
use App\Services\Mail\DnsResolver;
use App\Services\Mail\SystemDnsResolver;
use App\Services\Newsletter\NullNewsletterSyncClient;
use App\Services\PressRelease\PressReleaseService;
use App\Support\OperationalAlert;
use App\Support\WebPageCache;
use Illuminate\Auth\Events\Login;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Mail\Events\MessageSent;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
$this->app->bind(NewsletterSyncClient::class, function (): NewsletterSyncClient {
$provider = (string) config('newsletter.sync.provider', 'placeholder');
// Es existiert bislang nur der No-Op-Client. Ein aktivierter, aber
// nicht implementierter Provider soll laut schlagen statt stumm
// ins Leere zu synchronisieren.
if (config('newsletter.sync.enabled') && $provider !== 'placeholder') {
throw new \RuntimeException(
"Newsletter-Sync-Provider [{$provider}] ist nicht implementiert – es existiert nur der Platzhalter-Client."
);
}
return new NullNewsletterSyncClient;
});
$this->app->bind(DnsResolver::class, SystemDnsResolver::class);
$this->app->singleton(PressReleaseService::class);
$this->app->singleton(AdminRequestPerformanceMetrics::class);
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Model::preventLazyLoading(app()->isLocal());
// 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
// längst verworfenen Generation lesen und löschen.
Queue::looping(static fn () => WebPageCache::resetMemo());
$scheme = request()->header('X-Forwarded-Proto')
?? request()->server('HTTP_X_FORWARDED_PROTO')
?? (request()->secure() ? 'https' : 'http');
if ($scheme === 'https') {
URL::forceScheme('https');
}
// Baseline für das Ausgabe-Präfix (de|en). Die SetEdition-Middleware
// überschreibt dies pro Request mit der tatsächlichen Ausgabe; der
// Default hier stellt sicher, dass route('…')-Aufrufe das {edition}-
// Segment auch außerhalb der HTTP-Middleware erhalten (Livewire-Tests,
// Konsole, Mails).
URL::defaults(['edition' => SetEdition::DEFAULT_EDITION]);
// Zugriffs-Gates als Single Source of Truth (auch in Blade via @can und in
// Policies nutzbar). Dieselbe Definition, die die Panel-Middleware prüfen.
// Super-Admins dürfen alles: zentraler Kurzschluss vor allen Gates und
// Permission-Checks (analog zu den Policy-`before()`-Methoden). Damit
// greifen feingranulare `can('users:manage')`-Prüfungen auch für
// Super-Admins, die keine Spatie-Rolle mit der Permission tragen.
// Deaktivierung geht vor (Audit F09): ein gesperrter Super-Admin fällt
// auf die regulären Gates zurück, die `is_active` prüfen – sonst wäre
// die Sperre für bestehende Sessions wirkungslos.
Gate::before(fn (User $user, string $ability): ?bool => ($user->is_super_admin && $user->is_active) ? true : null);
Gate::define('access-admin', fn (User $user): bool => $user->canAccessAdmin());
Gate::define('access-customer', fn (User $user): bool => $user->canAccessCustomer());
// Betriebs-Alert bei fehlgeschlagenem Queue-Job (R5.3): die KI-Jobs
// laufen auf der `classification`-Queue — ein stiller Stau in
// `failed_jobs` würde jede Einreichung in der Prüfung hängen lassen.
Queue::failing(function (JobFailed $event): void {
// Bounce-Pfad 2: SMTP-Ablehnung im Worker → Sperre bzw. Circuit
// Breaker (Roadmap 1b.6). Läuft VOR dem Alert, damit der Breaker
// seinen eigenen, einmaligen Alert setzen kann.
app(RecordSmtpFailure::class)->record($event);
// Der Breaker hat den Versand angehalten: Das ist EIN Alert (beim
// Öffnen), nicht einer je liegengebliebener Mail.
if ($event->exception instanceof MailDeliveryPausedException) {
return;
}
OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
});
// Registrierung → Verifizierungsmail; bestätigter Klick → Aktivierung.
Event::listen(Registered::class, SendEmailVerificationNotification::class);
Event::listen(Verified::class, ActivateUserAfterVerification::class);
// Aktivitäts-Verlauf (Admin ▸ Reports ▸ Verlauf): Registrierung und
// Login; Formular-Eingänge laufen über den ActivityLogObserver unten.
Event::listen(Registered::class, [RecordAuthActivity::class, 'recordRegistered']);
Event::listen(Login::class, [RecordAuthActivity::class, 'recordLogin']);
// Das eine Gate vor jeder ausgehenden Mail: Suppression, Return-Path,
// Circuit Breaker (Roadmap 1b.6). Gibt false zurück → Mail geht nicht raus.
Event::listen(MessageSending::class, [GateOutgoingMail::class, 'gate']);
// Jede tatsächlich versendete E-Mail landet ebenfalls im Verlauf.
Event::listen(MessageSent::class, [RecordMailActivity::class, 'recordSent']);
// Framework-Auth-Mails (Passwort-Reset, E-Mail-Verifizierung) im
// pressekonto-CI rendern statt im Laravel-Standard-Markdown-Theme —
// gleiches Layout wie alle übrigen System-Mails (emails/layout).
ResetPassword::toMailUsing(function (User $user, string $token): MailMessage {
$resetUrl = url(route('password.reset', [
'token' => $token,
'email' => $user->getEmailForPasswordReset(),
], false));
return (new MailMessage)
->subject('Passwort zurücksetzen für Ihr pressekonto')
->view('emails.auth.reset-password', [
'user' => $user,
'resetUrl' => $resetUrl,
'expiresInMinutes' => (int) config('auth.passwords.'.config('auth.defaults.passwords').'.expire'),
]);
});
VerifyEmail::toMailUsing(function (User $user, string $verificationUrl): MailMessage {
return (new MailMessage)
->subject('E-Mail-Adresse bestätigen für Ihr pressekonto')
->view('emails.auth.verify-email', [
'user' => $user,
'verificationUrl' => $verificationUrl,
]);
});
// Bei jedem Login Firmen-/Kontakt-Zuordnungen (gleiche E-Mail) auffrischen.
Event::listen(Login::class, SyncCompanyMembershipsOnLogin::class);
// Stripe Tax berechnet die USt im Checkout automatisch nach den
// gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer,
// EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich
// die USt-ID-Abfrage im Stripe Checkout.
Cashier::calculateTaxes();
// Formular-Eingänge in den Aktivitäts-Verlauf schreiben.
ContactMessage::observe(ActivityLogObserver::class);
LegalRequest::observe(ActivityLogObserver::class);
WithdrawalRequest::observe(ActivityLogObserver::class);
TerminationRequest::observe(ActivityLogObserver::class);
NewsletterSubscription::observe(ActivityLogObserver::class);
// Hält die Suchtabelle synchron — sie trägt die Volltext-Indizes,
// damit press_releases ohne teuren Rebuild erweitert werden kann.
PressRelease::observe(PressReleaseSearchObserver::class);
// Bestätigungsprotokoll je Bild bleibt insert-only (Bildkennzeichnung A.2).
ImageRightsConfirmation::observe(ImageRightsConfirmationObserver::class);
// Kontakterreichbarkeit: DNS-Befund der Mail-Domain nachziehen,
// sobald eine Adresse entsteht oder sich ändert.
Company::observe(EmailDomainObserver::class);
Contact::observe(EmailDomainObserver::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);
AdminPreset::observe(AdminPerformanceCacheObserver::class);
Category::observe(AdminPerformanceCacheObserver::class);
CategoryTranslation::observe(AdminPerformanceCacheObserver::class);
Company::observe(AdminPerformanceCacheObserver::class);
Contact::observe(AdminPerformanceCacheObserver::class);
NewsletterSubscription::observe(AdminPerformanceCacheObserver::class);
PressRelease::observe(AdminPerformanceCacheObserver::class);
User::observe(AdminPerformanceCacheObserver::class);
Permission::observe(AdminPerformanceCacheObserver::class);
Role::observe(AdminPerformanceCacheObserver::class);
DB::listen(fn (QueryExecuted $query) => app(AdminRequestPerformanceMetrics::class)->record($query));
// Auch bei Livewire-Folge-Requests erneut prüfen — sonst gälten die
// Panel-Gates nur für den initialen Seitenaufruf (R6.2: Customer-Gate
// symmetrisch zum Admin-Gate persistieren).
Livewire::addPersistentMiddleware([
BlockAdminDuringImpersonation::class,
EnsureUserIsAdmin::class,
EnsureUserIsCustomer::class,
LogSlowAdminRequests::class,
]);
}
/**
* Set the asset URL dynamically based on the current theme
*/
protected function setDynamicAssetUrl(): void
{
try {
$assetUrl = ThemeHelper::getAssetUrl();
config(['app.asset_url' => $assetUrl]);
} catch (\Exception $e) {
// Fallback to default if theme detection fails
config(['app.asset_url' => 'https://assets.pressekonto.test']);
}
}
}