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:
parent
6e84266f90
commit
6250b0bab4
21 changed files with 588 additions and 70 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
app/Services/PressRelease/PressReleaseMediaPrivatizer.php
Normal file
102
app/Services/PressRelease/PressReleaseMediaPrivatizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue