🔒 Implement signed media URLs and migrate media storage to private disk

-  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
This commit is contained in:
HolgerHatGarKeineNode
2026-01-25 19:14:49 +01:00
parent fe2f321a12
commit 10dac9d02b
11 changed files with 335 additions and 5 deletions

View File

@@ -0,0 +1,240 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Throwable;
class MoveMediaToPrivateDisk extends Command
{
protected $signature = 'media:move-to-private
{--dry-run : Show what would be moved without actually moving}
{--force : Skip confirmation prompt}';
protected $description = 'Move all Spatie Media Library files from public disk to private disk';
private int $movedCount = 0;
private int $skippedCount = 0;
private int $errorCount = 0;
public function handle(): int
{
$mediaOnPublicDisk = Media::query()
->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]);
}
}

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

@@ -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.
*/

View File

@@ -24,7 +24,7 @@
<a class="relative block w-full h-48 sm:w-56 sm:h-auto xl:sidebar-expanded:w-40 2xl:sidebar-expanded:w-56 shrink-0 sm:shrink-0"
href="{{ route('association.projectSupport.item', ['projectProposal' => $project]) }}">
<img class="absolute object-cover object-center w-full h-full"
src="{{ $project->getFirstMediaUrl('main') }}" alt="Meetup 01">
src="{{ $project->getSignedMediaUrl('main') }}" alt="Meetup 01">
<button class="absolute top-0 right-0 mt-4 mr-4">
<img class="rounded-full h-8 w-8"
src="{{ $project->einundzwanzigPleb->profile?->picture }}"
@@ -36,7 +36,7 @@
<a class="relative block w-full h-48 sm:w-56 sm:h-auto xl:sidebar-expanded:w-40 2xl:sidebar-expanded:w-56 shrink-0 sm:shrink-0"
href="{{ route('association.projectSupport.item', ['projectProposal' => $project]) }}">
<img class="absolute object-cover object-center w-full h-full"
src="{{ $project->getFirstMediaUrl('main') }}" alt="Meetup 01">
src="{{ $project->getSignedMediaUrl('main') }}" alt="Meetup 01">
<button class="absolute top-0 right-0 mt-4 mr-4">
<img class="rounded-full h-8 w-8"
src="{{ $project->einundzwanzigPleb->profile?->picture }}"

View File

@@ -179,7 +179,7 @@ class extends Component
<div class="mt-4">
<flux:file-item
:heading="$project->getFirstMedia('main')->file_name"
:image="$project->getFirstMediaUrl('main')"
:image="$project->getSignedMediaUrl('main')"
:size="$project->getFirstMedia('main')->size"
>
<x-slot name="actions">

View File

@@ -130,7 +130,7 @@ new class extends Component {
</div>
<figure class="mb-6">
<img class="rounded-sm h-48" src="{{ $projectProposal->getFirstMediaUrl('main') }}"
<img class="rounded-sm h-48" src="{{ $projectProposal->getSignedMediaUrl('main') }}"
alt="Picture">
</figure>

View File

@@ -13,6 +13,15 @@ Route::get('dl/{media}', function (Media $media, Request $request) {
->name('dl')
->middleware('signed');
Route::get('media/{media}', function (Media $media, Request $request) {
return response()->file($media->getPath(), [
'Content-Type' => $media->mime_type,
'Cache-Control' => 'private, max-age=3600',
]);
})
->name('media.signed')
->middleware('signed');
Route::post('logout', function () {
\App\Support\NostrAuth::logout();
Session::flush();