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
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
59
tests/Feature/PressReleaseMediaPrivatizerTest.php
Normal file
59
tests/Feature/PressReleaseMediaPrivatizerTest.php
Normal 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');
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue