Skip to content

Commit 41c109f

Browse files
authored
fix: 1.3.0 bug reviews — scheduled restore, paths, UI consistency (#362)
- Centralize destination-database-name validation on DatabaseType so Firebird/SQLite paths validate consistently across the Restore modal, Scheduled Restore modal, and the API. - Short-circuit SQLite in DatabaseProvider::listDatabasesForServer() so the autocomplete suggests configured full paths instead of basenames. - Require source_database_name on scheduled restores (UI, API, DB NOT NULL migration) — SQLite servers expose multiple paths and Redis was already filtered out at the source-server picker. - Reorder scheduled-restore steps (Schedule → Source → Target) and share the restore summary partial between both modals. - Let demo users manage scheduled restores; hide Redis from the source picker since Redis can't be restored. - Share a job-status indicator component and tighten badge styling. - Use a uniform "—" placeholder in the restore summary instead of type-specific "(enter name)". - Drop the duplicate @error block in _destination-autocomplete (Mary's x-input already renders it). - Docs: README and self-hosting quick start mention scheduled restores and align env vars; add DatabaseType per-type rule tests.
1 parent d66bd23 commit 41c109f

37 files changed

Lines changed: 384 additions & 294 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
- **Automated backups** - Schedule recurring backups on daily or weekly intervals. Flexible retention policies: simple time-based (days) or GFS (grandfather-father-son)
4343
- **Multiple compression options** - gzip, zstd (20-40% better compression), or encrypted (AES-256 for sensitive data)
4444
- **Cross-server restore** - Restore snapshots from production to staging, or between any compatible servers
45+
- **Scheduled restores** - Refresh a target database on a recurring schedule (e.g. nightly prod → staging) by replaying the latest completed snapshot
4546
- **Built-in data browser** - Open Adminer in-app to inspect MySQL, PostgreSQL, and SQLite servers (admin-enabled, role-gated)
4647
- **Flexible storage** - Store backups locally, on S3-compatible storage (AWS S3, MinIO, etc.), or remote servers via SFTP/FTP
4748
- **Real-time monitoring** - Track backup and restore progress with detailed job logs

app/Enums/DatabaseType.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,46 @@ public function createPdo(DatabaseServer $server, ?string $database = null, int
149149
return new \PDO($dsn, $server->username, $server->getDecryptedPassword(), $options);
150150
}
151151

152+
/**
153+
* Validation rules for a destination database name/path of this type.
154+
*
155+
* SQLite accepts any non-empty path. Firebird is a path too but with a
156+
* restricted character set. All other types use the conservative
157+
* identifier charset (letters, numbers, underscores).
158+
*
159+
* @return array<int, string>
160+
*/
161+
public function databaseNameRules(): array
162+
{
163+
return match ($this) {
164+
self::SQLITE => ['required', 'string', 'max:255'],
165+
self::FIREBIRD => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9_\/\\\\.\-: ]+$/'],
166+
default => ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_]+$/'],
167+
};
168+
}
169+
170+
/**
171+
* Validation messages for {@see databaseNameRules()}, keyed by `{field}.{rule}`.
172+
*
173+
* @return array<string, string>
174+
*/
175+
public function databaseNameMessages(string $field): array
176+
{
177+
return match ($this) {
178+
self::SQLITE => [
179+
"{$field}.required" => __('Please enter a database path.'),
180+
],
181+
self::FIREBIRD => [
182+
"{$field}.required" => __('Please enter a database name or path.'),
183+
"{$field}.regex" => __('Database name can only contain letters, numbers, spaces, slashes, dots, dashes, colons, and underscores.'),
184+
],
185+
default => [
186+
"{$field}.required" => __('Please enter a database name.'),
187+
"{$field}.regex" => __('Database name can only contain letters, numbers, and underscores.'),
188+
],
189+
};
190+
}
191+
152192
/**
153193
* Get the file extension used for database dumps.
154194
*

app/Http/Requests/Api/V1/RestoreRequest.php

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace App\Http\Requests\Api\V1;
44

5-
use App\Enums\DatabaseType;
65
use App\Models\DatabaseServer;
76
use Illuminate\Contracts\Validation\ValidationRule;
87
use Illuminate\Foundation\Http\FormRequest;
@@ -26,15 +25,10 @@ public function rules(): array
2625
{
2726
/** @var DatabaseServer $server */
2827
$server = $this->route('database_server');
29-
$isSqlite = $server->database_type === DatabaseType::SQLITE;
30-
31-
$schemaRules = $isSqlite
32-
? ['required', 'string', 'max:255']
33-
: ['required', 'string', 'max:64', 'regex:/^[a-zA-Z0-9_]+$/'];
3428

3529
return [
3630
'snapshot_id' => ['required', 'string', 'exists:snapshots,id'],
37-
'schema_name' => $schemaRules,
31+
'schema_name' => $server->database_type->databaseNameRules(),
3832
];
3933
}
4034

@@ -43,8 +37,9 @@ public function rules(): array
4337
*/
4438
public function messages(): array
4539
{
46-
return [
47-
'schema_name.regex' => 'Database name can only contain letters, numbers, and underscores.',
48-
];
40+
/** @var DatabaseServer $server */
41+
$server = $this->route('database_server');
42+
43+
return $server->database_type->databaseNameMessages('schema_name');
4944
}
5045
}

app/Http/Requests/Api/V1/SaveScheduledRestoreRequest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public function rules(): array
1616
return [
1717
'name' => ['required', 'string', 'max:100'],
1818
'source_server_id' => ['required', 'string', 'exists:database_servers,id'],
19-
'source_database_name' => ['nullable', 'string', 'max:255'],
19+
'source_database_name' => ['required', 'string', 'max:255'],
2020
'target_server_id' => ['required', 'string', 'exists:database_servers,id'],
2121
'schema_name' => ['required', 'string', 'max:255'],
2222
'backup_schedule_id' => ['required', 'string', 'exists:backup_schedules,id'],

app/Livewire/Forms/DatabaseServerForm.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,7 +735,7 @@ public function getScheduleOptions(): array
735735
return $this->getBackupSchedules()
736736
->map(fn (BackupSchedule $schedule) => [
737737
'id' => $schedule->id,
738-
'name' => $schedule->name.''.$schedule->expression.' ('.\App\Support\Formatters::cronTranslation($schedule->expression).')',
738+
'name' => $schedule->displayLabel(),
739739
])
740740
->toArray();
741741
}

app/Livewire/Restore/Modal.php

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -239,26 +239,12 @@ public function previousStep(): void
239239
*/
240240
protected function validateSchemaName(): void
241241
{
242-
$type = $this->targetServer?->database_type;
243-
244-
if ($type === DatabaseType::SQLITE) {
245-
$rules = ['schemaName' => 'required|string|max:255'];
246-
$messages = ['schemaName.required' => __('Please enter a database path.')];
247-
} elseif ($type === DatabaseType::FIREBIRD) {
248-
$rules = ['schemaName' => 'required|string|max:255|regex:/^[a-zA-Z0-9_\/\\\\.\-: ]+$/'];
249-
$messages = [
250-
'schemaName.required' => __('Please enter a database name or path.'),
251-
'schemaName.regex' => __('Database name can only contain letters, numbers, spaces, slashes, dots, dashes, colons, and underscores.'),
252-
];
253-
} else {
254-
$rules = ['schemaName' => 'required|string|max:64|regex:/^[a-zA-Z0-9_]+$/'];
255-
$messages = [
256-
'schemaName.required' => __('Please enter a database name.'),
257-
'schemaName.regex' => __('Database name can only contain letters, numbers, and underscores.'),
258-
];
259-
}
242+
$type = $this->targetServer->database_type;
260243

261-
$this->validate($rules, $messages);
244+
$this->validate(
245+
['schemaName' => $type->databaseNameRules()],
246+
$type->databaseNameMessages('schemaName'),
247+
);
262248
}
263249

264250
public function restore(BackupJobFactory $backupJobFactory): void

app/Livewire/ScheduledRestore/Index.php

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -115,19 +115,6 @@ public function openEdit(string $id): void
115115
$this->dispatch('open-scheduled-restore-modal', id: $id);
116116
}
117117

118-
public function toggleEnabled(string $id): void
119-
{
120-
$scheduledRestore = ScheduledRestore::findOrFail($id);
121-
122-
$this->authorize('update', $scheduledRestore);
123-
124-
$scheduledRestore->update(['enabled' => ! $scheduledRestore->enabled]);
125-
126-
$this->success($scheduledRestore->enabled
127-
? __('Scheduled restore enabled.')
128-
: __('Scheduled restore disabled.'));
129-
}
130-
131118
public function runNow(string $id): void
132119
{
133120
$scheduledRestore = ScheduledRestore::findOrFail($id);

app/Livewire/ScheduledRestore/Modal.php

Lines changed: 19 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,14 @@ public function updatedTargetServerId(): void
8282
public function nextStep(): void
8383
{
8484
if ($this->currentStep === 1) {
85-
$this->validateSourceStep();
85+
$this->validateScheduleStep();
8686
$this->currentStep = 2;
8787

8888
return;
8989
}
9090

9191
if ($this->currentStep === 2) {
92-
$this->validateTargetStep();
92+
$this->validateSourceStep();
9393
$this->currentStep = 3;
9494
}
9595
}
@@ -103,14 +103,14 @@ public function previousStep(): void
103103

104104
public function save(): void
105105
{
106+
$this->validateScheduleStep();
106107
$this->validateSourceStep();
107108
$this->validateTargetStep();
108-
$this->validateScheduleStep();
109109

110110
$payload = [
111111
'name' => $this->name,
112112
'source_server_id' => $this->sourceServerId,
113-
'source_database_name' => $this->sourceDatabaseName ?: null,
113+
'source_database_name' => $this->sourceDatabaseName,
114114
'target_server_id' => $this->targetServerId,
115115
'schema_name' => $this->schemaName,
116116
'backup_schedule_id' => $this->backupScheduleId,
@@ -138,19 +138,10 @@ public function save(): void
138138

139139
protected function validateSourceStep(): void
140140
{
141-
$rules = [
141+
$this->validate([
142142
'sourceServerId' => 'required|exists:database_servers,id',
143-
];
144-
145-
$sourceServer = $this->sourceServerId
146-
? DatabaseServer::find($this->sourceServerId)
147-
: null;
148-
149-
if ($sourceServer && $this->sourceServerRequiresDatabaseName($sourceServer)) {
150-
$rules['sourceDatabaseName'] = 'required|string|max:255';
151-
}
152-
153-
$this->validate($rules, [
143+
'sourceDatabaseName' => 'required|string|max:255',
144+
], [
154145
'sourceServerId.required' => __('Please select a source server.'),
155146
'sourceDatabaseName.required' => __('Please select the source database.'),
156147
]);
@@ -167,22 +158,11 @@ protected function validateTargetStep(): void
167158
]);
168159

169160
$target = DatabaseServer::findOrFail($this->targetServerId);
170-
$isSqlite = $target->database_type === DatabaseType::SQLITE;
171161

172-
if ($isSqlite) {
173-
$this->validate(
174-
['schemaName' => 'required|string|max:255'],
175-
['schemaName.required' => __('Please enter a database path.')]
176-
);
177-
} else {
178-
$this->validate(
179-
['schemaName' => 'required|string|max:64|regex:/^[a-zA-Z0-9_]+$/'],
180-
[
181-
'schemaName.required' => __('Please enter a database name.'),
182-
'schemaName.regex' => __('Database name can only contain letters, numbers, and underscores.'),
183-
]
184-
);
185-
}
162+
$this->validate(
163+
['schemaName' => $target->database_type->databaseNameRules()],
164+
$target->database_type->databaseNameMessages('schemaName'),
165+
);
186166

187167
if ($target->isAppDatabase($this->schemaName)) {
188168
$this->addError('schemaName', __('Cannot restore over the application database.'));
@@ -208,18 +188,14 @@ protected function validateScheduleStep(): void
208188
]);
209189
}
210190

211-
protected function sourceServerRequiresDatabaseName(DatabaseServer $server): bool
212-
{
213-
return ! in_array($server->database_type, [DatabaseType::REDIS, DatabaseType::SQLITE], true);
214-
}
215-
216191
/**
217192
* @return array<int, array{id: string, name: string}>
218193
*/
219194
public function getSourceServerOptionsProperty(): array
220195
{
221196
return DatabaseServer::query()
222197
->whereHas('snapshots', fn ($q) => $q->whereHas('job', fn ($jq) => $jq->whereRaw('status = ?', ['completed'])))
198+
->where('database_type', '!=', DatabaseType::REDIS->value)
223199
->orderBy('name')
224200
->get(['id', 'name'])
225201
->map(fn (DatabaseServer $s) => ['id' => $s->id, 'name' => $s->name])
@@ -277,19 +253,18 @@ public function getBackupScheduleOptionsProperty(): array
277253
return BackupSchedule::query()
278254
->orderBy('name')
279255
->get(['id', 'name', 'expression'])
280-
->map(fn (BackupSchedule $s) => ['id' => $s->id, 'name' => "{$s->name} ({$s->expression})"])
256+
->map(fn (BackupSchedule $s) => ['id' => $s->id, 'name' => $s->displayLabel()])
281257
->toArray();
282258
}
283259

284-
public function getSourceServerRequiresDatabaseProperty(): bool
260+
public function getSourceServerProperty(): ?DatabaseServer
285261
{
286-
if (! $this->sourceServerId) {
287-
return true;
288-
}
289-
290-
$source = DatabaseServer::find($this->sourceServerId);
262+
return $this->sourceServerId ? DatabaseServer::find($this->sourceServerId) : null;
263+
}
291264

292-
return $source ? $this->sourceServerRequiresDatabaseName($source) : true;
265+
public function getSelectedScheduleProperty(): ?BackupSchedule
266+
{
267+
return $this->backupScheduleId ? BackupSchedule::find($this->backupScheduleId) : null;
293268
}
294269

295270
public function getTargetServerProperty(): ?DatabaseServer

app/Models/BackupSchedule.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Models;
44

5+
use App\Support\Formatters;
56
use Database\Factories\BackupScheduleFactory;
67
use Illuminate\Database\Eloquent\Concerns\HasUlids;
78
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -21,6 +22,24 @@ class BackupSchedule extends Model
2122
'expression',
2223
];
2324

25+
/**
26+
* Human label combining the schedule name, the raw cron expression, and a
27+
* natural-language translation of it. Used wherever a schedule is shown
28+
* inline (select options, summaries) so the formatting stays consistent.
29+
*/
30+
public function displayLabel(): string
31+
{
32+
return $this->name.''.$this->expression.' ('.$this->cronTranslation().')';
33+
}
34+
35+
/**
36+
* Natural-language translation of the cron expression (e.g. "At 02:00").
37+
*/
38+
public function cronTranslation(): string
39+
{
40+
return Formatters::cronTranslation($this->expression);
41+
}
42+
2443
/**
2544
* @return HasMany<Backup, BackupSchedule>
2645
*/

app/Policies/RestorePolicy.php

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,38 +27,38 @@ public function view(User $user, Restore|ScheduledRestore $restore): bool
2727
}
2828

2929
/**
30-
* Determine whether the user can start a new restore (from a context
31-
* where the target server is not yet known).
32-
* Demo users can trigger restores. Final authorization on the target
33-
* server is still checked via DatabaseServerPolicy@restore.
30+
* Determine whether the user can start a new restore or schedule one.
31+
* Demo users can create both. Final authorization on the target server
32+
* is still checked via DatabaseServerPolicy@restore.
3433
*/
3534
public function create(User $user): bool
3635
{
3736
return $user->isDemo() || $user->canPerformActions();
3837
}
3938

4039
/**
41-
* Determine whether the user can update the model.
40+
* Determine whether the user can update the scheduled restore.
41+
* One-shot Restore records are not editable, so update only applies
42+
* to ScheduledRestore.
4243
*/
43-
public function update(User $user, Restore|ScheduledRestore $restore): bool
44+
public function update(User $user, ScheduledRestore $restore): bool
4445
{
45-
return $user->canPerformActions();
46+
return $user->isDemo() || $user->canPerformActions();
4647
}
4748

4849
/**
49-
* Determine whether the user can delete a restore record.
50-
* Viewers and demo users cannot delete.
50+
* Determine whether the user can delete the model.
5151
*/
5252
public function delete(User $user, Restore|ScheduledRestore $restore): bool
5353
{
54-
return $user->canPerformActions();
54+
return $user->isDemo() || $user->canPerformActions();
5555
}
5656

5757
/**
5858
* Determine whether the user can manually run the scheduled restore now.
5959
*/
6060
public function run(User $user, ScheduledRestore $restore): bool
6161
{
62-
return $user->canPerformActions();
62+
return $user->isDemo() || $user->canPerformActions();
6363
}
6464
}

0 commit comments

Comments
 (0)