presseportale/app/Http/Controllers/Admin/PressReleaseImageEvidenceController.php
Kevin Adametz 60650baeb8 archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)
- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue),
  Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel
  FAILURE; archive_pending_at nur noch Sammelmarker
- O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre
- O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder
  offener Auftrag, nie ein still unvollständiger Nachweis
- A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore,
  archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut
- O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur
  mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab
- A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold-
  Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit
  compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und
  ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export
- 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id

PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock),
N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für
auslieferbare Bilder), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:01 +00:00

87 lines
3.8 KiB
PHP

<?php
namespace App\Http\Controllers\Admin;
use App\Authorization\Permission;
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\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
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`.
*
* Umsetzungsaudit 29.09.2026: Der Zweck kommt nur per POST (A-08), GET
* zeigt das Zweckformular. Restnachweise entfernter Bilder (weich
* gelöschte Zeile) nur mit `compliance:manage` (A-11) — wie das Archiv.
*/
class PressReleaseImageEvidenceController extends Controller
{
public function __construct(private readonly ActivityLog $activityLog) {}
public function original(Request $request, int $id, int $image): StreamedResponse|View
{
return $this->stream($request, $id, $image, 'original');
}
public function published(Request $request, int $id, int $image): StreamedResponse|View
{
return $this->stream($request, $id, $image, 'published');
}
private function stream(Request $request, int $pressReleaseId, int $imageId, string $kind): StreamedResponse|View
{
$pressRelease = PressRelease::withoutGlobalScope(PortalScope::class)->findOrFail($pressReleaseId);
$image = PressReleaseImage::query()->withTrashed()->where('press_release_id', $pressRelease->getKey())->findOrFail($imageId);
if ($image->trashed()) {
Gate::authorize(Permission::ComplianceManage);
}
$purpose = $request->isMethod('post') ? trim((string) $request->request->get('purpose', '')) : '';
if ($purpose === '') {
abort_if($request->isMethod('post'), 422, 'Zweck des Zugriffs fehlt (purpose).');
return view('admin.archive.purpose', [
'formAction' => route($kind === 'original' ? 'admin.press-releases.images.original' : 'admin.press-releases.images.published', ['id' => $pressRelease->getKey(), 'image' => $image->getKey()]),
'description' => __(':kind zu Bild #:image (PM #:pr). Jeder Abruf wird mit Zweck protokolliert.', [
'kind' => $kind === 'original' ? __('Beweisoriginal') : __('Veröffentlichte Fassung'),
'image' => $image->getKey(),
'pr' => $pressRelease->getKey(),
]),
'submitLabel' => __('Datei öffnen'),
'cancelUrl' => route('admin.press-releases.show', ['id' => $pressRelease->getKey()]),
]);
}
$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',
]);
}
}