images: Lebenszyklus — Veröffentlichungsgate, published_ever_at, Snapshot der veröffentlichten Fassung, Sofortlöschung, Legacy-Bereinigung, Beweiszugriff, Korrekturweg (Bildkennzeichnung Block E)

- publish()/changeStatusFromAdmin(): kein Bild aus Formular v2 ohne gültige Bestätigung (ImageRightsConfirmationRequiredException, Admin-Seite meldet es); published_ever_at an PM und Bildern; PublishedImageSnapshot (Cover + JSON mit Nachweis/Kennzeichnung)
- deleteFromAdmin() und press-releases:purge-drafts löschen Bilder nie veröffentlichter Meldungen samt Original und Bestätigung sofort (E4, O1)
- press-releases:backfill-published-ever (Status-Log), press-release-images:purge-inactive-legacy (E-B11, Trockenlauf Standard)
- Admin-Route für Beweisoriginal und veröffentlichte Fassung nur mit Zweck, ActivityType EvidenceAccess; Architekturtest: original_path nur in vier Dateien
- ImageCorrectionService + image_declaration_corrections (insert-only), withheld_at blendet das Bild öffentlich aus (publiclyVisible-Scope), Korrekturpanel auf der Admin-Seite
- Systemstatus: fehlgeschlagene Herkunftsprüfungen der letzten 7 Tage

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-22 09:53:49 +00:00
parent 9f44546082
commit da167b713b
24 changed files with 1202 additions and 12 deletions

View file

@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\ActivityType;
use App\Http\Controllers\Controller;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Einziger Zugriffsweg auf Beweisoriginal und veröffentlichte Fassung
* (Bildkennzeichnung E.4): nur Admins, nur mit angegebenem Zweck, jeder
* Abruf im Aktivitätsverlauf. Medienroute, Vorschau-Signaturen und API
* kennen diese Pfade nicht. Block F verlagert das Protokoll in
* `archive_access_log`.
*/
class PressReleaseImageEvidenceController extends Controller
{
public function __construct(private readonly ActivityLog $activityLog) {}
public function original(Request $request, int $id, int $image): StreamedResponse
{
return $this->stream($request, $id, $image, 'original');
}
public function published(Request $request, int $id, int $image): StreamedResponse
{
return $this->stream($request, $id, $image, 'published');
}
private function stream(Request $request, int $pressReleaseId, int $imageId, string $kind): StreamedResponse
{
$purpose = trim((string) $request->query('purpose', ''));
abort_if($purpose === '', 422, 'Zweck des Zugriffs fehlt (purpose).');
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($pressReleaseId);
$image = PressReleaseImage::query()->withTrashed()->where('press_release_id', $pressRelease->getKey())->findOrFail($imageId);
$path = $kind === 'original' ? $image->original_path : $image->published_snapshot_path;
$disk = Storage::disk($image->original_disk ?? (string) config('images.originals_disk'));
abort_unless(is_string($path) && $path !== '' && $disk->exists($path), 404);
$this->activityLog->record(
ActivityType::EvidenceAccess,
sprintf('%s zu Bild #%d (PM #%d) abgerufen: %s', $kind === 'original' ? 'Beweisoriginal' : 'Veröffentlichte Fassung', $image->getKey(), $pressRelease->getKey(), $purpose),
$request->user(),
$image,
['kind' => $kind, 'purpose' => $purpose, 'sha256' => $kind === 'original' ? $image->original_sha256 : $image->published_snapshot_sha256],
);
return $disk->response($path, basename($path), [
'Cache-Control' => 'private, no-store',
'X-Content-Type-Options' => 'nosniff',
]);
}
}