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

@ -106,6 +106,7 @@ class ImageService
* the cover variant to keep storage usage predictable.
*
* @return array{
* disk: string,
* path: string,
* variants: array<string, string>,
* width: int|null,
@ -113,8 +114,11 @@ class ImageService
* mime: string|null,
* }
*/
public function storePressReleaseImage(UploadedFile $upload, int $pressReleaseId): array
{
public function storePressReleaseImage(
UploadedFile $upload,
int $pressReleaseId,
string $diskName = 'local',
): array {
$this->assertValidPressReleaseImageUpload($upload);
$directory = sprintf('press-releases/%d/images', $pressReleaseId);
@ -122,8 +126,8 @@ class ImageService
$filename = Str::uuid()->toString().'.'.$extension;
$relativePath = $directory.'/'.$filename;
$disk = $this->disk();
$disk->put($relativePath, $upload->get(), 'public');
$disk = $this->disk($diskName);
$disk->put($relativePath, $upload->get(), $diskName === 'public' ? 'public' : 'private');
$variants = $this->generateVariants(
$disk,
@ -142,6 +146,7 @@ class ImageService
}
return [
'disk' => $diskName,
'path' => $coverPath,
'variants' => $variants,
'width' => is_int($coverSize[0] ?? null) ? $coverSize[0] : null,
@ -435,8 +440,8 @@ class ImageService
}
}
private function disk(): Filesystem
private function disk(?string $diskName = null): Filesystem
{
return Storage::disk($this->disk);
return Storage::disk($diskName ?? $this->disk);
}
}

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);
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace App\Services\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Models\PressReleaseImage;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class PressReleaseMediaPrivatizer
{
/**
* @return array{images:int,attachments:int}
*/
public function privatizeAll(): array
{
$counts = ['images' => 0, 'attachments' => 0];
PressReleaseImage::query()
->withTrashed()
->where('disk', 'public')
->chunkById(100, function ($images) use (&$counts): void {
foreach ($images as $image) {
$paths = collect([$image->path, ...array_values($image->variants ?? [])])
->filter(fn ($path): bool => is_string($path) && filled($path))
->unique();
$this->assertPathsAvailable($paths->all());
foreach ($paths as $path) {
$this->moveToPrivateDisk($path);
}
$image->updateQuietly(['disk' => 'local']);
$counts['images']++;
}
});
PressReleaseAttachment::query()
->withTrashed()
->where('disk', 'public')
->chunkById(100, function ($attachments) use (&$counts): void {
foreach ($attachments as $attachment) {
$this->assertPathsAvailable([$attachment->path]);
$this->moveToPrivateDisk($attachment->path);
$attachment->updateQuietly(['disk' => 'local']);
$counts['attachments']++;
}
});
return $counts;
}
/**
* @param list<string> $paths
*/
private function assertPathsAvailable(array $paths): void
{
$public = Storage::disk('public');
$private = Storage::disk('local');
foreach ($paths as $path) {
if (! $public->exists($path) && ! $private->exists($path)) {
throw new RuntimeException("Press release media file is missing on both disks: {$path}");
}
}
}
private function moveToPrivateDisk(string $path): void
{
$public = Storage::disk('public');
$private = Storage::disk('local');
if ($private->exists($path)) {
$public->delete($path);
return;
}
if (! $public->exists($path)) {
throw new RuntimeException("Public press release media file disappeared during migration: {$path}");
}
$stream = $public->readStream($path);
if (! is_resource($stream)) {
throw new RuntimeException("Could not read public press release media: {$path}");
}
try {
if (! $private->put($path, $stream, 'private')) {
throw new RuntimeException("Could not write private press release media: {$path}");
}
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
$public->delete($path);
}
}