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,7 +11,7 @@ use Tests\TestCase;
test('api user can upload list and delete own press release images', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::fake('local');
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create([
@ -40,18 +40,28 @@ test('api user can upload list and delete own press release images', function ()
->assertJsonStructure(['data' => ['variants', 'urls']]);
$image = PressReleaseImage::query()->firstOrFail();
expect(Storage::disk('public')->exists($image->path))->toBeTrue();
expect($image->disk)->toBe('local');
expect(Storage::disk('local')->exists($image->path))->toBeTrue();
expect($image->variants)->toBeArray();
expect($image->variants)->toHaveKeys(['thumb', 'medium', 'large']);
foreach ($image->variants as $variantPath) {
expect(Storage::disk('public')->exists($variantPath))->toBeTrue();
expect(Storage::disk('local')->exists($variantPath))->toBeTrue();
}
$this->getJson("/api/v1/press-releases/{$pressRelease->id}/images")
->assertOk()
->assertJsonPath('data.0.id', $image->id)
->assertJsonPath('data.0.url', asset('storage/'.ltrim($image->path, '/')));
->assertJsonPath('data.0.url', route('media.press-release-images.show', ['image' => $image->id]))
// Variant-URLs müssen über die Media-Route zeigen (Disk 'local'), nicht
// auf /storage/ — sonst 404 für private Bilder.
->assertJsonPath('data.0.urls.thumb', route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'thumb']))
->assertJsonPath('data.0.urls.large', route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'large']));
$listedUrls = $this->getJson("/api/v1/press-releases/{$pressRelease->id}/images")->json('data.0.urls');
foreach ($listedUrls as $variantUrl) {
expect($variantUrl)->not->toContain('/storage/');
}
$this->getJson("/api/v1/press-releases/{$pressRelease->id}")
->assertOk()
@ -62,16 +72,16 @@ test('api user can upload list and delete own press release images', function ()
$this->deleteJson("/api/v1/press-release-images/{$image->id}")
->assertNoContent();
expect(Storage::disk('public')->exists($image->path))->toBeFalse();
expect(Storage::disk('local')->exists($image->path))->toBeFalse();
foreach ($variantPaths as $variantPath) {
expect(Storage::disk('public')->exists($variantPath))->toBeFalse();
expect(Storage::disk('local')->exists($variantPath))->toBeFalse();
}
expect(PressReleaseImage::withTrashed()->find($image->id)?->trashed())->toBeTrue();
});
test('image upload is limited to eight megabytes', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::fake('local');
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create([
@ -89,7 +99,7 @@ test('image upload is limited to eight megabytes', function () {
test('api user cannot manage another users press release images', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::fake('local');
$owner = User::factory()->create();
$otherUser = User::factory()->create();
@ -108,7 +118,7 @@ test('api user cannot manage another users press release images', function () {
test('published press release images cannot be changed via api', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::fake('local');
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->published()->create([
@ -126,7 +136,7 @@ test('published press release images cannot be changed via api', function () {
test('api image upload requires the per-image ai declaration', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::fake('local');
$user = User::factory()->create();
$pressRelease = PressRelease::factory()->create([
@ -148,3 +158,33 @@ test('api image upload requires the per-image ai declaration', function () {
expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('ai_generated');
});
test('private press release images follow publication and ownership access', function () {
/** @var TestCase $this */
Storage::fake('local');
$owner = User::factory()->create();
$draft = PressRelease::factory()->create([
'user_id' => $owner->id,
'status' => PressReleaseStatus::Draft->value,
]);
$image = $draft->images()->create([
'disk' => 'local',
'path' => "press-releases/{$draft->id}/images/private.jpg",
'variants' => [],
'mime' => 'image/jpeg',
]);
Storage::disk('local')->put($image->path, 'image data');
$url = "https://pressekonto.test/media/press-release-images/{$image->id}";
$this->get($url)->assertNotFound();
$this->actingAs($owner)->get($url)->assertSuccessful();
$draft->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => now()->subMinute(),
]);
auth()->forgetGuards();
$this->get("https://presseecho.test/media/press-release-images/{$image->id}")
->assertSuccessful();
});

View file

@ -1,10 +1,12 @@
<?php
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Models\User;
use App\Services\PressRelease\PressReleaseAttachmentStorage;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Filesystem\FilesystemAdapter;
@ -13,10 +15,10 @@ use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
function publicDisk(): FilesystemAdapter
function privateAttachmentDisk(): FilesystemAdapter
{
/** @var FilesystemAdapter $disk */
$disk = Storage::disk('public');
$disk = Storage::disk('local');
return $disk;
}
@ -24,7 +26,7 @@ function publicDisk(): FilesystemAdapter
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Storage::fake('public');
Storage::fake('local');
});
function makeDraftWithOwner(string $status = 'draft'): array
@ -69,8 +71,8 @@ test('owner can upload a PDF attachment', function () {
expect($att->original_name)->toBe('factsheet.pdf');
expect($att->mime)->toBe('application/pdf');
expect($att->sort_order)->toBe(1);
expect($att->disk)->toBe('public');
publicDisk()->assertExists($att->path);
expect($att->disk)->toBe('local');
privateAttachmentDisk()->assertExists($att->path);
});
test('upload rejects executable / disallowed file types', function () {
@ -89,6 +91,43 @@ test('upload rejects executable / disallowed file types', function () {
expect($pr->attachments()->count())->toBe(0);
});
test('storage rejects a permitted extension with a mismatched mime type', function () {
$disguised = UploadedFile::fake()->create('factsheet.pdf', 50, 'application/x-php');
expect(fn () => app(PressReleaseAttachmentStorage::class)->store($disguised, 1))
->toThrow(RuntimeException::class, 'Unsupported attachment MIME type');
});
test('private draft attachments require an authorized user', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
$attachment = PressReleaseAttachment::factory()->for($pr)->create([
'disk' => 'local',
'path' => "press-releases/{$pr->id}/attachments/private.pdf",
'original_name' => 'private.pdf',
]);
Storage::disk('local')->put($attachment->path, 'private content');
$url = "https://pressekonto.test/media/press-release-attachments/{$attachment->id}";
$this->get($url)->assertNotFound();
$this->actingAs($owner)->get($url)->assertSuccessful();
});
test('private published attachments can be downloaded publicly', function () {
/** @var TestCase $this */
['pr' => $pr] = makeDraftWithOwner(PressReleaseStatus::Published->value);
$pr->update(['published_at' => now()->subMinute()]);
$attachment = PressReleaseAttachment::factory()->for($pr)->create([
'disk' => 'local',
'path' => "press-releases/{$pr->id}/attachments/public.pdf",
'original_name' => 'public.pdf',
]);
Storage::disk('local')->put($attachment->path, 'public content');
$this->get("https://presseecho.test/media/press-release-attachments/{$attachment->id}")
->assertSuccessful();
});
test('upload rejects oversized files', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = makeDraftWithOwner();
@ -144,12 +183,12 @@ test('remove deletes the row and the file', function () {
->assertHasNoErrors();
$att = $pr->attachments()->first();
publicDisk()->assertExists($att->path);
privateAttachmentDisk()->assertExists($att->path);
$component->call('remove', $att->id);
expect($pr->attachments()->withTrashed()->find($att->id)?->trashed())->toBeTrue();
publicDisk()->assertMissing($att->path);
privateAttachmentDisk()->assertMissing($att->path);
});
test('startEdit + updateAttachment update title and description', function () {

View file

@ -16,6 +16,7 @@ beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Storage::fake('public');
Storage::fake('local');
});
function makeImageDraftOwner(): array
@ -226,7 +227,8 @@ test('valid upload stores license metadata and confirms rights', function () {
expect($image->width)->toBe(1280);
expect($image->height)->toBe(580);
Storage::disk('public')->assertExists($image->path);
expect($image->disk)->toBe('local');
Storage::disk('local')->assertExists($image->path);
$originalPath = preg_replace(
'#/variants/([^/]+)-cover(\.[^.]+)$#',
@ -235,7 +237,7 @@ test('valid upload stores license metadata and confirms rights', function () {
);
expect($originalPath)->toBeString()->not->toBe($image->path);
Storage::disk('public')->assertMissing($originalPath);
Storage::disk('local')->assertMissing($originalPath);
});
test('valid cc upload stores license detail and license url', function () {

View file

@ -0,0 +1,59 @@
<?php
use App\Models\PressRelease;
use App\Services\PressRelease\PressReleaseMediaPrivatizer;
use Illuminate\Support\Facades\Storage;
test('existing public press release media is moved to private storage', function () {
Storage::fake('public');
Storage::fake('local');
$pressRelease = PressRelease::factory()->create();
$image = $pressRelease->images()->create([
'disk' => 'public',
'path' => "press-releases/{$pressRelease->id}/images/cover.jpg",
'variants' => [
'thumb' => "press-releases/{$pressRelease->id}/images/thumb.jpg",
],
]);
$attachment = $pressRelease->attachments()->create([
'disk' => 'public',
'path' => "press-releases/{$pressRelease->id}/attachments/factsheet.pdf",
'original_name' => 'factsheet.pdf',
'mime' => 'application/pdf',
'size' => 10,
]);
foreach ([$image->path, $image->variants['thumb'], $attachment->path] as $path) {
Storage::disk('public')->put($path, 'content');
}
$this->artisan('press-releases:privatize-media')
->expectsOutput('1 Bilder und 1 Anhänge wurden auf den privaten Datenträger migriert.')
->assertSuccessful();
foreach ([$image->path, $image->variants['thumb'], $attachment->path] as $path) {
Storage::disk('public')->assertMissing($path);
Storage::disk('local')->assertExists($path);
}
expect($image->fresh()->disk)->toBe('local')
->and($attachment->fresh()->disk)->toBe('local');
});
test('a missing source file never flips its database record to the private disk', function () {
Storage::fake('public');
Storage::fake('local');
$pressRelease = PressRelease::factory()->create();
$attachment = $pressRelease->attachments()->create([
'disk' => 'public',
'path' => "press-releases/{$pressRelease->id}/attachments/missing.pdf",
'original_name' => 'missing.pdf',
'mime' => 'application/pdf',
'size' => 10,
]);
expect(fn () => app(PressReleaseMediaPrivatizer::class)->privatizeAll())
->toThrow(RuntimeException::class, 'missing on both disks');
expect($attachment->fresh()->disk)->toBe('public');
});

View file

@ -3,9 +3,11 @@
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\User;
use App\Services\Auth\MagicLinkGenerator;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Storage;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
@ -151,6 +153,67 @@ test('preview link allows access to draft press release without authentication',
expect(auth()->check())->toBeFalse();
});
test('preview media of an unpublished release is reachable via the signed url but not unsigned', function () {
/** @var TestCase $this */
Storage::fake('local');
$owner = User::factory()->create();
$pr = PressRelease::factory()->forPortal(Portal::Presseecho)->create([
'user_id' => $owner->id,
'status' => PressReleaseStatus::Draft->value,
'title' => 'Entwurf mit Bild',
]);
$path = "press-releases/{$pr->id}/images/entwurf-detail.jpg";
Storage::disk('local')->put($path, 'binary');
$image = PressReleaseImage::query()->create([
'press_release_id' => $pr->id,
'disk' => 'local',
'path' => $path,
'variants' => ['detail' => $path],
'is_preview' => true,
]);
// Ohne Signatur ist das Medium einer unveröffentlichten PM anonym gesperrt.
$this->get(route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'detail']))
->assertNotFound();
// Die im Vorschaumodus erzeugte, signierte URL liefert das Bild aus.
$signedUrl = $image->variantUrl('detail', signed: true);
expect($signedUrl)->toContain('signature=');
$this->get($signedUrl)->assertOk();
});
test('preview page emits signed media urls for the hero image', function () {
/** @var TestCase $this */
Storage::fake('local');
$owner = User::factory()->create();
$pr = PressRelease::factory()->forPortal(Portal::Presseecho)->create([
'user_id' => $owner->id,
'status' => PressReleaseStatus::Draft->value,
]);
$path = "press-releases/{$pr->id}/images/hero-detail.jpg";
Storage::disk('local')->put($path, 'binary');
PressReleaseImage::query()->create([
'press_release_id' => $pr->id,
'disk' => 'local',
'path' => $path,
'variants' => ['detail' => $path],
'is_preview' => true,
]);
$share = app(MagicLinkGenerator::class)->createPressReleaseShareLink($pr, $owner);
$this->get(route('press-releases.preview', ['token' => $share['plain_token']]))
->assertOk()
->assertSee('media/press-release-images', false)
->assertSee('signature=', false);
});
test('expired preview link returns 410 gone', function () {
/** @var TestCase $this */
$owner = User::factory()->create();