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 0b35c3b04d
commit 000ae93e3b
21 changed files with 588 additions and 70 deletions

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