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

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