media: PM-Medien privat ausliefern + Vorschau-Signatur (M4, M5, Fix#4/#5)

- Neue PM-Bilder/Anhänge liegen auf der privaten local-Disk; Auslieferung
  über kontrollierte Media-Routen mit gemeinsamer Zugriffslogik
  (AuthorizesPressReleaseMediaAccess). Öffentliche Medien werden aus den
  geladenen Modellen ermittelt (weniger Queries) und länger gecacht.
- Anonyme Token-Vorschau lädt Bilder über temporär signierte URLs (Fix#5).
- API-Resource baut Variant-URLs über die Media-Route statt /storage
  (Fix#4). Storage-Schicht prüft zusätzlich den serverseitigen MIME-Type (M5).
- press-releases:privatize-media migriert Bestandsmedien.

Deployment: einmalig `php artisan press-releases:privatize-media` ausführen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-14 13:48:06 +00:00
parent 6e84266f90
commit 6250b0bab4
21 changed files with 588 additions and 70 deletions

View file

@ -11,11 +11,11 @@ use RuntimeException;
/**
* Storage for press release attachments (PDFs and other documents).
*
* Files are stored on the `public` disk under
* Files are stored on the private `local` disk under
* press-releases/{id}/attachments/{uuid}-{slug}.{ext}
*
* Path-obscurity is sufficient for MVP: anonymous downloads are intentional
* for published press releases, and embargo logic lives at the PR level.
* Access is served through a controller which checks publication state or the
* authenticated user's policy.
*/
class PressReleaseAttachmentStorage
{
@ -43,7 +43,7 @@ class PressReleaseAttachmentStorage
*
* @return array{disk:string,path:string,original_name:string,mime:string,size:int}
*/
public function store(UploadedFile $upload, int $pressReleaseId): array
public function store(UploadedFile $upload, int $pressReleaseId, string $diskName = 'local'): array
{
$this->assertValidUpload($upload);
@ -56,7 +56,7 @@ class PressReleaseAttachmentStorage
$filename = Str::uuid()->toString().'-'.$baseSlug.'.'.$extension;
$relativePath = $directory.'/'.$filename;
$disk = $this->disk();
$disk = $this->disk($diskName);
$stream = fopen($upload->getRealPath(), 'r');
if ($stream === false) {
@ -64,7 +64,7 @@ class PressReleaseAttachmentStorage
}
try {
$disk->put($relativePath, $stream, 'public');
$disk->put($relativePath, $stream, $diskName === 'public' ? 'public' : 'private');
} finally {
if (is_resource($stream)) {
fclose($stream);
@ -72,7 +72,7 @@ class PressReleaseAttachmentStorage
}
return [
'disk' => 'public',
'disk' => $diskName,
'path' => $relativePath,
'original_name' => $originalName,
'mime' => $upload->getMimeType() ?: 'application/octet-stream',
@ -101,13 +101,19 @@ class PressReleaseAttachmentStorage
throw new RuntimeException('Unsupported attachment extension: '.$extension);
}
$mime = $upload->getMimeType();
if ($mime === null || ! in_array($mime, self::ALLOWED_MIMES, true)) {
throw new RuntimeException('Unsupported attachment MIME type: '.($mime ?? 'unknown'));
}
if ($upload->getSize() > self::MAX_BYTES) {
throw new RuntimeException('Attachment exceeds maximum size.');
}
}
private function disk(): Filesystem
private function disk(string $diskName): Filesystem
{
return Storage::disk('public');
return Storage::disk($diskName);
}
}