diff --git a/app/Console/Commands/MigrateMediaToPrivateDisk.php b/app/Console/Commands/MigrateMediaToPrivateDisk.php new file mode 100644 index 00000000..1db6bbb9 --- /dev/null +++ b/app/Console/Commands/MigrateMediaToPrivateDisk.php @@ -0,0 +1,134 @@ +resolveTargetDisk(); + + if (! $targetDisk) { + $this->error('No target disk found. Set a default FileDisk or configure media_disk_id setting.'); + + return self::FAILURE; + } + + $prefix = env('DYNAMIC_DISK_PREFIX', 'temp_'); + $targetDiskName = $prefix.$targetDisk->driver; + + $targetDisk->setConfig(); + $targetRoot = config('filesystems.disks.'.$targetDiskName.'.root'); + + if (! $targetRoot) { + $this->error('Could not resolve target disk root path.'); + + return self::FAILURE; + } + + $records = DB::table('media') + ->where('disk', 'public') + ->where(function ($query) { + $query->where('collection_name', 'receipts'); + }) + ->get(); + + if ($records->isEmpty()) { + $this->info('No media files to migrate.'); + + return self::SUCCESS; + } + + $this->info('Found '.$records->count().' file(s) to migrate.'); + + if ($this->option('dry-run')) { + foreach ($records as $record) { + $this->line(" Would move: media/{$record->id}/{$record->file_name}"); + } + + return self::SUCCESS; + } + + $moved = 0; + $skipped = 0; + $publicMediaRoot = public_path('media'); + + $bar = $this->output->createProgressBar($records->count()); + $bar->start(); + + foreach ($records as $record) { + $relativePath = $record->id.DIRECTORY_SEPARATOR.$record->file_name; + $sourcePath = $publicMediaRoot.DIRECTORY_SEPARATOR.$relativePath; + $destPath = $targetRoot.DIRECTORY_SEPARATOR.$relativePath; + + if (! file_exists($sourcePath)) { + $skipped++; + $bar->advance(); + + continue; + } + + $destDir = dirname($destPath); + if (! File::isDirectory($destDir)) { + File::makeDirectory($destDir, 0755, true); + } + + File::move($sourcePath, $destPath); + + DB::table('media') + ->where('id', $record->id) + ->update(['disk' => $targetDiskName]); + + $moved++; + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + + $this->info("Done. Moved: {$moved}, Skipped (missing): {$skipped}"); + + // Clean up empty directories in public/media + $this->cleanEmptyDirectories($publicMediaRoot); + + return self::SUCCESS; + } + + private function resolveTargetDisk(): ?FileDisk + { + $mediaDiskId = Setting::getSetting('media_disk_id'); + + if ($mediaDiskId) { + return FileDisk::find($mediaDiskId); + } + + return FileDisk::where('set_as_default', true)->first(); + } + + private function cleanEmptyDirectories(string $path): void + { + if (! File::isDirectory($path)) { + return; + } + + $directories = File::directories($path); + + foreach ($directories as $dir) { + $this->cleanEmptyDirectories($dir); + + if (count(File::allFiles($dir)) === 0 && count(File::directories($dir)) === 0) { + File::deleteDirectory($dir); + } + } + } +} diff --git a/app/Http/Controllers/Admin/Settings/DiskController.php b/app/Http/Controllers/Admin/Settings/DiskController.php index d533b565..1588a598 100644 --- a/app/Http/Controllers/Admin/Settings/DiskController.php +++ b/app/Http/Controllers/Admin/Settings/DiskController.php @@ -11,6 +11,7 @@ use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Facades\DB; class DiskController extends Controller { @@ -158,8 +159,23 @@ class DiskController extends Controller { $this->authorize('manage file disk'); - if ($disk->setAsDefault() && $disk->type === 'SYSTEM') { - return respondJson('not_allowed', 'Not Allowed'); + if ($disk->type === 'SYSTEM') { + return respondJson('not_allowed', 'System disks cannot be deleted.'); + } + + if ($disk->setAsDefault()) { + return respondJson('not_allowed', 'The default disk cannot be deleted.'); + } + + $prefix = env('DYNAMIC_DISK_PREFIX', 'temp_'); + $diskName = $prefix.$disk->driver; + $mediaCount = DB::table('media') + ->where('disk', $diskName) + ->orWhere('disk', $disk->driver) + ->count(); + + if ($mediaCount > 0) { + return respondJson('disk_has_files', 'Cannot delete this disk — it contains '.$mediaCount.' file(s). Migrate files first.'); } $disk->delete(); diff --git a/app/Models/Expense.php b/app/Models/Expense.php index b67c34e6..b9209d46 100644 --- a/app/Models/Expense.php +++ b/app/Models/Expense.php @@ -42,6 +42,11 @@ class Expense extends Model implements HasMedia ]; } + public function registerMediaCollections(): void + { + $this->addMediaCollection('receipts'); + } + public function category(): BelongsTo { return $this->belongsTo(ExpenseCategory::class, 'expense_category_id'); @@ -92,7 +97,7 @@ class Expense extends Model implements HasMedia if ($media) { return [ - 'url' => $media->getFullUrl(), + 'url' => '/reports/expenses/'.$this->id.'/receipt', 'type' => $media->type, ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index acf9eda6..a6f99eda 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Models\FileDisk; +use App\Models\Setting; use App\Policies\CompanyPolicy; use App\Policies\CustomerPolicy; use App\Policies\DashboardPolicy; @@ -57,6 +59,7 @@ class AppServiceProvider extends ServiceProvider if (InstallUtils::isDbCreated()) { $this->addMenus(); + $this->configureMediaDisk(); } Gate::policy(Role::class, RolePolicy::class); @@ -166,4 +169,29 @@ class AppServiceProvider extends ServiceProvider { Broadcast::routes(['middleware' => 'api.auth']); } + + /** + * Configure Spatie Media Library to use the FileDisk system. + * + * Resolves the media disk from the `media_disk_id` setting, + * falling back to the default FileDisk. This ensures media + * uploads go to a private disk by default. + */ + private function configureMediaDisk(): void + { + try { + $mediaDiskId = Setting::getSetting('media_disk_id'); + $disk = $mediaDiskId + ? FileDisk::find($mediaDiskId) + : FileDisk::where('set_as_default', true)->first(); + + if ($disk) { + $disk->setConfig(); + $prefix = env('DYNAMIC_DISK_PREFIX', 'temp_'); + config(['media-library.disk_name' => $prefix.$disk->driver]); + } + } catch (\Exception $e) { + // DB not yet migrated or settings table missing — use config default + } + } } diff --git a/config/media-library.php b/config/media-library.php index 6fea786f..78a81237 100644 --- a/config/media-library.php +++ b/config/media-library.php @@ -31,7 +31,7 @@ return [ * The disk on which to store added files and derived images by default. Choose * one or more of the disks you've configured in config/filesystems.php. */ - 'disk_name' => env('MEDIA_DISK', 'public'), + 'disk_name' => env('MEDIA_DISK', 'local'), /* * The maximum file size of an item in bytes.