mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-04-15 01:04:03 +00:00
Refactor FileDisk system with per-disk unique names and disk assignments UI
Major changes to the file disk subsystem:
- Each FileDisk now gets a unique Laravel disk name (disk_{id}) instead
of temp_{driver}, fixing the bug where multiple local disks with
different roots overwrote each other's config.
- Move disk registration logic from FileDisk model to FileDiskService
(registerDisk, getDiskName). Model keeps only getDecodedCredentials
and a deprecated setConfig() wrapper.
- Add Disk Assignments admin UI (File Disk tab) with three purpose
dropdowns: Media Storage, PDF Storage, Backup Storage. Stored as
settings (media_disk_id, pdf_disk_id, backup_disk_id).
- Backup tab now uses the assigned backup disk instead of a per-backup
dropdown. BackupsController refactored to use BackupService which
centralizes disk resolution. Removed stale 4-second cache.
- Add local_public disk to config/filesystems.php so system disks
are properly defined.
- Local disk roots stored relative to storage/app/ with hint text
in the admin modal explaining the convention.
- Fix BaseModal watchEffect -> watch to prevent infinite request
loops on the File Disk page.
- Fix string/number comparison for disk purpose IDs from settings.
- Add safeguards: prevent deleting disks with files, warn on
purpose change, prevent deleting system disks.
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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'];
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.)
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
52
app/Services/Backup/BackupService.php
Normal file
52
app/Services/Backup/BackupService.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Backup;
|
||||
|
||||
use App\Models\FileDisk;
|
||||
use App\Models\Setting;
|
||||
use App\Services\FileDiskService;
|
||||
use Spatie\Backup\BackupDestination\BackupDestination;
|
||||
|
||||
class BackupService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FileDiskService $fileDiskService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the backup FileDisk from the given ID, settings, or default.
|
||||
*/
|
||||
public function resolveBackupDisk(?int $fileDiskId = null): ?FileDisk
|
||||
{
|
||||
if ($fileDiskId) {
|
||||
$disk = FileDisk::find($fileDiskId);
|
||||
if ($disk) {
|
||||
return $disk;
|
||||
}
|
||||
}
|
||||
|
||||
$backupDiskId = Setting::getSetting('backup_disk_id');
|
||||
if ($backupDiskId) {
|
||||
$disk = FileDisk::find($backupDiskId);
|
||||
if ($disk) {
|
||||
return $disk;
|
||||
}
|
||||
}
|
||||
|
||||
return FileDisk::where('set_as_default', true)->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'));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -25,7 +25,7 @@ class FileDiskFactory extends Factory
|
||||
'set_as_default' => false,
|
||||
'credentials' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app'),
|
||||
'root' => 'test-disks',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
13
lang/en.json
13
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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
|
||||
@@ -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<PaginatedResponse<Disk>> {
|
||||
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<DiskPurposes> {
|
||||
const { data } = await client.get(API.DISK_PURPOSES)
|
||||
return data
|
||||
},
|
||||
|
||||
async updateDiskPurposes(payload: Partial<DiskPurposes>): Promise<{ success: boolean }> {
|
||||
const { data } = await client.put(API.DISK_PURPOSES, payload)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useModalStore } from '@v2/stores/modal.store'
|
||||
import { computed, watchEffect, useSlots } from 'vue'
|
||||
import { computed, watch, useSlots } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogOverlay,
|
||||
@@ -120,9 +120,9 @@ const emit = defineEmits<Emits>()
|
||||
|
||||
const modalStore = useModalStore()
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.show) {
|
||||
emit('open', props.show)
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
emit('open', newVal)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
backupService,
|
||||
type CreateBackupPayload,
|
||||
} from '@v2/api/services/backup.service'
|
||||
import { diskService, type Disk } from '@v2/api/services/disk.service'
|
||||
import {
|
||||
getErrorTranslationKey,
|
||||
handleApiError,
|
||||
@@ -17,23 +16,14 @@ import {
|
||||
|
||||
type BackupOption = CreateBackupPayload['option']
|
||||
|
||||
interface DiskOption extends Disk {
|
||||
display_name: string
|
||||
}
|
||||
|
||||
interface BackupTypeOption {
|
||||
id: BackupOption
|
||||
label: string
|
||||
}
|
||||
|
||||
interface BackupModalData {
|
||||
disks?: DiskOption[]
|
||||
selectedDiskId?: number | null
|
||||
}
|
||||
|
||||
interface BackupForm {
|
||||
option: BackupOption | ''
|
||||
selectedDiskId: number | null
|
||||
file_disk_id: number | null
|
||||
}
|
||||
|
||||
const modalStore = useModalStore()
|
||||
@@ -41,27 +31,16 @@ const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const disks = ref<DiskOption[]>([])
|
||||
|
||||
const form = reactive<BackupForm>({
|
||||
option: 'full',
|
||||
selectedDiskId: null,
|
||||
file_disk_id: null,
|
||||
})
|
||||
|
||||
const backupTypeOptions: BackupTypeOption[] = [
|
||||
{
|
||||
id: 'full',
|
||||
label: 'full',
|
||||
},
|
||||
{
|
||||
id: 'only-db',
|
||||
label: 'only-db',
|
||||
},
|
||||
{
|
||||
id: 'only-files',
|
||||
label: 'only-files',
|
||||
},
|
||||
{ id: 'full', label: 'full' },
|
||||
{ id: 'only-db', label: 'only-db' },
|
||||
{ id: 'only-files', label: 'only-files' },
|
||||
]
|
||||
|
||||
const modalActive = computed<boolean>(() => {
|
||||
@@ -72,61 +51,23 @@ const rules = computed(() => ({
|
||||
option: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
selectedDiskId: {
|
||||
required: helpers.withMessage(t('validation.required'), required),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, form)
|
||||
|
||||
async function setInitialData(): Promise<void> {
|
||||
function setInitialData(): void {
|
||||
resetForm()
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
try {
|
||||
const modalData = isBackupModalData(modalStore.data) ? modalStore.data : null
|
||||
|
||||
if (modalData?.disks?.length) {
|
||||
disks.value = modalData.disks
|
||||
form.selectedDiskId =
|
||||
modalData.selectedDiskId ??
|
||||
modalData.disks.find((disk) => disk.set_as_default)?.id ??
|
||||
modalData.disks[0]?.id ??
|
||||
null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const response = await diskService.list({ limit: 'all' })
|
||||
|
||||
disks.value = response.data.map((disk) => ({
|
||||
...disk,
|
||||
display_name: `${disk.name} - [${disk.driver}]`,
|
||||
}))
|
||||
|
||||
const selectedDiskId =
|
||||
modalStore.data &&
|
||||
typeof modalStore.data === 'object' &&
|
||||
'id' in (modalStore.data as Record<string, unknown>)
|
||||
? Number((modalStore.data as Record<string, unknown>).id)
|
||||
: null
|
||||
|
||||
form.selectedDiskId =
|
||||
disks.value.find((disk) => disk.id === selectedDiskId)?.id ??
|
||||
disks.value.find((disk) => disk.set_as_default)?.id ??
|
||||
disks.value[0]?.id ??
|
||||
null
|
||||
} catch (error: unknown) {
|
||||
showApiError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
const modalData = modalStore.data as Record<string, unknown> | null
|
||||
if (modalData?.file_disk_id) {
|
||||
form.file_disk_id = Number(modalData.file_disk_id)
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup(): Promise<void> {
|
||||
v$.value.$touch()
|
||||
|
||||
if (v$.value.$invalid || !form.selectedDiskId) {
|
||||
if (v$.value.$invalid || !form.file_disk_id) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -135,7 +76,7 @@ async function createBackup(): Promise<void> {
|
||||
try {
|
||||
const response = await backupService.create({
|
||||
option: form.option as BackupOption,
|
||||
file_disk_id: form.selectedDiskId,
|
||||
file_disk_id: form.file_disk_id,
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
@@ -165,7 +106,7 @@ function showApiError(error: unknown): void {
|
||||
|
||||
function resetForm(): void {
|
||||
form.option = 'full'
|
||||
form.selectedDiskId = null
|
||||
form.file_disk_id = null
|
||||
v$.value.$reset()
|
||||
}
|
||||
|
||||
@@ -174,13 +115,8 @@ function closeModal(): void {
|
||||
|
||||
setTimeout(() => {
|
||||
resetForm()
|
||||
disks.value = []
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function isBackupModalData(value: unknown): value is BackupModalData {
|
||||
return Boolean(value && typeof value === 'object')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -212,28 +148,6 @@ function isBackupModalData(value: unknown): value is BackupModalData {
|
||||
@update:model-value="v$.option.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.disk.select_disk')"
|
||||
:error="
|
||||
v$.selectedDiskId.$error && v$.selectedDiskId.$errors[0]?.$message
|
||||
"
|
||||
required
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="form.selectedDiskId"
|
||||
:options="disks"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:can-deselect="false"
|
||||
:invalid="v$.selectedDiskId.$error"
|
||||
label="display_name"
|
||||
track-by="id"
|
||||
value-prop="id"
|
||||
searchable
|
||||
:placeholder="$t('settings.disk.select_disk')"
|
||||
@update:model-value="v$.selectedDiskId.$touch()"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ interface DiskField {
|
||||
key: string
|
||||
labelKey: string
|
||||
placeholder?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
interface DiskDriverOption {
|
||||
@@ -39,7 +40,8 @@ const DRIVER_FIELDS: Record<DiskDriverValue, DiskField[]> = {
|
||||
{
|
||||
key: 'root',
|
||||
labelKey: 'settings.disk.local_root',
|
||||
placeholder: 'Ex. /user/root/',
|
||||
placeholder: 'Ex. backups',
|
||||
hint: 'settings.disk.local_root_hint',
|
||||
},
|
||||
],
|
||||
s3: [
|
||||
@@ -512,6 +514,9 @@ function isDisk(value: unknown): value is Disk {
|
||||
:placeholder="field.placeholder"
|
||||
@input="touchCredential(field.key)"
|
||||
/>
|
||||
<span v-if="field.hint" class="text-xs text-subtle mt-1 block">
|
||||
{{ $t(field.hint) }}
|
||||
</span>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalStore } from '@v2/stores/modal.store'
|
||||
import { useDialogStore } from '@v2/stores/dialog.store'
|
||||
@@ -20,10 +20,6 @@ interface TableColumn {
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
interface DiskOption extends Disk {
|
||||
display_name: string
|
||||
}
|
||||
|
||||
interface FetchParams {
|
||||
page: number
|
||||
filter: Record<string, unknown>
|
||||
@@ -46,8 +42,7 @@ const notificationStore = useNotificationStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const table = ref<{ refresh: () => void } | null>(null)
|
||||
const disks = ref<DiskOption[]>([])
|
||||
const selectedDisk = ref<DiskOption | null>(null)
|
||||
const backupDisk = ref<Disk | null>(null)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const backupError = ref('')
|
||||
|
||||
@@ -68,6 +63,12 @@ const backupColumns = computed<TableColumn[]>(() => [
|
||||
label: t('settings.backup.size'),
|
||||
tdClass: 'font-medium text-heading',
|
||||
},
|
||||
{
|
||||
key: 'disk_name',
|
||||
label: t('settings.disk.title', 1),
|
||||
tdClass: 'font-medium text-muted',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
@@ -76,30 +77,27 @@ const backupColumns = computed<TableColumn[]>(() => [
|
||||
},
|
||||
])
|
||||
|
||||
watch(
|
||||
selectedDisk,
|
||||
(newDisk, oldDisk) => {
|
||||
if (newDisk?.id && oldDisk?.id && newDisk.id !== oldDisk.id) {
|
||||
refreshTable()
|
||||
}
|
||||
}
|
||||
)
|
||||
loadBackupDisk()
|
||||
|
||||
loadDisks()
|
||||
|
||||
async function loadDisks(): Promise<void> {
|
||||
async function loadBackupDisk(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
|
||||
try {
|
||||
const response = await diskService.list({ limit: 'all' })
|
||||
const [diskResponse, purposesResponse] = await Promise.all([
|
||||
diskService.list({ limit: 'all' }),
|
||||
diskService.getDiskPurposes(),
|
||||
])
|
||||
|
||||
disks.value = response.data.map((disk) => ({
|
||||
...disk,
|
||||
display_name: `${disk.name} - [${disk.driver}]`,
|
||||
}))
|
||||
const disks = diskResponse.data
|
||||
const backupDiskId = purposesResponse.backup_disk_id
|
||||
|
||||
selectedDisk.value =
|
||||
disks.value.find((disk) => disk.set_as_default) ?? disks.value[0] ?? null
|
||||
backupDisk.value =
|
||||
(backupDiskId ? disks.find((disk) => disk.id === Number(backupDiskId)) : null) ??
|
||||
disks.find((disk) => disk.set_as_default) ??
|
||||
disks[0] ??
|
||||
null
|
||||
// Refresh table now that we know which disk to query
|
||||
refreshTable()
|
||||
} catch (error: unknown) {
|
||||
showApiError(error)
|
||||
} finally {
|
||||
@@ -108,7 +106,7 @@ async function loadDisks(): Promise<void> {
|
||||
}
|
||||
|
||||
async function fetchData({ page }: FetchParams): Promise<FetchResult> {
|
||||
if (!selectedDisk.value) {
|
||||
if (!backupDisk.value) {
|
||||
return emptyResult(page)
|
||||
}
|
||||
|
||||
@@ -116,8 +114,8 @@ async function fetchData({ page }: FetchParams): Promise<FetchResult> {
|
||||
|
||||
try {
|
||||
const response = await backupService.list({
|
||||
disk: selectedDisk.value.driver,
|
||||
file_disk_id: selectedDisk.value.id,
|
||||
disk: backupDisk.value.driver,
|
||||
file_disk_id: backupDisk.value.id,
|
||||
})
|
||||
|
||||
if (response.error) {
|
||||
@@ -142,7 +140,7 @@ async function fetchData({ page }: FetchParams): Promise<FetchResult> {
|
||||
}
|
||||
|
||||
async function removeBackup(backup: Backup): Promise<void> {
|
||||
if (!selectedDisk.value) {
|
||||
if (!backupDisk.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -162,8 +160,8 @@ async function removeBackup(backup: Backup): Promise<void> {
|
||||
|
||||
try {
|
||||
const response = await backupService.delete({
|
||||
disk: selectedDisk.value.driver,
|
||||
file_disk_id: selectedDisk.value.id,
|
||||
disk: backupDisk.value.driver,
|
||||
file_disk_id: backupDisk.value.id,
|
||||
path: backup.path,
|
||||
})
|
||||
|
||||
@@ -180,7 +178,7 @@ async function removeBackup(backup: Backup): Promise<void> {
|
||||
}
|
||||
|
||||
async function downloadBackup(backup: Backup): Promise<void> {
|
||||
if (!selectedDisk.value) {
|
||||
if (!backupDisk.value) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,8 +187,8 @@ async function downloadBackup(backup: Backup): Promise<void> {
|
||||
|
||||
try {
|
||||
const blob = await backupService.download({
|
||||
disk: selectedDisk.value.driver,
|
||||
file_disk_id: selectedDisk.value.id,
|
||||
disk: backupDisk.value.driver,
|
||||
file_disk_id: backupDisk.value.id,
|
||||
path: backup.path,
|
||||
})
|
||||
|
||||
@@ -216,13 +214,16 @@ async function downloadBackup(backup: Backup): Promise<void> {
|
||||
}
|
||||
|
||||
function openCreateBackupModal(): void {
|
||||
if (!backupDisk.value) {
|
||||
return
|
||||
}
|
||||
|
||||
modalStore.openModal({
|
||||
title: t('settings.backup.create_backup'),
|
||||
componentName: 'AdminBackupModal',
|
||||
size: 'sm',
|
||||
data: {
|
||||
disks: disks.value,
|
||||
selectedDiskId: selectedDisk.value?.id ?? null,
|
||||
file_disk_id: backupDisk.value.id,
|
||||
},
|
||||
refreshData: table.value?.refresh,
|
||||
})
|
||||
@@ -271,26 +272,6 @@ function showApiError(error: unknown): void {
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<div class="grid my-14 md:grid-cols-3">
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.disk.select_disk')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="selectedDisk"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:options="disks"
|
||||
track-by="id"
|
||||
value-prop="id"
|
||||
label="display_name"
|
||||
:placeholder="$t('settings.disk.select_disk')"
|
||||
object
|
||||
searchable
|
||||
class="w-full"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<BaseErrorAlert
|
||||
v-if="backupError"
|
||||
class="mt-6"
|
||||
@@ -304,6 +285,10 @@ function showApiError(error: unknown): void {
|
||||
:data="fetchData"
|
||||
:columns="backupColumns"
|
||||
>
|
||||
<template #cell-disk_name>
|
||||
{{ backupDisk?.name ?? '-' }}
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<BaseDropdown>
|
||||
<template #activator>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { DiskPurposes } from '@v2/api/services/disk.service'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalStore } from '@v2/stores/modal.store'
|
||||
import { useDialogStore } from '@v2/stores/dialog.store'
|
||||
@@ -47,6 +48,79 @@ const savePdfToDisk = ref(
|
||||
(globalStore.globalSettings?.save_pdf_to_disk ?? 'NO') === 'YES'
|
||||
)
|
||||
|
||||
// Disk purpose assignments
|
||||
const allDisks = ref<Disk[]>([])
|
||||
const purposes = ref<DiskPurposes>({
|
||||
media_disk_id: null,
|
||||
pdf_disk_id: null,
|
||||
backup_disk_id: null,
|
||||
})
|
||||
const originalPurposes = ref<DiskPurposes>({
|
||||
media_disk_id: null,
|
||||
pdf_disk_id: null,
|
||||
backup_disk_id: null,
|
||||
})
|
||||
const isSavingPurposes = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [disksRes, purposesRes] = await Promise.all([
|
||||
diskService.list({ limit: 'all' as unknown as number }),
|
||||
diskService.getDiskPurposes(),
|
||||
])
|
||||
allDisks.value = disksRes.data
|
||||
const normalized = {
|
||||
media_disk_id: purposesRes.media_disk_id ? Number(purposesRes.media_disk_id) : null,
|
||||
pdf_disk_id: purposesRes.pdf_disk_id ? Number(purposesRes.pdf_disk_id) : null,
|
||||
backup_disk_id: purposesRes.backup_disk_id ? Number(purposesRes.backup_disk_id) : null,
|
||||
}
|
||||
purposes.value = { ...normalized }
|
||||
originalPurposes.value = { ...normalized }
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
})
|
||||
|
||||
function hasChangedPurposes(): boolean {
|
||||
return (
|
||||
purposes.value.media_disk_id !== originalPurposes.value.media_disk_id ||
|
||||
purposes.value.pdf_disk_id !== originalPurposes.value.pdf_disk_id ||
|
||||
purposes.value.backup_disk_id !== originalPurposes.value.backup_disk_id
|
||||
)
|
||||
}
|
||||
|
||||
async function savePurposes(): Promise<void> {
|
||||
if (hasChangedPurposes()) {
|
||||
const confirmed = await dialogStore.openDialog({
|
||||
title: t('general.are_you_sure'),
|
||||
message: t('settings.disk.change_disk_warning'),
|
||||
yesLabel: t('general.ok'),
|
||||
noLabel: t('general.cancel'),
|
||||
variant: 'danger',
|
||||
hideNoButton: false,
|
||||
size: 'lg',
|
||||
})
|
||||
|
||||
if (!confirmed) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isSavingPurposes.value = true
|
||||
try {
|
||||
await diskService.updateDiskPurposes(purposes.value)
|
||||
originalPurposes.value = { ...purposes.value }
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: t('settings.disk.purposes_saved'),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
showApiError(error)
|
||||
} finally {
|
||||
isSavingPurposes.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fileDiskColumns = computed<TableColumn[]>(() => [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -292,4 +366,68 @@ function showApiError(error: unknown): void {
|
||||
:description="$t('settings.disk.disk_setting_description')"
|
||||
/>
|
||||
</BaseSettingCard>
|
||||
|
||||
<!-- Disk Assignments -->
|
||||
<BaseSettingCard
|
||||
:title="$t('settings.disk.disk_assignments')"
|
||||
:description="$t('settings.disk.disk_assignments_description')"
|
||||
class="mt-6"
|
||||
>
|
||||
<BaseInputGrid class="mt-4">
|
||||
<BaseInputGroup :label="$t('settings.disk.media_storage')">
|
||||
<BaseMultiselect
|
||||
v-model="purposes.media_disk_id"
|
||||
:options="allDisks"
|
||||
value-prop="id"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:can-deselect="false"
|
||||
:placeholder="$t('settings.disk.select_disk')"
|
||||
/>
|
||||
<span class="text-xs text-subtle mt-1 block">
|
||||
{{ $t('settings.disk.media_storage_description') }}
|
||||
</span>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('settings.disk.pdf_storage')">
|
||||
<BaseMultiselect
|
||||
v-model="purposes.pdf_disk_id"
|
||||
:options="allDisks"
|
||||
value-prop="id"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:can-deselect="false"
|
||||
:placeholder="$t('settings.disk.select_disk')"
|
||||
/>
|
||||
<span class="text-xs text-subtle mt-1 block">
|
||||
{{ $t('settings.disk.pdf_storage_description') }}
|
||||
</span>
|
||||
</BaseInputGroup>
|
||||
|
||||
<BaseInputGroup :label="$t('settings.disk.backup_storage')">
|
||||
<BaseMultiselect
|
||||
v-model="purposes.backup_disk_id"
|
||||
:options="allDisks"
|
||||
value-prop="id"
|
||||
label="name"
|
||||
track-by="name"
|
||||
:can-deselect="false"
|
||||
:placeholder="$t('settings.disk.select_disk')"
|
||||
/>
|
||||
<span class="text-xs text-subtle mt-1 block">
|
||||
{{ $t('settings.disk.backup_storage_description') }}
|
||||
</span>
|
||||
</BaseInputGroup>
|
||||
</BaseInputGrid>
|
||||
|
||||
<BaseButton
|
||||
:loading="isSavingPurposes"
|
||||
:disabled="isSavingPurposes"
|
||||
variant="primary"
|
||||
class="mt-6"
|
||||
@click="savePurposes"
|
||||
>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
</BaseSettingCard>
|
||||
</template>
|
||||
|
||||
@@ -346,6 +346,8 @@ Route::prefix('/v1')->group(function () {
|
||||
Route::get('download-backup', [BackupsController::class, 'download']);
|
||||
|
||||
Route::get('/disk/drivers', [DiskController::class, 'getDiskDrivers']);
|
||||
Route::get('/disk/purposes', [DiskController::class, 'getDiskPurposes']);
|
||||
Route::put('/disk/purposes', [DiskController::class, 'updateDiskPurposes']);
|
||||
|
||||
// Exchange Rate
|
||||
// ----------------------------------
|
||||
|
||||
@@ -50,11 +50,7 @@ test('create backup', function () {
|
||||
|
||||
$response = getJson("/api/v1/backups?disk={$disk->driver}&&file_disk_id={$disk->id}");
|
||||
|
||||
$prefix = env('DYNAMIC_DISK_PREFIX', 'temp_');
|
||||
|
||||
$response->assertStatus(200)->assertJson([
|
||||
'disks' => [
|
||||
$prefix.$disk->driver,
|
||||
],
|
||||
$response->assertStatus(200)->assertJsonStructure([
|
||||
'backups',
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user