Compare commits

..

1 Commits

Author SHA1 Message Date
Lance Pioch
60324225a7 docker: enable opcache, production php.ini, and tuned FPM pool
The image previously ran with no opcache (the official php images do not
enable it), no php.ini (defaults), and the stock FPM pool capped at
pm.max_children = 5. Every request recompiled the full framework and the
panel could serve at most five concurrent PHP requests.

- install and enable opcache in the base image, with timestamp validation
  kept on so runtime plugin installs still take effect
- use php.ini-production as the baseline and raise memory_limit to 256M
- tune the www pool (max_children 15, max_requests 500)
- cache Blade views at container start; config/route/event caches are
  deliberately skipped because settings live in .env and plugins register
  providers/routes at runtime
2026-07-15 20:32:42 -04:00
27 changed files with 78 additions and 595 deletions

View File

@@ -84,6 +84,9 @@ RUN mkdir -p /pelican-data/storage /var/run/supervisord \
&& chown -R www-data: /pelican-data .env ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/ /var/www/html/composer.json /var/www/html/composer.lock
# Configure PHP and PHP-FPM
COPY docker/php/pelican.ini /usr/local/etc/php/conf.d/zz-pelican.ini
COPY docker/php/pelican-pool.conf /usr/local/etc/php-fpm.d/zz-pelican.conf
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile

View File

@@ -5,6 +5,9 @@ FROM --platform=$TARGETOS/$TARGETARCH php:8.5-fpm-alpine
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip pcntl pdo_mysql pdo_pgsql bz2
RUN install-php-extensions bcmath gd intl zip opcache pcntl pdo_mysql pdo_pgsql bz2
RUN rm /usr/local/bin/install-php-extensions
# Use the production php.ini shipped with the image as the baseline
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

View File

@@ -5,10 +5,13 @@ FROM --platform=$TARGETOS/$TARGETARCH php:8.5-fpm-alpine AS base
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip pcntl pdo_mysql pdo_pgsql bz2
RUN install-php-extensions bcmath gd intl zip opcache pcntl pdo_mysql pdo_pgsql bz2
RUN rm /usr/local/bin/install-php-extensions
# Use the production php.ini shipped with the image as the baseline
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# ================================
# Stage 1-1: Composer Install
# ================================
@@ -90,6 +93,9 @@ RUN mkdir -p /pelican-data/storage /var/run/supervisord \
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/ /var/www/html/composer.json /var/www/html/composer.lock
# Configure PHP and PHP-FPM
COPY docker/php/pelican.ini /usr/local/etc/php/conf.d/zz-pelican.ini
COPY docker/php/pelican-pool.conf /usr/local/etc/php-fpm.d/zz-pelican.conf
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile

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

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

@@ -66,6 +66,12 @@ fi
echo "Optimizing Filament"
php artisan filament:optimize
# Note: config/route/event caches are intentionally NOT built here. Settings
# live in .env and are edited at runtime, and plugins register providers (and
# routes) at runtime - those caches would freeze them until the next restart.
echo "Caching Blade views"
php artisan view:cache
# default to caddy not starting
export SUPERVISORD_CADDY=false
export CADDY_APP_URL="${APP_URL}"

View File

@@ -0,0 +1,12 @@
; Pelican overrides for the default www pool.
; The image default of pm.max_children = 5 caps the panel at five concurrent
; PHP requests. Sizing assumes roughly 64-128MB per worker; lower
; pm.max_children on very small hosts.
[www]
pm = dynamic
pm.max_children = 15
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
; recycle workers periodically to guard against memory leaks
pm.max_requests = 500

14
docker/php/pelican.ini Normal file
View File

@@ -0,0 +1,14 @@
; Pelican overrides, layered on top of php.ini-production
; Filament recommends at least 256M
memory_limit = 256M
[opcache]
opcache.enable = 1
opcache.memory_consumption = 192
opcache.interned_strings_buffer = 32
opcache.max_accelerated_files = 30000
; keep timestamp validation enabled so runtime plugin installs pick up
; new and changed files without a container restart
opcache.validate_timestamps = 1
opcache.revalidate_freq = 2

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]);
});