Compare commits

..

1 Commits

Author SHA1 Message Date
Lance Pioch
aa8acc7695 Fix backup_hosts migration failing on populated databases
The migration added a NOT NULL backup_host_id column and its foreign
key to backups in a single ALTER while backup_hosts was still empty,
so existing rows defaulted to 0 and violated the constraint. Create
the default backup host first, add the column nullable, backfill,
then tighten to NOT NULL and add the foreign key.

Each DDL step is now guarded so installs left in a partially-applied
state (MySQL DDL is not transactional) can simply re-run migrations;
the backfill also repairs rows stuck at 0. Also drop backup_host_node
before backup_hosts in down(), since its foreign key blocks the drop.

Fixes #2447
2026-07-18 12:30:25 -04:00
22 changed files with 117 additions and 643 deletions

View File

@@ -1,31 +0,0 @@
<?php
namespace App\Enums;
enum ServerUserSettingKey: string
{
case ManualBackupNotifications = 'manual_backup_notifications';
case ScheduledBackupNotifications = 'scheduled_backup_notifications';
/**
* The default value for users without an explicit setting.
*/
public function getDefaultValue(): bool
{
return match ($this) {
self::ManualBackupNotifications, self::ScheduledBackupNotifications => false,
};
}
/** @return array<string, bool> */
public static function getDefaultSettings(): array
{
$default = [];
foreach (self::cases() as $key) {
$default[$key->value] = $key->getDefaultValue();
}
return $default;
}
}

View File

@@ -17,7 +17,7 @@ final class CreateBackupSchema extends TaskSchema
public function runTask(Task $task): void
{
$this->initiateService->setIsScheduled(true)->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
$this->initiateService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
}
public function canCreate(Schedule $schedule): bool

View File

@@ -689,6 +689,16 @@ class Settings extends Page implements HasSchemas
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REINSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_REINSTALL_NOTIFICATION', config('panel.email.send_reinstall_notification'))),
Toggle::make('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION')
->label(trans('admin/setting.misc.mail_notifications.backup_completed'))
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', config('panel.email.send_backup_completed_notification'))),
]),
Section::make(trans('admin/setting.misc.connections.title'))
->description(trans('admin/setting.misc.connections.helper'))

View File

@@ -2,7 +2,6 @@
namespace App\Filament\Server\Pages;
use App\Enums\ServerUserSettingKey;
use App\Enums\SubuserPermission;
use App\Enums\TablerIcon;
use App\Facades\Activity;
@@ -15,7 +14,6 @@ use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Fieldset;
@@ -256,44 +254,9 @@ class Settings extends ServerFormPage
TextEntry::make('files_info')
->label(trans('server/setting.reinstall.body2')),
]),
Section::make(trans('server/setting.notifications.title'))
->columnSpanFull()
->schema([
Toggle::make(ServerUserSettingKey::ManualBackupNotifications->value)
->label(trans('server/setting.notifications.backup_manual.label'))
->helperText(trans('server/setting.notifications.backup_manual.helper'))
->live()
->afterStateUpdated(fn ($state, Server $server) => $this->updateNotificationSetting(ServerUserSettingKey::ManualBackupNotifications, (bool) $state, $server)),
Toggle::make(ServerUserSettingKey::ScheduledBackupNotifications->value)
->label(trans('server/setting.notifications.backup_scheduled.label'))
->helperText(trans('server/setting.notifications.backup_scheduled.helper'))
->live()
->afterStateUpdated(fn ($state, Server $server) => $this->updateNotificationSetting(ServerUserSettingKey::ScheduledBackupNotifications, (bool) $state, $server)),
]),
]);
}
protected function fillForm(): void
{
$data = $this->getRecord()->attributesToArray();
foreach (ServerUserSettingKey::cases() as $key) {
$data[$key->value] = (bool) user()?->getServerSetting($this->getRecord(), $key);
}
$this->form->fill($data);
}
public function updateNotificationSetting(ServerUserSettingKey $key, bool $state, Server $server): void
{
user()?->updateServerSetting($server, $key, $state);
Notification::make()
->title(trans('server/setting.notifications.saved'))
->success()
->send();
}
public function updateName(string $name, Server $server): void
{
abort_unless(user()?->can(SubuserPermission::SettingsRename, $server), 403);

View File

@@ -2,16 +2,9 @@
namespace App\Filament\Server\Resources\Backups\Pages;
use App\Enums\ServerUserSettingKey;
use App\Enums\TablerIcon;
use App\Filament\Server\Resources\Backups\BackupResource;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
class ListBackups extends ListRecords
@@ -21,39 +14,6 @@ class ListBackups extends ListRecords
protected static string $resource = BackupResource::class;
/** @return Action[] */
protected function getDefaultHeaderActions(): array
{
/** @var Server $server */
$server = Filament::getTenant();
return [
Action::make('notification_settings')
->label(trans('server/backup.notifications.action'))
->icon(TablerIcon::Bell)
->schema([
Toggle::make(ServerUserSettingKey::ManualBackupNotifications->value)
->label(trans('server/backup.notifications.toggle_manual'))
->helperText(trans('server/backup.notifications.helper_manual'))
->default(fn () => (bool) user()?->getServerSetting($server, ServerUserSettingKey::ManualBackupNotifications)),
Toggle::make(ServerUserSettingKey::ScheduledBackupNotifications->value)
->label(trans('server/backup.notifications.toggle_scheduled'))
->helperText(trans('server/backup.notifications.helper_scheduled'))
->default(fn () => (bool) user()?->getServerSetting($server, ServerUserSettingKey::ScheduledBackupNotifications)),
])
->action(function (array $data) use ($server) {
foreach (ServerUserSettingKey::cases() as $key) {
user()?->updateServerSetting($server, $key, (bool) $data[$key->value]);
}
Notification::make()
->title(trans('server/backup.notifications.saved'))
->success()
->send();
}),
];
}
public function getBreadcrumbs(): array
{
return [];

View File

@@ -100,10 +100,6 @@ class DispatchWebhooks
{
$eventName = $activityLogged->model->event;
if ($eventName === null) {
return;
}
if (!$activityLogged->isServerEvent()) {
return;
}
@@ -181,10 +177,6 @@ class DispatchWebhooks
$eventName = $activityLogged->model->event;
$activityLoggedClass = ActivityLogged::class;
if ($eventName === null) {
return;
}
$matchingHooks = collect();
if ($this->eventIsWatched($eventName)) {
@@ -218,12 +210,8 @@ class DispatchWebhooks
}
}
protected function eventIsWatched(?string $eventName): bool
protected function eventIsWatched(string $eventName): bool
{
if ($eventName === null) {
return false;
}
$watchedEvents = cache()->rememberForever('watchedWebhooks', function () {
return WebhookConfiguration::where('scope', WebhookScope::Global)
->pluck('events')

View File

@@ -2,11 +2,8 @@
namespace App\Listeners\Server;
use App\Enums\ServerUserSettingKey;
use App\Events\Server\BackupCompleted;
use App\Filament\Server\Resources\Backups\Pages\ListBackups;
use App\Models\ServerUserSettings;
use App\Models\User;
use App\Notifications\BackupCompleted as BackupCompletedNotification;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
@@ -15,47 +12,26 @@ class BackupCompletedListener
{
public function handle(BackupCompleted $event): void
{
$event->backup->loadMissing(['server.user', 'server.subusers.user']);
$event->backup->loadMissing('server');
$event->backup->server->loadMissing('user');
$server = $event->backup->server;
$locale = $event->backup->server->user->language ?? 'en';
$candidates = collect([$server->user])
->concat($server->subusers->map->user)
->filter()
->unique('id');
Notification::make()
->status($event->backup->is_successful ? 'success' : 'danger')
->title(trans('notifications.backup_' . ($event->backup->is_successful ? 'completed' : 'failed'), locale: $locale))
->body(trans('notifications.backup_body', ['name' => $event->backup->name, 'server' => $event->backup->server->name], $locale))
->actions([
Action::make('exclude_view')
->button()
->label(trans('notifications.view_backups', locale: $locale))
->markAsRead()
->url(fn () => ListBackups::getUrl(panel: 'server', tenant: $event->backup->server)),
])
->sendToDatabase($event->backup->server->user);
$settings = ServerUserSettings::query()
->where('server_id', $server->id)
->whereIn('user_id', $candidates->pluck('id'))
->get()
->keyBy('user_id');
// Users only receive notifications if they opted in for this kind of backup.
$key = $event->backup->is_scheduled ? ServerUserSettingKey::ScheduledBackupNotifications : ServerUserSettingKey::ManualBackupNotifications;
$recipients = $candidates->filter(function (User $user) use ($settings, $key) {
$userSettings = $settings->get($user->id)->settings ?? [];
return (bool) ($userSettings[$key->value] ?? $key->getDefaultValue());
});
foreach ($recipients as $user) {
$locale = $user->language ?? 'en';
Notification::make()
->status($event->backup->is_successful ? 'success' : 'danger')
->title(trans('notifications.backup_' . ($event->backup->is_successful ? 'completed' : 'failed'), locale: $locale))
->body(trans('notifications.backup_body', ['name' => $event->backup->name, 'server' => $server->name], $locale))
->actions([
Action::make('exclude_view')
->button()
->label(trans('notifications.view_backups', locale: $locale))
->markAsRead()
->url(fn () => ListBackups::getUrl(panel: 'server', tenant: $server)),
])
->sendToDatabase($user);
$user->notify(new BackupCompletedNotification($event->backup));
if (config()->get('panel.email.send_backup_completed_notification', true)) {
$event->backup->server->user->notify(new BackupCompletedNotification($event->backup));
}
}
}

View File

@@ -31,7 +31,6 @@ use Illuminate\Database\Query\Builder;
* @property bool $is_successful
* @property string|null $upload_id
* @property bool $is_locked
* @property bool $is_scheduled
* @property-read Server $server
* @property-read BackupStatus $status
*
@@ -70,7 +69,6 @@ class Backup extends Model implements Validatable
protected $attributes = [
'is_successful' => false,
'is_locked' => false,
'is_scheduled' => false,
'checksum' => null,
'bytes' => 0,
'upload_id' => null,
@@ -84,7 +82,6 @@ class Backup extends Model implements Validatable
'uuid' => ['required', 'uuid'],
'is_successful' => ['boolean'],
'is_locked' => ['boolean'],
'is_scheduled' => ['boolean'],
'name' => ['required', 'string'],
'ignored_files' => ['array'],
'backup_host_id' => ['required', 'integer', 'exists:backup_hosts,id'],
@@ -99,7 +96,6 @@ class Backup extends Model implements Validatable
'id' => 'int',
'is_successful' => 'bool',
'is_locked' => 'bool',
'is_scheduled' => 'bool',
'ignored_files' => 'array',
'bytes' => 'int',
'completed_at' => 'immutable_datetime',

View File

@@ -90,8 +90,6 @@ use Psr\Http\Message\ResponseInterface;
* @property-read int|null $server_variables_count
* @property-read Collection<int, Subuser> $subusers
* @property-read int|null $subusers_count
* @property-read Collection<int, ServerUserSettings> $userSettings
* @property-read int|null $user_settings_count
* @property-read ServerTransfer|null $transfer
* @property-read User $user
* @property-read Collection<int, EggVariable> $variables
@@ -273,16 +271,6 @@ class Server extends Model implements HasAvatar, Validatable
return $this->hasMany(Subuser::class, 'server_id', 'id');
}
/**
* Gets the per-user settings associated with a server.
*
* @return HasMany<ServerUserSettings, $this>
*/
public function userSettings(): HasMany
{
return $this->hasMany(ServerUserSettings::class);
}
/**
* Gets the default allocation for a server.
*/

View File

@@ -1,84 +0,0 @@
<?php
namespace App\Models;
use App\Contracts\Validatable;
use App\Traits\HasValidation;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property int $server_id
* @property array<string, string|int|bool>|null $settings
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read Server $server
* @property-read User $user
*
* @method static \Database\Factories\ServerUserSettingsFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereServerId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereSettings($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerUserSettings whereUserId($value)
*/
class ServerUserSettings extends Model implements Validatable
{
use HasFactory;
use HasValidation;
/**
* The resource name for this model when it is transformed into an
* API representation using fractal.
*/
public const RESOURCE_NAME = 'server_user_settings';
protected $table = 'server_user_settings';
/**
* Fields that are not mass assignable.
*/
protected $guarded = ['id', 'created_at', 'updated_at'];
/** @var array<array-key, string[]> */
public static array $validationRules = [
'user_id' => ['required', 'numeric', 'exists:users,id'],
'server_id' => ['required', 'numeric', 'exists:servers,id'],
'settings' => ['nullable', 'array'],
'settings.manual_backup_notifications' => ['boolean'],
'settings.scheduled_backup_notifications' => ['boolean'],
];
protected function casts(): array
{
return [
'user_id' => 'int',
'server_id' => 'int',
'settings' => 'array',
];
}
/**
* Gets the server these settings are associated with.
*/
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
/**
* Gets the user these settings are associated with.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Enums\CustomizationKey;
use App\Enums\ServerUserSettingKey;
use App\Enums\SubuserPermission;
use App\Events\User\Deleting;
use App\Exceptions\DisplayException;
@@ -87,8 +86,6 @@ use Spatie\Permission\Traits\HasRoles;
* @property-read int|null $sub_servers_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, Subuser> $subusers
* @property-read int|null $subusers_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, ServerUserSettings> $serverSettings
* @property-read int|null $server_settings_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, ApiKey> $tokens
* @property-read int|null $tokens_count
*
@@ -354,30 +351,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return $this->belongsToMany(Server::class, 'subusers');
}
/** @return HasMany<ServerUserSettings, $this> */
public function serverSettings(): HasMany
{
return $this->hasMany(ServerUserSettings::class);
}
public function getServerSetting(Server $server, ServerUserSettingKey $key): string|int|bool
{
$settings = $this->serverSettings()->where('server_id', $server->id)->first()->settings ?? [];
return $settings[$key->value] ?? $key->getDefaultValue();
}
public function updateServerSetting(Server $server, ServerUserSettingKey $key, string|int|bool $value): void
{
$row = $this->serverSettings()->firstOrNew(['server_id' => $server->id]);
$settings = $row->settings ?? [];
$settings[$key->value] = $value;
$row->settings = array_intersect_key($settings, ServerUserSettingKey::getDefaultSettings());
$row->save();
}
/** @return ($key is null ? array<string, string|int|bool> : string|int|bool) */
public function getCustomization(?CustomizationKey $key = null): array|string|int|bool|null
{

View File

@@ -21,8 +21,6 @@ class InitiateBackupService
private bool $isLocked = false;
private bool $isScheduled = false;
/**
* InitiateBackupService constructor.
*/
@@ -43,16 +41,6 @@ class InitiateBackupService
return $this;
}
/**
* Set if the backup was initiated by a schedule rather than a user.
*/
public function setIsScheduled(bool $isScheduled): self
{
$this->isScheduled = $isScheduled;
return $this;
}
/**
* Sets the files to be ignored by this backup.
*
@@ -137,7 +125,6 @@ class InitiateBackupService
'ignored_files' => array_values($this->ignoredFiles ?? []),
'backup_host_id' => $backupHost->id,
'is_locked' => $this->isLocked,
'is_scheduled' => $this->isScheduled,
]);
$schema->createBackup($backup);

View File

@@ -20,7 +20,6 @@ class BackupTransformer extends BaseClientTransformer
'uuid' => $backup->uuid,
'is_successful' => $backup->is_successful,
'is_locked' => $backup->is_locked,
'is_scheduled' => $backup->is_scheduled,
'name' => $backup->name,
'ignored_files' => $backup->ignored_files,
'checksum' => $backup->checksum,

View File

@@ -55,6 +55,8 @@ return [
'send_install_notification' => env('PANEL_SEND_INSTALL_NOTIFICATION', true),
// Should an email be sent to a server owner whenever their server is reinstalled?
'send_reinstall_notification' => env('PANEL_SEND_REINSTALL_NOTIFICATION', true),
// Should an email be sent to a server owner whenever a backup is completed?
'send_backup_completed_notification' => env('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', true),
],
'filament' => [

View File

@@ -1,56 +0,0 @@
<?php
namespace Database\Factories;
use App\Enums\ServerUserSettingKey;
use App\Models\ServerUserSettings;
use Illuminate\Database\Eloquent\Factories\Factory;
class ServerUserSettingsFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = ServerUserSettings::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'settings' => [
ServerUserSettingKey::ManualBackupNotifications->value => true,
ServerUserSettingKey::ScheduledBackupNotifications->value => true,
],
];
}
/**
* Indicate the user has opted in to all backup notifications.
*/
public function optedIn(): static
{
return $this->state([
'settings' => [
ServerUserSettingKey::ManualBackupNotifications->value => true,
ServerUserSettingKey::ScheduledBackupNotifications->value => true,
],
]);
}
/**
* Indicate the user has opted out of all backup notifications.
*/
public function optedOut(): static
{
return $this->state([
'settings' => [
ServerUserSettingKey::ManualBackupNotifications->value => false,
ServerUserSettingKey::ScheduledBackupNotifications->value => false,
],
]);
}
}

View File

@@ -13,57 +13,92 @@ return new class extends Migration
*/
public function up(): void
{
Schema::create('backup_hosts', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('schema');
$table->json('configuration')->nullable();
$table->timestamps();
});
Schema::create('backup_host_node', function (Blueprint $table) {
$table->unsignedInteger('node_id');
$table->foreign('node_id')->references('id')->on('nodes')->cascadeOnDelete();
$table->unsignedInteger('backup_host_id');
$table->foreign('backup_host_id')->references('id')->on('backup_hosts')->cascadeOnDelete();
$table->timestamps();
$table->unique(['node_id']);
});
Schema::table('backups', function (Blueprint $table) {
$table->unsignedInteger('backup_host_id')->after('disk');
$table->foreign('backup_host_id')->references('id')->on('backup_hosts');
$table->dropColumn('disk');
});
$oldDriver = env('APP_BACKUP_DRIVER', 'wings');
$oldConfiguration = null;
if ($oldDriver === 's3') {
$oldConfiguration = [
'region' => env('AWS_DEFAULT_REGION'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'bucket' => env('AWS_BACKUPS_BUCKET'),
'prefix' => env('AWS_BACKUPS_BUCKET', ''),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'use_accelerate_endpoint' => env('AWS_BACKUPS_USE_ACCELERATE', false),
'storage_class' => env('AWS_BACKUPS_STORAGE_CLASS'),
];
if (!Schema::hasTable('backup_hosts')) {
Schema::create('backup_hosts', function (Blueprint $table) {
$table->increments('id');
$table->string('name');
$table->string('schema');
$table->json('configuration')->nullable();
$table->timestamps();
});
}
$backupHost = BackupHost::create([
'name' => $oldDriver === 's3' ? 'Remote' : 'Local',
'schema' => $oldDriver,
'configuration' => $oldConfiguration,
]);
if (!Schema::hasTable('backup_host_node')) {
Schema::create('backup_host_node', function (Blueprint $table) {
$table->unsignedInteger('node_id');
$table->foreign('node_id')->references('id')->on('nodes')->cascadeOnDelete();
DB::table('backups')->update(['backup_host_id' => $backupHost->id]);
$table->unsignedInteger('backup_host_id');
$table->foreign('backup_host_id')->references('id')->on('backup_hosts')->cascadeOnDelete();
$table->timestamps();
$table->unique(['node_id']);
});
}
$backupHost = BackupHost::first();
if (!$backupHost) {
$oldDriver = env('APP_BACKUP_DRIVER', 'wings');
$oldConfiguration = null;
if ($oldDriver === 's3') {
$oldConfiguration = [
'region' => env('AWS_DEFAULT_REGION'),
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'bucket' => env('AWS_BACKUPS_BUCKET'),
'prefix' => env('AWS_BACKUPS_BUCKET', ''),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'use_accelerate_endpoint' => env('AWS_BACKUPS_USE_ACCELERATE', false),
'storage_class' => env('AWS_BACKUPS_STORAGE_CLASS'),
];
}
$backupHost = BackupHost::create([
'name' => $oldDriver === 's3' ? 'Remote' : 'Local',
'schema' => $oldDriver,
'configuration' => $oldConfiguration,
]);
}
// The column must start out nullable: adding it NOT NULL would give existing
// rows a value of 0, which the foreign key below would reject.
if (!Schema::hasColumn('backups', 'backup_host_id')) {
$hasDiskColumn = Schema::hasColumn('backups', 'disk');
Schema::table('backups', function (Blueprint $table) use ($hasDiskColumn) {
$column = $table->unsignedInteger('backup_host_id')->nullable();
// The disk column may already be gone on installs that were repaired by hand.
if ($hasDiskColumn) {
$column->after('disk');
}
});
}
DB::table('backups')
->whereNull('backup_host_id')
->orWhere('backup_host_id', 0)
->update(['backup_host_id' => $backupHost->id]);
Schema::table('backups', function (Blueprint $table) {
$table->unsignedInteger('backup_host_id')->nullable(false)->change();
});
$foreignKeys = array_column(Schema::getForeignKeys('backups'), 'name');
if (!in_array('backups_backup_host_id_foreign', $foreignKeys)) {
Schema::table('backups', function (Blueprint $table) {
$table->foreign('backup_host_id')->references('id')->on('backup_hosts');
});
}
if (Schema::hasColumn('backups', 'disk')) {
Schema::table('backups', function (Blueprint $table) {
$table->dropColumn('disk');
});
}
}
/**
@@ -78,8 +113,8 @@ return new class extends Migration
$table->dropColumn('backup_host_id');
});
Schema::dropIfExists('backup_hosts');
Schema::dropIfExists('backup_host_node');
Schema::dropIfExists('backup_hosts');
}
};

View File

@@ -1,48 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('server_user_settings', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('user_id');
$table->unsignedInteger('server_id');
$table->json('settings')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
$table->foreign('server_id')->references('id')->on('servers')->cascadeOnDelete();
$table->unique(['user_id', 'server_id']);
});
// Backup notifications now default off for everyone. Owners on existing
// installs previously always received them, so seed an opt-in row for each
// server's owner unless the panel-wide email switch was already disabled.
if (!filter_var(env('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', true), FILTER_VALIDATE_BOOL)) {
return;
}
$now = now();
DB::table('servers')->select(['id', 'owner_id'])->orderBy('id')->chunkById(250, function ($servers) use ($now) {
DB::table('server_user_settings')->insert($servers->map(fn ($server) => [
'user_id' => $server->owner_id,
'server_id' => $server->id,
'settings' => json_encode(['manual_backup_notifications' => true, 'scheduled_backup_notifications' => true]),
'created_at' => $now,
'updated_at' => $now,
])->all());
});
}
public function down(): void
{
Schema::dropIfExists('server_user_settings');
}
};

View File

@@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('backups', function (Blueprint $table) {
$table->boolean('is_scheduled')->default(false)->after('is_locked');
});
}
public function down(): void
{
Schema::table('backups', function (Blueprint $table) {
$table->dropColumn('is_scheduled');
});
}
};

View File

@@ -121,6 +121,7 @@ return [
'removed_from_server' => 'Removed from Server',
'server_installed' => 'Server Installed',
'server_reinstalled' => 'Server Reinstalled',
'backup_completed' => 'Backup Completed',
],
'connections' => [
'title' => 'Connections',

View File

@@ -12,14 +12,6 @@ return [
'successful' => 'Successful',
'failed' => 'Failed',
],
'notifications' => [
'action' => 'Notification Settings',
'toggle_manual' => 'Manual Backup Notifications',
'helper_manual' => 'Receive a notification and email when a manually created backup for this server completes or fails.',
'toggle_scheduled' => 'Scheduled Backup Notifications',
'helper_scheduled' => 'Receive a notification and email when a scheduled backup for this server completes or fails.',
'saved' => 'Notification Settings Updated',
],
'actions' => [
'create' => [
'title' => 'Create Backup',

View File

@@ -41,18 +41,6 @@ return [
'password_body' => 'Your SFTP password is the same as the password you use to access this panel.',
],
],
'notifications' => [
'title' => 'Notifications',
'saved' => 'Notification Settings Updated',
'backup_manual' => [
'label' => 'Manual Backup Notifications',
'helper' => 'Receive a notification and email when a manually created backup for this server completes or fails.',
],
'backup_scheduled' => [
'label' => 'Scheduled Backup Notifications',
'helper' => 'Receive a notification and email when a scheduled backup for this server completes or fails.',
],
],
'reinstall' => [
'title' => 'Reinstall Server',
'body' => 'Reinstalling your server will stop it, and then re-run the installation script that initially set it up.',

View File

@@ -1,143 +0,0 @@
<?php
use App\Enums\ServerUserSettingKey;
use App\Enums\SubuserPermission;
use App\Events\Server\BackupCompleted;
use App\Listeners\Server\BackupCompletedListener;
use App\Models\Backup;
use App\Models\BackupHost;
use App\Models\Server;
use App\Models\ServerUserSettings;
use App\Models\User;
use App\Notifications\BackupCompleted as BackupCompletedNotification;
use Filament\Notifications\DatabaseNotification;
use Illuminate\Support\Facades\Notification;
function runBackupCompletedListener(Server $server, bool $scheduled = false): void
{
/** @var Backup $backup */
$backup = Backup::factory()->create([
'server_id' => $server->id,
'backup_host_id' => BackupHost::factory()->create()->id,
'is_scheduled' => $scheduled,
]);
(new BackupCompletedListener())->handle(new BackupCompleted($backup));
}
it('does not notify anyone by default', function () {
Notification::fake();
[$subuser, $server] = generateTestAccount([SubuserPermission::WebsocketConnect]);
runBackupCompletedListener($server);
runBackupCompletedListener($server, scheduled: true);
Notification::assertNotSentTo($server->user, DatabaseNotification::class);
Notification::assertNotSentTo($server->user, BackupCompletedNotification::class);
Notification::assertNotSentTo($subuser, DatabaseNotification::class);
Notification::assertNotSentTo($subuser, BackupCompletedNotification::class);
});
it('notifies owners who opted in to manual backups only for manual backups', function () {
Notification::fake();
[$owner, $server] = generateTestAccount();
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
runBackupCompletedListener($server, scheduled: true);
Notification::assertNotSentTo($owner, DatabaseNotification::class);
Notification::assertNotSentTo($owner, BackupCompletedNotification::class);
runBackupCompletedListener($server);
Notification::assertSentTo($owner, DatabaseNotification::class);
Notification::assertSentTo($owner, BackupCompletedNotification::class);
});
it('notifies owners who opted in to scheduled backups only for scheduled backups', function () {
Notification::fake();
[$owner, $server] = generateTestAccount();
$owner->updateServerSetting($server, ServerUserSettingKey::ScheduledBackupNotifications, true);
runBackupCompletedListener($server);
Notification::assertNotSentTo($owner, DatabaseNotification::class);
Notification::assertNotSentTo($owner, BackupCompletedNotification::class);
runBackupCompletedListener($server, scheduled: true);
Notification::assertSentTo($owner, DatabaseNotification::class);
Notification::assertSentTo($owner, BackupCompletedNotification::class);
});
it('notifies subusers who opted in', function () {
Notification::fake();
[$subuser, $server] = generateTestAccount([SubuserPermission::WebsocketConnect]);
$subuser->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
runBackupCompletedListener($server);
Notification::assertSentTo($subuser, DatabaseNotification::class);
Notification::assertSentTo($subuser, BackupCompletedNotification::class);
});
it('does not notify users who opted back out', function () {
Notification::fake();
[$owner, $server] = generateTestAccount();
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, false);
runBackupCompletedListener($server);
Notification::assertNotSentTo($owner, DatabaseNotification::class);
Notification::assertNotSentTo($owner, BackupCompletedNotification::class);
});
it('defaults backup notifications off for everyone', function () {
[$owner, $server] = generateTestAccount();
/** @var User $subuser */
$subuser = User::factory()->create();
foreach (ServerUserSettingKey::cases() as $key) {
expect($owner->getServerSetting($server, $key))->toBeFalse()
->and($subuser->getServerSetting($server, $key))->toBeFalse();
}
});
it('updates a single settings row per user and server', function () {
[$owner, $server] = generateTestAccount();
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, false);
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
$owner->updateServerSetting($server, ServerUserSettingKey::ScheduledBackupNotifications, true);
expect(ServerUserSettings::query()->where('user_id', $owner->id)->where('server_id', $server->id)->count())->toBe(1)
->and($owner->getServerSetting($server, ServerUserSettingKey::ManualBackupNotifications))->toBeTrue()
->and($owner->getServerSetting($server, ServerUserSettingKey::ScheduledBackupNotifications))->toBeTrue();
});
it('drops unknown keys when updating settings', function () {
[$owner, $server] = generateTestAccount();
ServerUserSettings::factory()->create([
'user_id' => $owner->id,
'server_id' => $server->id,
'settings' => ['unknown_key' => 'value'],
]);
$owner->updateServerSetting($server, ServerUserSettingKey::ManualBackupNotifications, true);
$settings = ServerUserSettings::query()->where('user_id', $owner->id)->where('server_id', $server->id)->value('settings');
expect($settings)->toBe([ServerUserSettingKey::ManualBackupNotifications->value => true]);
});