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

@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Services\PressRelease\PressReleaseMediaPrivatizer;
use Illuminate\Console\Command;
class PrivatizePressReleaseMedia extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'press-releases:privatize-media';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Verschiebt bestehende Pressemitteilungs-Medien vom öffentlichen auf den privaten Datenträger';
/**
* Execute the console command.
*/
public function handle(PressReleaseMediaPrivatizer $privatizer): int
{
$counts = $privatizer->privatizeAll();
$this->info(sprintf(
'%d Bilder und %d Anhänge wurden auf den privaten Datenträger migriert.',
$counts['images'],
$counts['attachments'],
));
return self::SUCCESS;
}
}

View file

@ -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,

View file

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

View file

@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Web\Concerns;
use App\Models\PressRelease;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
/**
* Gemeinsame Zugriffslogik der geschützten Presse-Medienrouten (Bilder und
* Anhänge): Öffentlich sichtbare PMs sind frei abrufbar; sonst braucht es eine
* gültige Signatur (anonyme Token-Vorschau) oder eine angemeldete Session mit
* `view`-Recht auf die PM.
*/
trait AuthorizesPressReleaseMediaAccess
{
protected function authorizePressReleaseMediaAccess(Request $request, PressRelease $pressRelease, bool $isPublic): void
{
if ($isPublic) {
return;
}
// Signierte URL (Vorschau-Link) autorisiert den Zugriff auf die Medien
// einer noch nicht öffentlichen PM, ohne eine Session zu erfordern.
if ($request->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';
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AuthorizesPressReleaseMediaAccess;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PressReleaseAttachmentMediaController extends Controller
{
use AuthorizesPressReleaseMediaAccess;
public function __invoke(Request $request, int $attachment): StreamedResponse
{
$attachment = PressReleaseAttachment::query()
->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',
],
);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Web\Concerns\AuthorizesPressReleaseMediaAccess;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PressReleaseImageMediaController extends Controller
{
use AuthorizesPressReleaseMediaAccess;
public function __invoke(Request $request, int $image, ?string $variant = null): StreamedResponse
{
$image = PressReleaseImage::query()
->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',
]);
}
}

View file

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

View file

@ -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()]);
}
}

View file

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

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

View file

@ -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
{

View file

@ -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,

View file

@ -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

View file

@ -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

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