diff --git a/app/Console/Commands/PrivatizePressReleaseMedia.php b/app/Console/Commands/PrivatizePressReleaseMedia.php new file mode 100644 index 00000000..d0e0bfa5 --- /dev/null +++ b/app/Console/Commands/PrivatizePressReleaseMedia.php @@ -0,0 +1,39 @@ +privatizeAll(); + + $this->info(sprintf( + '%d Bilder und %d Anhänge wurden auf den privaten Datenträger migriert.', + $counts['images'], + $counts['attachments'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php index 733bdaff..60742c63 100644 --- a/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php +++ b/app/Http/Controllers/Api/Legacy/LegacyPressReleaseImageController.php @@ -24,6 +24,8 @@ class LegacyPressReleaseImageController extends Controller public function store(Request $request, int $pressRelease): JsonResponse { + abort_unless($request->user()->tokenCan('press-release-images:write'), 403); + $pressRelease = PressRelease::withoutGlobalScopes()->find($pressRelease); if (! $pressRelease) { @@ -56,7 +58,7 @@ class LegacyPressReleaseImageController extends Controller } $image = $pressRelease->images()->create([ - 'disk' => 'public', + 'disk' => $stored['disk'], 'path' => $stored['path'], 'variants' => $stored['variants'], 'ai_declaration' => AiImageDeclaration::Original->value, diff --git a/app/Http/Controllers/Api/V1/PressReleaseImageController.php b/app/Http/Controllers/Api/V1/PressReleaseImageController.php index 948eb175..51e3f22f 100644 --- a/app/Http/Controllers/Api/V1/PressReleaseImageController.php +++ b/app/Http/Controllers/Api/V1/PressReleaseImageController.php @@ -61,7 +61,7 @@ class PressReleaseImageController extends Controller } $image = $pressRelease->images()->create([ - 'disk' => 'public', + 'disk' => $stored['disk'], 'path' => $stored['path'], 'variants' => $stored['variants'], // v1 verlangt die KI-Kennzeichnung je Bild (FormRequest); diff --git a/app/Http/Controllers/Web/Concerns/AuthorizesPressReleaseMediaAccess.php b/app/Http/Controllers/Web/Concerns/AuthorizesPressReleaseMediaAccess.php new file mode 100644 index 00000000..802cf0f5 --- /dev/null +++ b/app/Http/Controllers/Web/Concerns/AuthorizesPressReleaseMediaAccess.php @@ -0,0 +1,57 @@ +hasValidSignature()) { + return; + } + + abort_unless(auth()->check(), 404); + Gate::authorize('view', $pressRelease); + } + + /** + * Öffentliche Sichtbarkeit aus den bereits geladenen Modellen ermitteln + * (PM inkl. `company` werden vom Controller eager-geladen), statt eine + * separate EXISTS-Abfrage je Medien-Request zu fahren. + */ + protected function pressReleaseMediaIsPublic(PressRelease $pressRelease): bool + { + $company = $pressRelease->company; + + return $pressRelease->isVisibleInArchive() + && $company !== null + && (bool) $company->is_active; + } + + /** + * Cache-Control für die Auslieferung. Öffentliche Medien werden lange + * gecacht (Browser/CDN), damit die PHP-Route auf den hochfrequenten + * Archiv-/Detailseiten nicht bei jedem Bild erneut getroffen wird; nicht + * öffentliche (Vorschau/geschützt) werden nie gecacht. + */ + protected function mediaCacheControl(bool $isPublic): string + { + return $isPublic ? 'public, max-age=86400' : 'private, no-store'; + } +} diff --git a/app/Http/Controllers/Web/PressReleaseAttachmentMediaController.php b/app/Http/Controllers/Web/PressReleaseAttachmentMediaController.php new file mode 100644 index 00000000..5b86215c --- /dev/null +++ b/app/Http/Controllers/Web/PressReleaseAttachmentMediaController.php @@ -0,0 +1,39 @@ +with(['pressRelease' => fn ($query) => $query + ->withoutGlobalScopes() + ->with(['company' => fn ($company) => $company->withoutGlobalScopes()])]) + ->findOrFail($attachment); + $pressRelease = $attachment->pressRelease; + + abort_unless($pressRelease instanceof PressRelease, 404); + $isPublic = $this->pressReleaseMediaIsPublic($pressRelease); + $this->authorizePressReleaseMediaAccess($request, $pressRelease, $isPublic); + + return Storage::disk($attachment->disk)->download( + $attachment->path, + $attachment->original_name, + [ + 'Cache-Control' => $this->mediaCacheControl($isPublic), + 'X-Content-Type-Options' => 'nosniff', + ], + ); + } +} diff --git a/app/Http/Controllers/Web/PressReleaseImageMediaController.php b/app/Http/Controllers/Web/PressReleaseImageMediaController.php new file mode 100644 index 00000000..aa51cc5e --- /dev/null +++ b/app/Http/Controllers/Web/PressReleaseImageMediaController.php @@ -0,0 +1,39 @@ +with(['pressRelease' => fn ($query) => $query + ->withoutGlobalScopes() + ->with(['company' => fn ($company) => $company->withoutGlobalScopes()])]) + ->findOrFail($image); + $pressRelease = $image->pressRelease; + + abort_unless($pressRelease instanceof PressRelease, 404); + $isPublic = $this->pressReleaseMediaIsPublic($pressRelease); + $this->authorizePressReleaseMediaAccess($request, $pressRelease, $isPublic); + + $path = $image->pathForVariant($variant); + + abort_unless(is_string($path) && filled($path), 404); + + return Storage::disk($image->disk)->response($path, headers: [ + 'Cache-Control' => $this->mediaCacheControl($isPublic), + 'X-Content-Type-Options' => 'nosniff', + ]); + } +} diff --git a/app/Http/Resources/PressReleaseImageResource.php b/app/Http/Resources/PressReleaseImageResource.php index bfa7f072..89e4bfa8 100644 --- a/app/Http/Resources/PressReleaseImageResource.php +++ b/app/Http/Resources/PressReleaseImageResource.php @@ -17,9 +17,12 @@ class PressReleaseImageResource extends JsonResource $variants = is_array($this->variants) ? $this->variants : []; $variantUrls = []; + // Variant-URLs über das Model auflösen, damit sie – wie die Original-URL – + // je nach Disk entweder direkt (public) oder über die geschützte + // Media-Route (local) zeigen. Sonst 404 für private Medien. foreach ($variants as $key => $relativePath) { - if (is_string($relativePath) && filled($relativePath)) { - $variantUrls[$key] = asset('storage/'.ltrim($relativePath, '/')); + if (is_string($relativePath) && filled($relativePath) && filled($url = $this->variantUrl($key))) { + $variantUrls[$key] = $url; } } diff --git a/app/Models/PressReleaseAttachment.php b/app/Models/PressReleaseAttachment.php index db1b31d0..7f4d3670 100644 --- a/app/Models/PressReleaseAttachment.php +++ b/app/Models/PressReleaseAttachment.php @@ -7,7 +7,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Support\Facades\Storage; class PressReleaseAttachment extends Model { @@ -52,16 +51,6 @@ class PressReleaseAttachment extends Model return asset('storage/'.ltrim((string) $this->path, '/')); } - try { - $disk = Storage::disk($this->disk); - - if (method_exists($disk, 'url')) { - return $disk->url($this->path); - } - } catch (\Throwable) { - return null; - } - - return null; + return route('media.press-release-attachments.show', ['attachment' => $this->getKey()]); } } diff --git a/app/Models/PressReleaseImage.php b/app/Models/PressReleaseImage.php index 299084e8..1fd1dd22 100644 --- a/app/Models/PressReleaseImage.php +++ b/app/Models/PressReleaseImage.php @@ -7,7 +7,7 @@ use App\Enums\ImageLicenseType; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\URL; class PressReleaseImage extends Model { @@ -64,42 +64,60 @@ class PressReleaseImage extends Model return $this->belongsTo(PressRelease::class); } - public function url(): ?string + public function url(bool $signed = false): ?string { if (blank($this->path)) { return null; } - return $this->resolveDiskUrl($this->path); + return $this->resolveDiskUrl($this->path, signed: $signed); } - public function variantUrl(string $key): ?string + public function variantUrl(string $key, bool $signed = false): ?string { - $variantPath = $this->variants[$key] ?? null; + $variantPath = $this->pathForVariant($key); if (! is_string($variantPath) || blank($variantPath)) { return null; } - return $this->resolveDiskUrl($variantPath); + return $this->resolveDiskUrl($variantPath, $key, $signed); } - private function resolveDiskUrl(string $relativePath): ?string + public function pathForVariant(?string $key = null): ?string + { + if ($key === null || $key === 'original') { + return $this->path; + } + + $variants = $this->getAttribute('variants'); + + return is_array($variants) && is_string($variants[$key] ?? null) + ? $variants[$key] + : null; + } + + /** + * Löst die URL je nach Disk auf: public → direkt, sonst über die geschützte + * Media-Route. `signed=true` erzeugt eine temporär signierte Route, damit + * die anonyme Token-Vorschau (unveröffentlichte PM) die Bilder laden kann, + * ohne die Media-Route generell zu öffnen. + */ + private function resolveDiskUrl(string $relativePath, ?string $variant = null, bool $signed = false): string { if ($this->disk === 'public') { return asset('storage/'.ltrim($relativePath, '/')); } - try { - $disk = Storage::disk($this->disk); + $parameters = array_filter([ + 'image' => $this->getKey(), + 'variant' => $variant, + ]); - if (method_exists($disk, 'url')) { - return $disk->url($relativePath); - } - - return null; - } catch (\Throwable) { - return null; + if ($signed) { + return URL::temporarySignedRoute('media.press-release-images.show', now()->addHours(6), $parameters); } + + return route('media.press-release-images.show', $parameters); } } diff --git a/app/Services/Image/ImageService.php b/app/Services/Image/ImageService.php index 80067378..bdb0b4e8 100644 --- a/app/Services/Image/ImageService.php +++ b/app/Services/Image/ImageService.php @@ -106,6 +106,7 @@ class ImageService * the cover variant to keep storage usage predictable. * * @return array{ + * disk: string, * path: string, * variants: array, * 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); } } diff --git a/app/Services/PressRelease/PressReleaseAttachmentStorage.php b/app/Services/PressRelease/PressReleaseAttachmentStorage.php index 92906684..63d90275 100644 --- a/app/Services/PressRelease/PressReleaseAttachmentStorage.php +++ b/app/Services/PressRelease/PressReleaseAttachmentStorage.php @@ -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); } } diff --git a/app/Services/PressRelease/PressReleaseMediaPrivatizer.php b/app/Services/PressRelease/PressReleaseMediaPrivatizer.php new file mode 100644 index 00000000..ee7f58f6 --- /dev/null +++ b/app/Services/PressRelease/PressReleaseMediaPrivatizer.php @@ -0,0 +1,102 @@ + 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 $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); + } +} diff --git a/resources/views/livewire/components/press-release-attachments-manager.blade.php b/resources/views/livewire/components/press-release-attachments-manager.blade.php index da7e9bc8..66dac565 100644 --- a/resources/views/livewire/components/press-release-attachments-manager.blade.php +++ b/resources/views/livewire/components/press-release-attachments-manager.blade.php @@ -15,8 +15,8 @@ use Livewire\WithFileUploads; * delete. Authorisation is delegated to the `update` ability on the * PressReleasePolicy so the component is safe for admins and customers. * - * Storage uses the `public` disk with path obscurity (UUID prefix). Embargo - * / unpublished-state guards live at the PressRelease level. + * Storage uses the private disk. Downloads run through a controller that + * checks publication state or the PressRelease policy. */ new class extends Component { diff --git a/resources/views/livewire/components/press-release-images-manager.blade.php b/resources/views/livewire/components/press-release-images-manager.blade.php index 00374ac8..812b74a2 100644 --- a/resources/views/livewire/components/press-release-images-manager.blade.php +++ b/resources/views/livewire/components/press-release-images-manager.blade.php @@ -235,7 +235,7 @@ new class extends Component { $pressRelease->images()->update(['is_preview' => false]); $pressRelease->images()->create([ - 'disk' => 'public', + 'disk' => $stored['disk'], 'path' => $stored['path'], 'variants' => $stored['variants'], 'title' => $this->newTitle ?: null, diff --git a/resources/views/web/release-detail.blade.php b/resources/views/web/release-detail.blade.php index cb49099f..c39128b2 100644 --- a/resources/views/web/release-detail.blade.php +++ b/resources/views/web/release-detail.blade.php @@ -39,7 +39,10 @@ $lead = $release->subtitle; $image = $release->images->first(); - $imageUrl = $image?->variantUrl('detail') ?? $image?->variantUrl('card') ?? $image?->url(); + // In der anonymen Token-Vorschau ist die PM noch nicht öffentlich; die + // Media-Route braucht daher eine signierte URL, um die Bilder auszuliefern. + $signMedia = $isPreview ?? false; + $imageUrl = $image?->variantUrl('detail', $signMedia) ?? $image?->variantUrl('card', $signMedia) ?? $image?->url($signMedia); $imageCaption = $image?->description ?: $image?->title; $imageCredit = $image?->copyright ?: $image?->author; // KI-Bildkennzeichnung (Art. 50 KI-VO): Badge direkt am Bild diff --git a/routes/web.php b/routes/web.php index ca8cc449..ba9bac01 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,8 @@ use App\Http\Controllers\Web\LegacyMediaRedirectController; use App\Http\Controllers\Web\NewsroomDetailController; use App\Http\Controllers\Web\NewsroomsController; use App\Http\Controllers\Web\PreiseController; +use App\Http\Controllers\Web\PressReleaseAttachmentMediaController; +use App\Http\Controllers\Web\PressReleaseImageMediaController; use App\Http\Controllers\Web\ReleaseDetailController; use App\Http\Controllers\Web\ReleaseShareTrackingController; use App\Http\Controllers\Web\ReportFormController; @@ -30,6 +32,17 @@ Route::get('/pm-vorschau/{token}', PressReleasePreviewController::class) ->middleware('throttle:30,1') ->name('press-releases.preview'); +Route::get('/media/press-release-images/{image}/{variant?}', PressReleaseImageMediaController::class) + ->whereNumber('image') + ->where('variant', '[A-Za-z0-9_-]+') + ->middleware('throttle:600,1') + ->name('media.press-release-images.show'); + +Route::get('/media/press-release-attachments/{attachment}', PressReleaseAttachmentMediaController::class) + ->whereNumber('attachment') + ->middleware('throttle:600,1') + ->name('media.press-release-attachments.show'); + // Gemeinsame Web-Routes für alle Landingpages // Jede Landing-Page hat das gleiche Gerüst, aber unterschiedliches Styling diff --git a/tests/Feature/Api/V1/PressReleaseImageApiTest.php b/tests/Feature/Api/V1/PressReleaseImageApiTest.php index 2eaa57de..e0814926 100644 --- a/tests/Feature/Api/V1/PressReleaseImageApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseImageApiTest.php @@ -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(); +}); diff --git a/tests/Feature/PressReleaseAttachmentsManagerTest.php b/tests/Feature/PressReleaseAttachmentsManagerTest.php index fc05312c..9064fac9 100644 --- a/tests/Feature/PressReleaseAttachmentsManagerTest.php +++ b/tests/Feature/PressReleaseAttachmentsManagerTest.php @@ -1,10 +1,12 @@ 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 () { diff --git a/tests/Feature/PressReleaseImageLicenseTest.php b/tests/Feature/PressReleaseImageLicenseTest.php index c603c943..c70cfd89 100644 --- a/tests/Feature/PressReleaseImageLicenseTest.php +++ b/tests/Feature/PressReleaseImageLicenseTest.php @@ -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 () { diff --git a/tests/Feature/PressReleaseMediaPrivatizerTest.php b/tests/Feature/PressReleaseMediaPrivatizerTest.php new file mode 100644 index 00000000..191afb8c --- /dev/null +++ b/tests/Feature/PressReleaseMediaPrivatizerTest.php @@ -0,0 +1,59 @@ +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'); +}); diff --git a/tests/Feature/PressReleasePreviewTest.php b/tests/Feature/PressReleasePreviewTest.php index 6527853e..ba7824ab 100644 --- a/tests/Feature/PressReleasePreviewTest.php +++ b/tests/Feature/PressReleasePreviewTest.php @@ -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();