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 @@