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
39
app/Console/Commands/PrivatizePressReleaseMedia.php
Normal file
39
app/Console/Commands/PrivatizePressReleaseMedia.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
102
app/Services/PressRelease/PressReleaseMediaPrivatizer.php
Normal file
102
app/Services/PressRelease/PressReleaseMediaPrivatizer.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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