diff --git a/app/Console/Commands/MigrateMediaToPrivateDisk.php b/app/Console/Commands/MigrateMediaToPrivateDisk.php index 9286abcc..c3547c0d 100644 --- a/app/Console/Commands/MigrateMediaToPrivateDisk.php +++ b/app/Console/Commands/MigrateMediaToPrivateDisk.php @@ -4,6 +4,7 @@ namespace App\Console\Commands; use App\Models\FileDisk; use App\Models\Setting; +use App\Services\FileDiskService; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -24,21 +25,8 @@ class MigrateMediaToPrivateDisk extends Command return self::FAILURE; } - $prefix = env('DYNAMIC_DISK_PREFIX', 'temp_'); - $targetDiskName = $prefix.$targetDisk->driver; - - // Register disk config without mutating filesystems.default - $credentials = collect(json_decode($targetDisk->credentials)); - $baseConfig = config('filesystems.disks.'.$targetDisk->driver, []); - - foreach ($baseConfig as $key => $value) { - if ($credentials->has($key)) { - $baseConfig[$key] = $credentials[$key]; - } - } - - config(['filesystems.disks.'.$targetDiskName => $baseConfig]); - $targetRoot = $baseConfig['root'] ?? null; + $targetDiskName = app(FileDiskService::class)->registerDisk($targetDisk); + $targetRoot = config('filesystems.disks.'.$targetDiskName.'.root'); if (! $targetRoot) { $this->error('Could not resolve target disk root path.'); diff --git a/app/Http/Controllers/Admin/BackupsController.php b/app/Http/Controllers/Admin/BackupsController.php index 7b27234b..a1eaec3f 100644 --- a/app/Http/Controllers/Admin/BackupsController.php +++ b/app/Http/Controllers/Admin/BackupsController.php @@ -4,61 +4,47 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Jobs\CreateBackupJob; -use App\Models\FileDisk; use App\Rules\Backup\PathToZip; +use App\Services\Backup\BackupService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Cache; use Spatie\Backup\BackupDestination\Backup; -use Spatie\Backup\BackupDestination\BackupDestination; use Spatie\Backup\Helpers\Format; use Symfony\Component\HttpFoundation\StreamedResponse; class BackupsController extends Controller { + public function __construct( + private readonly BackupService $backupService, + ) {} + public function index(Request $request): JsonResponse { $this->authorize('manage backups'); - $configuredBackupDisks = config('backup.backup.destination.disks'); - try { - if ($request->file_disk_id) { - $fileDisk = FileDisk::find($request->file_disk_id); - if ($fileDisk) { - $fileDisk->setConfig(); - $prefix = env('DYNAMIC_DISK_PREFIX', 'temp_'); - config(['backup.backup.destination.disks' => [$prefix.$fileDisk->driver]]); - $configuredBackupDisks = config('backup.backup.destination.disks'); - } - } + $destination = $this->backupService->getDestination($request->file_disk_id); - $backupDestination = BackupDestination::create(config('filesystems.default'), config('backup.backup.name')); - - $backups = Cache::remember("backups-{$request->file_disk_id}", now()->addSeconds(4), function () use ($backupDestination) { - return $backupDestination - ->backups() - ->map(function (Backup $backup) { - return [ - 'path' => $backup->path(), - 'created_at' => $backup->date()->format('Y-m-d H:i:s'), - 'size' => Format::humanReadableSize($backup->sizeInBytes()), - ]; - }) - ->toArray(); - }); + $backups = $destination + ->backups() + ->map(function (Backup $backup) { + return [ + 'path' => $backup->path(), + 'created_at' => $backup->date()->format('Y-m-d H:i:s'), + 'size' => Format::humanReadableSize($backup->sizeInBytes()), + ]; + }) + ->toArray(); return response()->json([ 'backups' => $backups, - 'disks' => $configuredBackupDisks, ]); } catch (\Exception $e) { return response()->json([ 'backups' => [], 'error' => 'invalid_disk_credentials', 'error_message' => $e->getMessage(), - 'disks' => $configuredBackupDisks, ]); } } @@ -82,9 +68,9 @@ class BackupsController extends Controller 'path' => ['required', new PathToZip], ]); - $backupDestination = BackupDestination::create(config('filesystems.default'), config('backup.backup.name')); + $destination = $this->backupService->getDestination($request->file_disk_id); - $backupDestination + $destination ->backups() ->first(function (Backup $backup) use ($validated) { return $backup->path() === $validated['path']; @@ -102,9 +88,9 @@ class BackupsController extends Controller 'path' => ['required', new PathToZip], ]); - $backupDestination = BackupDestination::create(config('filesystems.default'), config('backup.backup.name')); + $destination = $this->backupService->getDestination($request->file_disk_id); - $backup = $backupDestination->backups()->first(function (Backup $backup) use ($validated) { + $backup = $destination->backups()->first(function (Backup $backup) use ($validated) { return $backup->path() === $validated['path']; }); diff --git a/app/Http/Controllers/Admin/Settings/DiskController.php b/app/Http/Controllers/Admin/Settings/DiskController.php index 1588a598..30d3312f 100644 --- a/app/Http/Controllers/Admin/Settings/DiskController.php +++ b/app/Http/Controllers/Admin/Settings/DiskController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\DiskEnvironmentRequest; use App\Http\Resources\FileDiskResource; use App\Models\FileDisk; +use App\Models\Setting; use App\Services\FileDiskService; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; @@ -91,8 +92,10 @@ class DiskController extends Controller $diskData = []; switch ($disk) { case 'local': + // Path is relative to storage/app/. + // e.g., "backups" resolves to storage/app/backups/ at runtime. $diskData = [ - 'root' => config('filesystems.disks.local.root'), + 'root' => '', ]; break; @@ -216,11 +219,50 @@ class DiskController extends Controller ], ]; - $default = config('filesystems.default'); + $defaultDisk = FileDisk::where('set_as_default', true)->first(); return response()->json([ 'drivers' => $drivers, - 'default' => $default, + 'default' => $defaultDisk?->driver ?? 'local', ]); } + + public function getDiskPurposes(): JsonResponse + { + $this->authorize('manage file disk'); + + $defaultDisk = FileDisk::where('set_as_default', true)->first(); + $defaultId = $defaultDisk?->id; + + return response()->json([ + 'media_disk_id' => Setting::getSetting('media_disk_id') ?? $defaultId, + 'pdf_disk_id' => Setting::getSetting('pdf_disk_id') ?? $defaultId, + 'backup_disk_id' => Setting::getSetting('backup_disk_id') ?? $defaultId, + ]); + } + + public function updateDiskPurposes(Request $request): JsonResponse + { + $this->authorize('manage file disk'); + + $request->validate([ + 'media_disk_id' => 'nullable|exists:file_disks,id', + 'pdf_disk_id' => 'nullable|exists:file_disks,id', + 'backup_disk_id' => 'nullable|exists:file_disks,id', + ]); + + if ($request->has('media_disk_id')) { + Setting::setSetting('media_disk_id', $request->media_disk_id); + } + + if ($request->has('pdf_disk_id')) { + Setting::setSetting('pdf_disk_id', $request->pdf_disk_id); + } + + if ($request->has('backup_disk_id')) { + Setting::setSetting('backup_disk_id', $request->backup_disk_id); + } + + return response()->json(['success' => true]); + } } diff --git a/app/Models/FileDisk.php b/app/Models/FileDisk.php index 0a8fca72..545d7e91 100644 --- a/app/Models/FileDisk.php +++ b/app/Models/FileDisk.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Services\FileDiskService; use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -31,6 +32,21 @@ class FileDisk extends Model $this->attributes['credentials'] = json_encode($value); } + /** + * Decode credentials, handling double-encoded JSON from legacy data. + */ + public function getDecodedCredentials(): Collection + { + $decoded = json_decode($this->credentials, true); + + // Handle double-encoded JSON (string inside string) + if (is_string($decoded)) { + $decoded = json_decode($decoded, true); + } + + return collect($decoded ?? []); + } + public function scopeWhereOrder($query, $orderByField, $orderBy) { $query->orderBy($orderByField, $orderBy); @@ -83,14 +99,14 @@ class FileDisk extends Model /** * Apply this disk's credentials to the filesystem configuration at runtime. + * + * @deprecated Use FileDiskService::registerDisk() instead — setConfig() mutates filesystems.default. */ public function setConfig(): void { - $driver = $this->driver; - - $credentials = collect(json_decode($this['credentials'])); - - self::setFilesystem($credentials, $driver); + $service = app(FileDiskService::class); + $diskName = $service->registerDisk($this); + config(['filesystems.default' => $diskName]); } /** @@ -103,6 +119,8 @@ class FileDisk extends Model /** * Register a dynamic filesystem disk in the runtime configuration using the given credentials. + * + * @deprecated Use FileDisk::find($id)->registerDisk() instead. */ public static function setFilesystem(Collection $credentials, string $driver): void { @@ -118,6 +136,10 @@ class FileDisk extends Model } } + if ($driver === 'local' && isset($disks['root']) && ! str_starts_with($disks['root'], '/')) { + $disks['root'] = storage_path('app/'.$disks['root']); + } + config(['filesystems.disks.'.$prefix.$driver => $disks]); } diff --git a/app/Providers/AppConfigProvider.php b/app/Providers/AppConfigProvider.php index 3bfaf859..8b8c8815 100644 --- a/app/Providers/AppConfigProvider.php +++ b/app/Providers/AppConfigProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Models\FileDisk; use App\Models\Setting; +use App\Services\FileDiskService; use App\Services\Setup\InstallUtils; use Illuminate\Support\Facades\Config; use Illuminate\Support\ServiceProvider; @@ -163,28 +164,16 @@ class AppConfigProvider extends ServiceProvider protected function configureFileSystemFromDatabase(): void { try { - // Register the default file disk config without changing filesystems.default. - // Calling setConfig() mutates the global default which causes side effects - // on pages that make multiple API requests (e.g., File Disk admin page). $fileDisk = FileDisk::whereSetAsDefault(true)->first(); - if ($fileDisk) { - $prefix = env('DYNAMIC_DISK_PREFIX', 'temp_'); - $diskName = $prefix.$fileDisk->driver; - $credentials = collect(json_decode($fileDisk->credentials)); - $baseConfig = config('filesystems.disks.'.$fileDisk->driver, []); - - foreach ($baseConfig as $key => $value) { - if ($credentials->has($key)) { - $baseConfig[$key] = $credentials[$key]; - } - } - - config(['filesystems.disks.'.$diskName => $baseConfig]); - - // Point Spatie Media Library at the same disk - config(['media-library.disk_name' => $diskName]); + if (! $fileDisk) { + return; } + + $diskName = app(FileDiskService::class)->registerDisk($fileDisk); + + // Point Spatie Media Library at the resolved disk + config(['media-library.disk_name' => $diskName]); } catch (\Exception $e) { // Silently fail if database is not available (during installation, migrations, etc.) } diff --git a/app/Services/Backup/BackupConfigurationFactory.php b/app/Services/Backup/BackupConfigurationFactory.php index e2fd9acd..8d742224 100644 --- a/app/Services/Backup/BackupConfigurationFactory.php +++ b/app/Services/Backup/BackupConfigurationFactory.php @@ -3,6 +3,7 @@ namespace App\Services\Backup; use App\Models\FileDisk; +use App\Services\FileDiskService; use Exception; use Spatie\Backup\Config\Config; @@ -16,14 +17,10 @@ class BackupConfigurationFactory $fileDisk = FileDisk::find($data['file_disk_id']); - $fileDisk->setConfig(); + $diskName = app(FileDiskService::class)->registerDisk($fileDisk); - $prefix = env('DYNAMIC_DISK_PREFIX', 'temp_'); + config(['backup.backup.destination.disks' => [$diskName]]); - config(['backup.backup.destination.disks' => [$prefix.$fileDisk->driver]]); - - $config = Config::fromArray(config('backup')); - - return $config; + return Config::fromArray(config('backup')); } } diff --git a/app/Services/Backup/BackupService.php b/app/Services/Backup/BackupService.php new file mode 100644 index 00000000..41a95fc3 --- /dev/null +++ b/app/Services/Backup/BackupService.php @@ -0,0 +1,52 @@ +first(); + } + + /** + * Create a BackupDestination pointing at the resolved disk. + */ + public function getDestination(?int $fileDiskId = null): BackupDestination + { + $disk = $this->resolveBackupDisk($fileDiskId); + + $diskName = $disk + ? $this->fileDiskService->registerDisk($disk) + : config('filesystems.default'); + + return BackupDestination::create($diskName, config('backup.backup.name')); + } +} diff --git a/app/Services/FileDiskService.php b/app/Services/FileDiskService.php index 35265367..b24d1fe1 100644 --- a/app/Services/FileDiskService.php +++ b/app/Services/FileDiskService.php @@ -13,8 +13,10 @@ class FileDiskService $this->clearDefaults(); } + $credentials = $this->normalizeCredentials($request->credentials, $request->driver); + return FileDisk::create([ - 'credentials' => $request->credentials, + 'credentials' => $credentials, 'name' => $request->name, 'driver' => $request->driver, 'set_as_default' => $request->set_as_default, @@ -24,8 +26,10 @@ class FileDiskService public function update(FileDisk $disk, Request $request): FileDisk { + $credentials = $this->normalizeCredentials($request->credentials, $request->driver); + $data = [ - 'credentials' => $request->credentials, + 'credentials' => $credentials, 'name' => $request->name, 'driver' => $request->driver, ]; @@ -53,21 +57,77 @@ class FileDiskService return $disk; } + /** + * Get the unique Laravel filesystem disk name for a FileDisk. + */ + public function getDiskName(FileDisk $disk): string + { + if ($disk->isSystem()) { + return $disk->name === 'local_public' ? 'local_public' : 'local'; + } + + return 'disk_'.$disk->id; + } + + /** + * Register a FileDisk in the runtime filesystem configuration. + * Returns the Laravel disk name. Does NOT change filesystems.default. + */ + public function registerDisk(FileDisk $disk): string + { + $diskName = $this->getDiskName($disk); + + // System disks are already in config/filesystems.php + if ($disk->isSystem()) { + return $diskName; + } + + $credentials = $disk->getDecodedCredentials(); + $baseConfig = config('filesystems.disks.'.$disk->driver, []); + + foreach ($baseConfig as $key => $value) { + if ($credentials->has($key)) { + $baseConfig[$key] = $credentials[$key]; + } + } + + // Resolve relative local roots to storage/app/{path} + if ($disk->driver === 'local' && isset($baseConfig['root']) && ! str_starts_with($baseConfig['root'], '/')) { + $baseConfig['root'] = storage_path('app/'.$baseConfig['root']); + } + + config(['filesystems.disks.'.$diskName => $baseConfig]); + + return $diskName; + } + public function validateCredentials(array $credentials, string $driver): bool { - FileDisk::setFilesystem(collect($credentials), $driver); + // Create a temporary disk config for validation + $baseConfig = config('filesystems.disks.'.$driver, []); - $prefix = env('DYNAMIC_DISK_PREFIX', 'temp_'); + foreach ($baseConfig as $key => $value) { + if (isset($credentials[$key])) { + $baseConfig[$key] = $credentials[$key]; + } + } + + if ($driver === 'local' && isset($baseConfig['root']) && ! str_starts_with($baseConfig['root'], '/')) { + $baseConfig['root'] = storage_path('app/'.$baseConfig['root']); + } + + $tempDiskName = 'validation_temp'; + config(['filesystems.disks.'.$tempDiskName => $baseConfig]); try { $root = ''; if ($driver == 'dropbox') { $root = $credentials['root'].'/'; } - \Storage::disk($prefix.$driver)->put($root.'invoiceshelf_temp.text', 'Check Credentials'); + \Storage::disk($tempDiskName)->put($root.'invoiceshelf_temp.text', 'Check Credentials'); - if (\Storage::disk($prefix.$driver)->exists($root.'invoiceshelf_temp.text')) { - \Storage::disk($prefix.$driver)->delete($root.'invoiceshelf_temp.text'); + if (\Storage::disk($tempDiskName)->exists($root.'invoiceshelf_temp.text')) { + \Storage::disk($tempDiskName)->delete($root.'invoiceshelf_temp.text'); return true; } @@ -78,6 +138,29 @@ class FileDiskService return false; } + /** + * For local disks, strip any absolute prefix and store the root + * as a path relative to storage/app. At runtime the path is + * resolved to an absolute path via storage_path(). + */ + private function normalizeCredentials(array $credentials, string $driver): array + { + if ($driver === 'local' && isset($credentials['root'])) { + $root = $credentials['root']; + + $storageApp = storage_path('app').'/'; + if (str_starts_with($root, $storageApp)) { + $root = substr($root, strlen($storageApp)); + } + + $root = ltrim($root, '/'); + + $credentials['root'] = $root; + } + + return $credentials; + } + private function clearDefaults(): void { FileDisk::query()->update(['set_as_default' => false]); diff --git a/config/filesystems.php b/config/filesystems.php index 169b37bd..25a7d2e4 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -37,6 +37,13 @@ return [ 'report' => false, ], + 'local_public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => env('APP_URL').'/storage', + 'visibility' => 'public', + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_KEY'), diff --git a/database/factories/FileDiskFactory.php b/database/factories/FileDiskFactory.php index 50c053e5..3bd97bea 100644 --- a/database/factories/FileDiskFactory.php +++ b/database/factories/FileDiskFactory.php @@ -25,7 +25,7 @@ class FileDiskFactory extends Factory 'set_as_default' => false, 'credentials' => [ 'driver' => 'local', - 'root' => storage_path('app'), + 'root' => 'test-disks', ], ]; diff --git a/lang/en.json b/lang/en.json index 58639961..1bb7cd41 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1461,7 +1461,18 @@ "deleted_message": "File Disk deleted successfully", "disk_variables_save_successfully": "Disk Configured Successfully", "disk_variables_save_error": "Disk configuration failed.", - "invalid_disk_credentials": "Invalid credential of selected disk" + "invalid_disk_credentials": "Invalid credential of selected disk", + "disk_assignments": "Disk Assignments", + "disk_assignments_description": "Configure which disk is used for different types of file storage.", + "media_storage": "Media Storage", + "media_storage_description": "Used for expense receipts, avatars, and company logos.", + "pdf_storage": "PDF Storage", + "pdf_storage_description": "Used for generated invoice, estimate, and payment PDFs.", + "backup_storage": "Backup Storage", + "backup_storage_description": "Used for application backups.", + "purposes_saved": "Disk assignments saved successfully", + "local_root_hint": "Path is relative to storage/app/. For example, entering \"backups\" will store files in storage/app/backups/.", + "change_disk_warning": "Changing disk assignments will only affect new uploads. Existing files will remain on their original disk and will continue to be accessible. If you need to move existing files, use the command: php artisan media:secure" }, "taxations": { "add_billing_address": "Enter Billing Address", diff --git a/resources/scripts-v2/api/endpoints.ts b/resources/scripts-v2/api/endpoints.ts index 1b5fb08f..d2c70fbc 100644 --- a/resources/scripts-v2/api/endpoints.ts +++ b/resources/scripts-v2/api/endpoints.ts @@ -118,6 +118,7 @@ export const API = { // Disks & Backups DISKS: '/api/v1/disks', DISK_DRIVERS: '/api/v1/disk/drivers', + DISK_PURPOSES: '/api/v1/disk/purposes', BACKUPS: '/api/v1/backups', DOWNLOAD_BACKUP: '/api/v1/download-backup', diff --git a/resources/scripts-v2/api/services/disk.service.ts b/resources/scripts-v2/api/services/disk.service.ts index 30f26b1e..7c259c74 100644 --- a/resources/scripts-v2/api/services/disk.service.ts +++ b/resources/scripts-v2/api/services/disk.service.ts @@ -34,6 +34,12 @@ export interface CreateDiskPayload { set_as_default?: boolean } +export interface DiskPurposes { + media_disk_id: number | null + pdf_disk_id: number | null + backup_disk_id: number | null +} + export const diskService = { async list(params?: ListParams): Promise> { const { data } = await client.get(API.DISKS, { params }) @@ -67,4 +73,14 @@ export const diskService = { const { data } = await client.get(API.DISK_DRIVERS) return data }, + + async getDiskPurposes(): Promise { + const { data } = await client.get(API.DISK_PURPOSES) + return data + }, + + async updateDiskPurposes(payload: Partial): Promise<{ success: boolean }> { + const { data } = await client.put(API.DISK_PURPOSES, payload) + return data + }, } diff --git a/resources/scripts-v2/components/base/BaseModal.vue b/resources/scripts-v2/components/base/BaseModal.vue index 0cc0205a..a8c4d1cb 100644 --- a/resources/scripts-v2/components/base/BaseModal.vue +++ b/resources/scripts-v2/components/base/BaseModal.vue @@ -93,7 +93,7 @@