mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-27 06:33:18 +00:00
🔒 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:
240
app/Console/Commands/MoveMediaToPrivateDisk.php
Normal file
240
app/Console/Commands/MoveMediaToPrivateDisk.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user