diff --git a/app/Actions/Database/PgBackrestRestore.php b/app/Actions/Database/PgBackrestRestore.php index 05a78340c..e14aea97f 100644 --- a/app/Actions/Database/PgBackrestRestore.php +++ b/app/Actions/Database/PgBackrestRestore.php @@ -18,18 +18,8 @@ class PgBackrestRestore ?ScheduledDatabaseBackupExecution $execution = null, ?string $targetTime = null ): DatabaseRestore { - $attempts = 0; - do { - $uuid = (string) new Cuid2; - $exists = DatabaseRestore::where('uuid', $uuid)->exists(); - $attempts++; - if ($attempts >= 3 && $exists) { - throw new \Exception('Unable to generate unique UUID for restore after 3 attempts'); - } - } while ($exists); - $restore = DatabaseRestore::create([ - 'uuid' => $uuid, + 'uuid' => (string) new Cuid2, 'database_id' => $database->id, 'database_type' => $database->getMorphClass(), 'engine' => 'pgbackrest', diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index e2a3f4df8..1b441499f 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -338,7 +338,11 @@ class StartPostgresql $content = $this->database->postgres_conf ?? ''; if (! str($content)->contains('listen_addresses')) { - $content .= "\nlisten_addresses = '*'"; + if ($this->database->is_public) { + $content .= "\nlisten_addresses = '*'"; + } else { + $content .= "\nlisten_addresses = 'localhost'"; + } } if ($this->hasPgBackrest) { @@ -350,11 +354,9 @@ class StartPostgresql $content .= "\narchive_mode = on"; } - if (str($content)->contains('archive_command')) { - $content = preg_replace("/archive_command\s*=\s*'[^']*'/", "archive_command = '{$archiveCommand}'", $content); - } else { - $content .= "\narchive_command = '{$archiveCommand}'"; - } + $content = preg_replace('/^\s*archive_command\s*=.*$/m', '', $content); + $content = preg_replace('/\n{3,}/', "\n\n", $content); + $content .= "\narchive_command = '{$archiveCommand}'"; if (! str($content)->contains('wal_level')) { $content .= "\nwal_level = replica"; diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index 64be5ea47..da007c581 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -47,6 +47,51 @@ class DatabasesController extends Controller return serializeApiResponse($database); } + /** + * Allowed fields for backup configuration API requests. + */ + private function getBackupConfigFields(): array + { + return [ + 'save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', + 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', + 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', + 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', + 's3_storage_uuid', + 'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level', + 'pgbackrest_log_level', 'pgbackrest_archive_mode', + ]; + } + + /** + * Build backup configuration data from request input. + * Converts s3_storage_uuid to s3_storage_id and filters to allowed fields. + * + * @return array{data: array, error: \Illuminate\Http\JsonResponse|null} + */ + private function buildBackupConfig(Request $request, bool $requireSaveS3 = false): array + { + $backupData = $request->only($this->getBackupConfigFields()); + + if (isset($backupData['s3_storage_uuid'])) { + $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); + if ($s3Storage) { + $backupData['s3_storage_id'] = $s3Storage->id; + } elseif ($requireSaveS3 || $request->boolean('save_s3')) { + return [ + 'data' => [], + 'error' => response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], + ], 422), + ]; + } + unset($backupData['s3_storage_uuid']); + } + + return ['data' => $backupData, 'error' => null]; + } + #[OA\Get( summary: 'List', description: 'List all databases.', @@ -685,22 +730,13 @@ class DatabasesController extends Controller )] public function create_backup(Request $request) { - $backupConfigFields = [ - 'save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', - 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', - 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', - 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', - 's3_storage_uuid', - 'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level', - 'pgbackrest_log_level', 'pgbackrest_archive_mode', - ]; + $backupConfigFields = $this->getBackupConfigFields(); $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - // Validate incoming request is valid JSON $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; @@ -796,21 +832,11 @@ class DatabasesController extends Controller ], 422); } - $backupData = $request->only($backupConfigFields); - - // Convert s3_storage_uuid to s3_storage_id - if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); - if ($s3Storage) { - $backupData['s3_storage_id'] = $s3Storage->id; - } elseif ($request->boolean('save_s3')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - unset($backupData['s3_storage_uuid']); + $result = $this->buildBackupConfig($request); + if ($result['error']) { + return $result['error']; } + $backupData = $result['data']; // Set default databases_to_backup based on database type if not provided if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) { @@ -933,25 +959,18 @@ class DatabasesController extends Controller )] public function update_backup(Request $request) { - $backupConfigFields = [ - 'save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', - 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', - 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', - 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', - 's3_storage_uuid', - 'engine', 'pgbackrest_backup_type', 'pgbackrest_compress_type', 'pgbackrest_compress_level', - 'pgbackrest_log_level', 'pgbackrest_archive_mode', - ]; + $backupConfigFields = $this->getBackupConfigFields(); $teamId = getTeamIdFromToken(); if (is_null($teamId)) { return invalidTokenResponse(); } - // this check if the request is a valid json + $return = validateIncomingRequest($request); if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } + $validator = customApiValidator($request->all(), [ 'save_s3' => 'boolean', 'backup_now' => 'boolean|nullable', @@ -973,6 +992,7 @@ class DatabasesController extends Controller 'pgbackrest_log_level' => 'string|in:off,error,warn,info,detail,debug,trace|nullable', 'pgbackrest_archive_mode' => 'string|in:standard,reduced,minimal|nullable', ]); + if ($validator->fails()) { return response()->json([ 'message' => 'Validation failed.', @@ -984,7 +1004,6 @@ class DatabasesController extends Controller return response()->json(['message' => 'UUID is required.'], 404); } - // Validate scheduled_backup_uuid is provided if (! $request->scheduled_backup_uuid) { return response()->json(['message' => 'Scheduled backup UUID is required.'], 400); } @@ -1012,6 +1031,7 @@ class DatabasesController extends Controller 'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']], ], 422); } + if ($request->filled('s3_storage_uuid')) { $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists(); if (! $existsInTeam) { @@ -1042,21 +1062,11 @@ class DatabasesController extends Controller ], 422); } - $backupData = $request->only($backupConfigFields); - - // Convert s3_storage_uuid to s3_storage_id - if (isset($backupData['s3_storage_uuid'])) { - $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first(); - if ($s3Storage) { - $backupData['s3_storage_id'] = $s3Storage->id; - } elseif ($request->boolean('save_s3')) { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']], - ], 422); - } - unset($backupData['s3_storage_uuid']); + $result = $this->buildBackupConfig($request); + if ($result['error']) { + return $result['error']; } + $backupData = $result['data']; $backupConfig->update($backupData); @@ -2883,6 +2893,18 @@ class DatabasesController extends Controller return response()->json(['message' => 'UUID is required.'], 400); } + $validator = customApiValidator($request->all(), [ + 'execution_uuid' => 'string|uuid|nullable', + 'target_time' => 'date|nullable', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + $database = queryDatabaseByUuidWithinTeam($uuid, $teamId); if (! $database) { return response()->json(['message' => 'Database not found.'], 404); @@ -2899,7 +2921,7 @@ class DatabasesController extends Controller } $execution = null; - if ($request->has('execution_uuid')) { + if ($request->filled('execution_uuid')) { $execution = ScheduledDatabaseBackupExecution::where('uuid', $request->execution_uuid) ->whereHas('scheduledDatabaseBackup', function ($query) use ($database) { $query->where('database_id', $database->id) @@ -2914,7 +2936,10 @@ class DatabasesController extends Controller } } - $targetTime = $request->input('target_time'); + $targetTime = null; + if ($request->filled('target_time')) { + $targetTime = \Carbon\Carbon::parse($request->input('target_time'))->toIso8601String(); + } $restore = PgBackrestRestore::run($database, $execution, $targetTime); diff --git a/app/Jobs/PgBackrestRestoreJob.php b/app/Jobs/PgBackrestRestoreJob.php index e2906a510..c6ce39b01 100644 --- a/app/Jobs/PgBackrestRestoreJob.php +++ b/app/Jobs/PgBackrestRestoreJob.php @@ -67,9 +67,10 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue $this->restore->appendLog('Starting PostgreSQL after restore.'); StartPostgresql::run($this->database); - // Restore succeeded, safely remove the backup - $this->restore->appendLog('Removing backup of previous database.'); - $this->removePgDataBackup($backupPath); + if ($backupPath) { + $this->restore->appendLog('Removing backup of previous database.'); + $this->removePgDataBackup($backupPath); + } $this->restore->updateStatus('success', 'PgBackRest restore completed successfully.'); @@ -82,9 +83,10 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue )); } } catch (Throwable $restoreError) { - // Restore failed, attempt to recover $this->restore->appendLog('Restore failed, attempting to recover from backup: '.$restoreError->getMessage()); - $this->recoverFromBackup($backupPath); + if ($backupPath) { + $this->recoverFromBackup($backupPath); + } throw $restoreError; } } catch (Throwable $e) { @@ -237,7 +239,7 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue return $info; } - private function backupCurrentPgData(int $timestamp): string + private function backupCurrentPgData(int $timestamp): ?string { $server = $this->database->destination->server; $pgdataVolume = $this->database->pgdataVolume(); @@ -247,10 +249,27 @@ class PgBackrestRestoreJob implements ShouldBeEncrypted, ShouldQueue } $mount = $pgdataVolume->host_path ?: $pgdataVolume->name; + + $checkCmd = "docker run --rm -v {$mount}:/data alpine sh -c 'test -n \"$(ls -A /data 2>/dev/null)\" && echo OK || echo EMPTY'"; + $checkResult = instant_remote_process([$checkCmd], $server, false, false, 30, disableMultiplexing: true); + + if (trim($checkResult) === 'EMPTY') { + $this->restore->appendLog('No existing PGDATA to backup (directory is empty).'); + + return null; + } + $backupPath = "{$mount}_backup_{$timestamp}"; - $backupCmd = "docker run --rm -v {$mount}:/data -v {$mount}_backup_{$timestamp}:/backup alpine sh -c 'cp -a /data/* /backup/ 2>/dev/null || true'"; - instant_remote_process([$backupCmd], $server, false, false, 600, disableMultiplexing: true); + $backupCmd = "docker run --rm -v {$mount}:/data -v {$mount}_backup_{$timestamp}:/backup alpine sh -c 'cp -a /data/. /backup/'"; + instant_remote_process([$backupCmd], $server, true, false, 600, disableMultiplexing: true); + + $verifyCmd = "docker run --rm -v {$backupPath}:/backup alpine sh -c 'test -n \"$(ls -A /backup 2>/dev/null)\" && echo OK'"; + $result = instant_remote_process([$verifyCmd], $server, false, false, 30, disableMultiplexing: true); + + if (trim($result) !== 'OK') { + throw new \RuntimeException('PGDATA backup verification failed: backup directory is empty or inaccessible.'); + } $this->restore->appendLog("PGDATA backed up to temporary location: {$backupPath}"); diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 7b1b35417..775c09736 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -122,6 +122,19 @@ class BackupEdit extends Component #[Validate(['nullable', 'integer', 'min:1'])] public ?int $s3RepoRetentionDiff = 7; + public function getShowLocalRepoSettingsProperty(): bool + { + return ! $this->disableLocalBackup || ! $this->saveS3; + } + + /** + * Toggle between pgBackRest and native backup engines. + */ + public function togglePgbackrestEngine(): void + { + $this->engine = $this->engine === 'pgbackrest' ? 'native' : 'pgbackrest'; + } + public function mount() { try { @@ -140,6 +153,8 @@ class BackupEdit extends Component $this->backup->frequency = $this->frequency; $this->backup->engine = $this->engine; + $this->backup->timeout = $this->timeout; + if ($this->engine === 'pgbackrest') { $this->backup->save_s3 = $this->saveS3; $this->backup->disable_local_backup = $this->saveS3 && $this->disableLocalBackup; @@ -150,6 +165,7 @@ class BackupEdit extends Component $this->backup->pgbackrest_log_level = $this->pgbackrestLogLevel; $this->backup->pgbackrest_archive_mode = $this->pgbackrestArchiveMode; + $this->customValidate(); $this->backup->save(); $this->syncPgbackrestRepos(); } else { @@ -169,9 +185,9 @@ class BackupEdit extends Component $dbName = trim($db); try { validateShellSafePath($dbName, 'database name'); - } catch (\Exception $e) { + } catch (Exception $e) { $position = $index + 1; - throw new \Exception( + throw new Exception( "Database #{$position} ('{$dbName}') validation failed: ". $e->getMessage() ); @@ -181,12 +197,10 @@ class BackupEdit extends Component $this->backup->databases_to_backup = $this->databasesToBackup; $this->backup->dump_all = $this->dumpAll; + + $this->customValidate(); $this->backup->save(); } - - $this->backup->timeout = $this->timeout; - $this->customValidate(); - $this->backup->save(); } else { $this->backupEnabled = $this->backup->enabled; $this->frequency = $this->backup->frequency; @@ -245,19 +259,24 @@ class BackupEdit extends Component private function syncPgbackrestRepos(): void { $hasLocal = ! $this->disableLocalBackup; + + if ($this->saveS3 && empty($this->s3RepoStorageId)) { + if ($this->s3s->isNotEmpty()) { + $this->s3RepoStorageId = $this->s3s->first()->id; + } else { + throw new Exception('S3 storage must be selected when S3 backups are enabled.'); + } + } + $hasS3 = $this->saveS3 && ! empty($this->s3RepoStorageId); if (! $hasLocal && ! $hasS3) { - throw new \Exception( + throw new Exception( 'At least one backup repository (local or S3) must be enabled for pgBackRest. '. 'Either enable local backups or configure S3 storage and enable it.' ); } - if ($hasS3 && empty($this->s3RepoStorageId)) { - throw new \Exception('S3 storage must be selected when S3 backups are enabled.'); - } - $repoNumber = 1; if ($hasLocal) { @@ -279,10 +298,6 @@ class BackupEdit extends Component } if ($hasS3) { - if (empty($this->s3RepoStorageId)) { - throw new \Exception('S3 Storage is required when enabling S3 backups for pgBackRest.'); - } - $s3Repo = $this->backup->s3Repo(); if (! $s3Repo) { $s3Repo = new PgbackrestRepo; @@ -352,7 +367,7 @@ class BackupEdit extends Component } else { return redirect()->route('project.database.backup.index', $this->parameters); } - } catch (\Exception $e) { + } catch (Exception $e) { $this->dispatch('error', 'Failed to delete backup: '.$e->getMessage()); return handleError($e, $this); @@ -383,7 +398,7 @@ class BackupEdit extends Component $isValid = validate_cron_expression($this->backup->frequency); if (! $isValid) { - throw new \Exception('Invalid Cron / Human expression'); + throw new Exception('Invalid Cron / Human expression'); } $this->validate(); } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index ad3f8c3fe..af6148dfb 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -127,7 +127,7 @@ class BackupExecutions extends Component public function confirmRestore(int $executionId) { - $execution = ScheduledDatabaseBackupExecution::find($executionId); + $execution = $this->backup->executions()->where('id', $executionId)->first(); if (! $execution || ! $execution->canRestore()) { $this->dispatch('error', 'This backup cannot be restored.'); @@ -144,17 +144,9 @@ class BackupExecutions extends Component $this->showRestoreModal = false; } - public function startRestore(int $executionId, $password = null, $selectedActions = []) + public function startRestore(int $executionId) { - if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) { - if (! $password || ! Hash::check($password, Auth::user()->password)) { - $this->addError('password', 'The provided password is incorrect.'); - - return; - } - } - - $execution = ScheduledDatabaseBackupExecution::find($executionId); + $execution = $this->backup->executions()->where('id', $executionId)->first(); if (! $execution || ! $execution->canRestore()) { $this->dispatch('error', 'This backup cannot be restored.'); @@ -268,6 +260,7 @@ class BackupExecutions extends Component { $this->backup = $backup; $this->database = $backup->database; + $this->authorize('view', $this->database); $this->updateCurrentPage(); $this->loadExecutions(); } diff --git a/app/Models/DatabaseRestore.php b/app/Models/DatabaseRestore.php index f5945ead7..417d008a7 100644 --- a/app/Models/DatabaseRestore.php +++ b/app/Models/DatabaseRestore.php @@ -52,11 +52,14 @@ class DatabaseRestore extends BaseModel return in_array($this->status, ['success', 'failed']); } - public function appendLog(string $message): void + public function appendLog(string $message, bool $persist = true): void { $timestamp = now()->format('Y-m-d H:i:s'); $this->log = ($this->log ?? '')."[{$timestamp}] {$message}\n"; - $this->save(); + + if ($persist) { + $this->save(); + } } public function updateStatus(string $status, ?string $message = null): void @@ -65,7 +68,7 @@ class DatabaseRestore extends BaseModel if ($message !== null) { $this->message = $message; - $this->appendLog($message); + $this->appendLog($message, persist: false); } if ($this->isFinished()) { diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 28315f34f..7a6e1c46a 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -3,6 +3,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\MorphTo; @@ -43,12 +44,14 @@ class ScheduledDatabaseBackup extends BaseModel public function localRepo(): ?PgbackrestRepo { - return $this->pgbackrestRepos()->where('type', 'posix')->first(); + return $this->enabledPgbackrestRepos()->where('type', 'posix')->first() + ?? $this->pgbackrestRepos()->where('type', 'posix')->first(); } public function s3Repo(): ?PgbackrestRepo { - return $this->pgbackrestRepos()->where('type', 's3')->first(); + return $this->enabledPgbackrestRepos()->where('type', 's3')->first() + ?? $this->pgbackrestRepos()->where('type', 's3')->first(); } public function hasLocalRepo(): bool @@ -61,7 +64,7 @@ class ScheduledDatabaseBackup extends BaseModel return $this->pgbackrestRepos()->where('type', 's3')->where('enabled', true)->exists(); } - public function restores(): HasMany + public function restores(): HasManyThrough { return $this->hasManyThrough( DatabaseRestore::class, diff --git a/app/Services/Backup/PgBackrestService.php b/app/Services/Backup/PgBackrestService.php index 09708da4d..b940a2dd2 100644 --- a/app/Services/Backup/PgBackrestService.php +++ b/app/Services/Backup/PgBackrestService.php @@ -198,17 +198,20 @@ BASH; ?string $logLevel = null, ?int $repoNumber = null ): string { - $cmd = "pgbackrest --stanza={$stanza}"; + $escapedStanza = escapeshellarg($stanza); + $cmd = "pgbackrest --stanza={$escapedStanza}"; if ($logLevel) { - $cmd .= " --log-level-console={$logLevel}"; + $escapedLogLevel = escapeshellarg($logLevel); + $cmd .= " --log-level-console={$escapedLogLevel}"; } if ($repoNumber !== null) { - $cmd .= " --repo={$repoNumber}"; + $cmd .= ' --repo='.((int) $repoNumber); } - $cmd .= " --type={$type} backup"; + $escapedType = escapeshellarg($type); + $cmd .= " --type={$escapedType} backup"; return $cmd; } @@ -220,22 +223,26 @@ BASH; ?string $logLevel = null, ?int $repoNumber = null ): string { - $cmd = "pgbackrest --stanza={$stanza}"; + $escapedStanza = escapeshellarg($stanza); + $cmd = "pgbackrest --stanza={$escapedStanza}"; if ($logLevel) { - $cmd .= " --log-level-console={$logLevel}"; + $escapedLogLevel = escapeshellarg($logLevel); + $cmd .= " --log-level-console={$escapedLogLevel}"; } if ($repoNumber !== null) { - $cmd .= " --repo={$repoNumber}"; + $cmd .= ' --repo='.((int) $repoNumber); } if ($label) { - $cmd .= " --set={$label}"; + $escapedLabel = escapeshellarg($label); + $cmd .= " --set={$escapedLabel}"; } if ($targetTime) { - $cmd .= " --type=time --target=\"{$targetTime}\" --target-action=promote"; + $escapedTargetTime = escapeshellarg($targetTime); + $cmd .= " --type=time --target={$escapedTargetTime} --target-action=promote"; } $cmd .= ' restore'; @@ -245,10 +252,11 @@ BASH; public static function buildInfoCommand(string $stanza, bool $json = true, ?int $repoNumber = null): string { - $cmd = "pgbackrest --stanza={$stanza}"; + $escapedStanza = escapeshellarg($stanza); + $cmd = "pgbackrest --stanza={$escapedStanza}"; if ($repoNumber !== null) { - $cmd .= " --repo={$repoNumber}"; + $cmd .= ' --repo='.((int) $repoNumber); } if ($json) { @@ -262,17 +270,20 @@ BASH; public static function buildStanzaCreateCommand(string $stanza): string { - return "pgbackrest --stanza={$stanza} --log-level-console=info stanza-create"; + $escapedStanza = escapeshellarg($stanza); + + return "pgbackrest --stanza={$escapedStanza} --log-level-console=info stanza-create"; } public static function buildExpireCommand( string $stanza, ?int $repoNumber = null ): string { - $cmd = "pgbackrest --stanza={$stanza}"; + $escapedStanza = escapeshellarg($stanza); + $cmd = "pgbackrest --stanza={$escapedStanza}"; if ($repoNumber !== null) { - $cmd .= " --repo={$repoNumber}"; + $cmd .= ' --repo='.((int) $repoNumber); } $cmd .= ' expire'; diff --git a/database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php b/database/migrations/2025_12_09_231049_add_pgbackrest_support.php similarity index 78% rename from database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php rename to database/migrations/2025_12_09_231049_add_pgbackrest_support.php index c69f3d41d..615eeee6f 100644 --- a/database/migrations/2025_12_09_231049_add_pgbackrest_fields_to_scheduled_database_backup_executions_table.php +++ b/database/migrations/2025_12_09_231049_add_pgbackrest_support.php @@ -52,10 +52,34 @@ return new class extends Migration $table->unique(['scheduled_database_backup_id', 'repo_number']); }); + + Schema::create('database_restores', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + + $table->morphs('database'); + + $table->foreignId('scheduled_database_backup_execution_id') + ->nullable() + ->constrained('scheduled_database_backup_executions') + ->nullOnDelete(); + + $table->string('engine')->default('pgbackrest'); + $table->string('target_label')->nullable(); + $table->timestamp('target_time')->nullable(); + + $table->string('status')->default('pending'); + $table->longText('message')->nullable(); + $table->longText('log')->nullable(); + + $table->timestamps(); + $table->timestamp('finished_at')->nullable(); + }); } public function down(): void { + Schema::dropIfExists('database_restores'); Schema::dropIfExists('pgbackrest_repos'); Schema::table('scheduled_database_backup_executions', function (Blueprint $table) { diff --git a/database/migrations/2025_12_09_231050_create_database_restores_table.php b/database/migrations/2025_12_09_231050_create_database_restores_table.php deleted file mode 100644 index 43c3d1a3f..000000000 --- a/database/migrations/2025_12_09_231050_create_database_restores_table.php +++ /dev/null @@ -1,43 +0,0 @@ -id(); - $table->string('uuid')->unique(); - - // Polymorphic relationship to the database being restored - $table->morphs('database'); - - // Reference to the backup execution being restored from - $table->foreignId('scheduled_database_backup_execution_id') - ->nullable() - ->constrained('scheduled_database_backup_executions') - ->nullOnDelete(); - - // Restore engine and target - $table->string('engine')->default('pgbackrest'); - $table->string('target_label')->nullable(); - $table->timestamp('target_time')->nullable(); - - // Status tracking: pending, running, success, failed - $table->string('status')->default('pending'); - $table->longText('message')->nullable(); - $table->longText('log')->nullable(); - - $table->timestamps(); - $table->timestamp('finished_at')->nullable(); - }); - } - - public function down(): void - { - Schema::dropIfExists('database_restores'); - } -}; diff --git a/resources/views/livewire/project/database/backup-edit.blade.php b/resources/views/livewire/project/database/backup-edit.blade.php index f11e2ac94..858b2fc14 100644 --- a/resources/views/livewire/project/database/backup-edit.blade.php +++ b/resources/views/livewire/project/database/backup-edit.blade.php @@ -24,7 +24,7 @@ @endif @if ($engine !== 'pgbackrest') @@ -166,7 +166,7 @@ @endif {{-- Local Repository Settings --}} - @if (!$disableLocalBackup || !$saveS3) + @if ($this->showLocalRepoSettings)

Local Repository

diff --git a/resources/views/livewire/project/database/backup-executions.blade.php b/resources/views/livewire/project/database/backup-executions.blade.php index 0e5f1d9cd..6e110a026 100644 --- a/resources/views/livewire/project/database/backup-executions.blade.php +++ b/resources/views/livewire/project/database/backup-executions.blade.php @@ -192,20 +192,22 @@ @endif {{-- Show Restore button for successful pgBackRest backups --}} @if (data_get($execution, 'engine') === 'pgbackrest' && data_get($execution, 'pgbackrest_label')) - + @can('manage', $database) + + @endcan @endif @endif @php @@ -296,24 +298,23 @@
-
- - -
+
+
+ + +
-
- Cancel - - Restore Database - +
+ Cancel + + Restore Database + +