From 10dac9d02b000eebcecfff9186b847f93e6f0099 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sun, 25 Jan 2026 19:14:49 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20Implement=20signed=20media=20URL?= =?UTF-8?q?s=20and=20migrate=20media=20storage=20to=20private=20disk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ✅ Introduce `getSignedMediaUrl` in models for temporary signed URLs - 🗂️ Migrate media collections to private disk for added security - 🔧 Add `media:move-to-private` command to streamline migration - ⚙️ Update views and components to use signed media URLs - ✏️ Adjust route `media.signed` for signed file access handling --- .../Commands/MoveMediaToPrivateDisk.php | 240 ++++++++++++++++++ app/Enums/NewsCategory.php | 26 +- app/Models/Course.php | 12 + app/Models/Lecturer.php | 12 + app/Models/Meetup.php | 11 + app/Models/ProjectProposal.php | 11 + app/Models/Venue.php | 11 + .../views/components/project-card.blade.php | 4 +- .../project-support/form/edit.blade.php | 2 +- .../project-support/show.blade.php | 2 +- routes/web.php | 9 + 11 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 app/Console/Commands/MoveMediaToPrivateDisk.php diff --git a/app/Console/Commands/MoveMediaToPrivateDisk.php b/app/Console/Commands/MoveMediaToPrivateDisk.php new file mode 100644 index 0000000..26fd463 --- /dev/null +++ b/app/Console/Commands/MoveMediaToPrivateDisk.php @@ -0,0 +1,240 @@ +where('disk', 'public') + ->get(); + + if ($mediaOnPublicDisk->isEmpty()) { + $this->info('No media files found on public disk. Nothing to migrate.'); + + return self::SUCCESS; + } + + $this->info(sprintf('Found %d media file(s) on public disk.', $mediaOnPublicDisk->count())); + + if ($this->option('dry-run')) { + $this->warn('DRY RUN MODE - No files will be moved.'); + $this->newLine(); + $this->showDryRunTable($mediaOnPublicDisk); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm('Do you want to proceed with moving these files to the private disk?')) { + $this->info('Operation cancelled.'); + + return self::SUCCESS; + } + + $this->newLine(); + $progressBar = $this->output->createProgressBar($mediaOnPublicDisk->count()); + $progressBar->start(); + + foreach ($mediaOnPublicDisk as $media) { + $this->processMedia($media); + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(2); + + $this->showSummary(); + + return $this->errorCount > 0 ? self::FAILURE : self::SUCCESS; + } + + private function processMedia(Media $media): void + { + $sourceDisk = Storage::disk('public'); + $targetDisk = Storage::disk('private'); + + $relativePath = $this->getRelativePath($media); + + if (! $sourceDisk->exists($relativePath)) { + $this->newLine(); + $this->warn(sprintf(' Source file not found: %s (Media ID: %d)', $relativePath, $media->id)); + $this->skippedCount++; + + return; + } + + if ($targetDisk->exists($relativePath)) { + $this->newLine(); + $this->warn(sprintf(' Target already exists: %s (Media ID: %d)', $relativePath, $media->id)); + $this->skippedCount++; + + return; + } + + try { + DB::beginTransaction(); + + $mediaDirectory = (string) $media->id; + + if (! $targetDisk->exists($mediaDirectory)) { + $targetDisk->makeDirectory($mediaDirectory); + } + + $fileContent = $sourceDisk->get($relativePath); + if ($fileContent === null) { + throw new \RuntimeException('Failed to read source file'); + } + + $targetDisk->put($relativePath, $fileContent); + + if (! $targetDisk->exists($relativePath)) { + throw new \RuntimeException('Failed to write target file'); + } + + $this->moveConversions($media, $sourceDisk, $targetDisk); + + $media->disk = 'private'; + $media->save(); + + $sourceDisk->delete($relativePath); + $this->cleanupEmptyDirectories($sourceDisk, $mediaDirectory); + + DB::commit(); + $this->movedCount++; + + } catch (Throwable $e) { + DB::rollBack(); + + $this->cleanupFailedMigration($targetDisk, (string) $media->id); + + $this->newLine(); + $this->error(sprintf(' Failed to move Media ID %d: %s', $media->id, $e->getMessage())); + $this->errorCount++; + } + } + + private function moveConversions(Media $media, $sourceDisk, $targetDisk): void + { + $conversionsPath = $this->getConversionsPath($media); + + if (! $sourceDisk->exists($conversionsPath)) { + return; + } + + $conversionFiles = $sourceDisk->files($conversionsPath); + + if (! $targetDisk->exists($conversionsPath)) { + $targetDisk->makeDirectory($conversionsPath); + } + + foreach ($conversionFiles as $conversionFile) { + $content = $sourceDisk->get($conversionFile); + if ($content !== null) { + $targetDisk->put($conversionFile, $content); + $sourceDisk->delete($conversionFile); + } + } + + $this->cleanupEmptyDirectories($sourceDisk, $conversionsPath); + } + + private function getRelativePath(Media $media): string + { + return sprintf('%s/%s', $media->id, $media->file_name); + } + + private function getConversionsPath(Media $media): string + { + return sprintf('%s/conversions', $media->id); + } + + private function cleanupEmptyDirectories($disk, string $path): void + { + while ($path !== '.' && $path !== '') { + $files = $disk->files($path); + $directories = $disk->directories($path); + + if (empty($files) && empty($directories)) { + $disk->deleteDirectory($path); + $path = dirname($path); + } else { + break; + } + } + } + + private function cleanupFailedMigration($disk, string $mediaDirectory): void + { + if ($disk->exists($mediaDirectory)) { + $disk->deleteDirectory($mediaDirectory); + } + } + + private function showDryRunTable($mediaCollection): void + { + $rows = $mediaCollection->map(fn (Media $media) => [ + $media->id, + $media->model_type, + $media->model_id, + $media->collection_name, + $media->file_name, + $this->formatBytes($media->size), + ])->toArray(); + + $this->table( + ['ID', 'Model Type', 'Model ID', 'Collection', 'Filename', 'Size'], + $rows + ); + } + + private function showSummary(): void + { + $this->info('Migration complete.'); + $this->newLine(); + $this->table( + ['Status', 'Count'], + [ + ['Moved', $this->movedCount], + ['Skipped', $this->skippedCount], + ['Errors', $this->errorCount], + ] + ); + + if ($this->errorCount > 0) { + $this->newLine(); + $this->error('Some files failed to migrate. Please check the errors above and retry.'); + } + } + + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $unitIndex = 0; + + while ($bytes >= 1024 && $unitIndex < count($units) - 1) { + $bytes /= 1024; + $unitIndex++; + } + + return sprintf('%.2f %s', $bytes, $units[$unitIndex]); + } +} diff --git a/app/Enums/NewsCategory.php b/app/Enums/NewsCategory.php index 960cedd..d40519e 100644 --- a/app/Enums/NewsCategory.php +++ b/app/Enums/NewsCategory.php @@ -20,8 +20,32 @@ enum NewsCategory: int use Options; use Values; + #[Label('Einundzwanzig')] #[Color('amber')] #[Icon('bitcoin-sign')] + case Einundzwanzig = 1; + + #[Label('Allgemeines')] #[Color('zinc')] #[Icon('newspaper')] + case Allgemeines = 2; + #[Label('Organisation')] #[Color('cyan')] #[Icon('file-lines')] - case ORGANISATION = 1; + case Organisation = 3; + + #[Label('Bitcoin')] #[Color('orange')] #[Icon('coins')] + case Bitcoin = 4; + + #[Label('Meetups')] #[Color('green')] #[Icon('users')] + case Meetups = 5; + + #[Label('Bildung')] #[Color('blue')] #[Icon('graduation-cap')] + case Bildung = 6; + + #[Label('Protokolle')] #[Color('purple')] #[Icon('clipboard-list')] + case Protokolle = 7; + + #[Label('Finanzen')] #[Color('emerald')] #[Icon('chart-pie')] + case Finanzen = 8; + + #[Label('Veranstaltungen')] #[Color('rose')] #[Icon('calendar-star')] + case Veranstaltungen = 9; public static function selectOptions() { diff --git a/app/Models/Course.php b/app/Models/Course.php index 0f9bd8b..1b346c1 100644 --- a/app/Models/Course.php +++ b/app/Models/Course.php @@ -67,6 +67,7 @@ class Course extends Model implements HasMedia 'image/gif', 'image/webp', ]) + ->useDisk('private') ->useFallbackUrl(asset('img/einundzwanzig.png')); $this->addMediaCollection('images') ->acceptsMimeTypes([ @@ -75,9 +76,20 @@ class Course extends Model implements HasMedia 'image/gif', 'image/webp', ]) + ->useDisk('private') ->useFallbackUrl(asset('img/einundzwanzig.png')); } + public function getSignedMediaUrl(string $collection = 'logo', int $expireMinutes = 60): string + { + $media = $this->getFirstMedia($collection); + if (! $media) { + return asset('img/einundzwanzig.png'); + } + + return url()->temporarySignedRoute('media.signed', now()->addMinutes($expireMinutes), ['media' => $media]); + } + public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); diff --git a/app/Models/Lecturer.php b/app/Models/Lecturer.php index d94e905..f780dbe 100644 --- a/app/Models/Lecturer.php +++ b/app/Models/Lecturer.php @@ -69,6 +69,7 @@ class Lecturer extends Model implements HasMedia 'image/gif', 'image/webp', ]) + ->useDisk('private') ->useFallbackUrl(asset('img/einundzwanzig.png')); $this->addMediaCollection('images') ->acceptsMimeTypes([ @@ -77,9 +78,20 @@ class Lecturer extends Model implements HasMedia 'image/gif', 'image/webp', ]) + ->useDisk('private') ->useFallbackUrl(asset('img/einundzwanzig.png')); } + public function getSignedMediaUrl(string $collection = 'avatar', int $expireMinutes = 60): string + { + $media = $this->getFirstMedia($collection); + if (! $media) { + return asset('img/einundzwanzig.png'); + } + + return url()->temporarySignedRoute('media.signed', now()->addMinutes($expireMinutes), ['media' => $media]); + } + /** * Get the options for generating the slug. */ diff --git a/app/Models/Meetup.php b/app/Models/Meetup.php index e97c82a..c458c42 100644 --- a/app/Models/Meetup.php +++ b/app/Models/Meetup.php @@ -79,9 +79,20 @@ class Meetup extends Model implements HasMedia 'image/gif', 'image/webp', ]) + ->useDisk('private') ->useFallbackUrl(asset('img/einundzwanzig.png')); } + public function getSignedMediaUrl(string $collection = 'logo', int $expireMinutes = 60): string + { + $media = $this->getFirstMedia($collection); + if (! $media) { + return asset('img/einundzwanzig.png'); + } + + return url()->temporarySignedRoute('media.signed', now()->addMinutes($expireMinutes), ['media' => $media]); + } + public function createdBy(): BelongsTo { return $this->belongsTo(User::class, 'created_by'); diff --git a/app/Models/ProjectProposal.php b/app/Models/ProjectProposal.php index 72d0862..7f5f008 100644 --- a/app/Models/ProjectProposal.php +++ b/app/Models/ProjectProposal.php @@ -73,9 +73,20 @@ class ProjectProposal extends Model implements HasMedia 'image/gif', 'image/webp', ]) + ->useDisk('private') ->useFallbackUrl(asset('einundzwanzig-alpha.jpg')); } + public function getSignedMediaUrl(string $collection = 'main', int $expireMinutes = 60): string + { + $media = $this->getFirstMedia($collection); + if (! $media) { + return asset('einundzwanzig-alpha.jpg'); + } + + return url()->temporarySignedRoute('media.signed', now()->addMinutes($expireMinutes), ['media' => $media]); + } + public function einundzwanzigPleb(): BelongsTo { return $this->belongsTo(EinundzwanzigPleb::class); diff --git a/app/Models/Venue.php b/app/Models/Venue.php index 43bc146..557f2af 100644 --- a/app/Models/Venue.php +++ b/app/Models/Venue.php @@ -69,9 +69,20 @@ class Venue extends Model implements HasMedia 'image/gif', 'image/webp', ]) + ->useDisk('private') ->useFallbackUrl(asset('img/einundzwanzig.png')); } + public function getSignedMediaUrl(string $collection = 'images', int $expireMinutes = 60): string + { + $media = $this->getFirstMedia($collection); + if (! $media) { + return asset('img/einundzwanzig.png'); + } + + return url()->temporarySignedRoute('media.signed', now()->addMinutes($expireMinutes), ['media' => $media]); + } + /** * Get the options for generating the slug. */ diff --git a/resources/views/components/project-card.blade.php b/resources/views/components/project-card.blade.php index 34e638f..d13468b 100644 --- a/resources/views/components/project-card.blade.php +++ b/resources/views/components/project-card.blade.php @@ -24,7 +24,7 @@ Meetup 01 + src="{{ $project->getSignedMediaUrl('main') }}" alt="Meetup 01">