Compare commits

..

6 Commits

Author SHA1 Message Date
Lance Pioch
0da15d483c Add option to delete S3 backups from storage on server deletion
When deleting a server, a checkbox now appears in the confirmation modal
if unlocked backups exist, allowing admins to also remove them from
storage via DeleteBackupService. Locked backups are always preserved.
2026-02-06 10:35:07 -05:00
Lance Pioch
b1a39f1724 Handle X-Forwarded-Proto in .htaccess for SSL-terminating proxies (#2171) 2026-02-06 09:54:24 -05:00
Lance Pioch
6a548c09a0 Clarify OAuth error when provider account has no linked email (#2179) 2026-02-06 07:50:37 -05:00
Lance Pioch
55bda569cc Implement flexible caching for node statuses (#2174) 2026-02-06 07:50:20 -05:00
Lance Pioch
adf1249086 Fix Egg Feature modals not working (#2175) 2026-02-06 07:49:56 -05:00
Lance Pioch
dbf77bf146 Implement single file move to support Unix mv semantics (#1984) (#2176) 2026-02-06 07:49:40 -05:00
15 changed files with 64 additions and 165 deletions

View File

@@ -58,9 +58,6 @@ enum SubuserPermission: string
case SettingsDescription = 'settings.description';
case SettingsReinstall = 'settings.reinstall';
case MountRead = 'mount.read';
case MountUpdate = 'mount.update';
/** @return string[] */
public function split(): array
{
@@ -87,7 +84,6 @@ enum SubuserPermission: string
'schedule' => TablerIcon::Clock,
'settings' => TablerIcon::Settings,
'activity' => TablerIcon::Stack,
'mount' => TablerIcon::LayersLinked,
default => null,
};
}

View File

@@ -143,24 +143,6 @@ class MountResource extends Resource
])
->inline()
->default(false),
ToggleButtons::make('user_mountable')
->label(trans('admin/mount.user_mountable'))
->helperText(trans('admin/mount.user_mountable_help'))
->stateCast(new BooleanStateCast(false, true))
->options([
false => trans('admin/mount.toggles.not_user_mountable'),
true => trans('admin/mount.toggles.user_mountable'),
])
->icons([
false => TablerIcon::Users,
true => TablerIcon::Users,
])
->colors([
false => 'warning',
true => 'success',
])
->inline()
->default(true),
TextInput::make('source')
->label(trans('admin/mount.source'))
->required()

View File

@@ -42,6 +42,7 @@ class CreateMount extends CreateRecord
protected function handleRecordCreation(array $data): Model
{
$data['uuid'] ??= Str::uuid()->toString();
$data['user_mountable'] = 1;
return parent::handleRecordCreation($data);
}

View File

@@ -30,6 +30,7 @@ use App\Traits\Filament\CanCustomizeTabs;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
@@ -1106,9 +1107,13 @@ class EditServer extends EditRecord
->modalHeading(trans('filament-actions::delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
->modalSubmitActionLabel(trans('filament-actions::delete.single.label'))
->requiresConfirmation()
->action(function (Server $server, ServerDeletionService $service) {
->form(fn (Server $server) => $server->backups()->where('is_locked', false)->exists() ? [
Checkbox::make('delete_backups')
->label(trans('admin/server.delete_backups', ['count' => $server->backups()->where('is_locked', false)->count()])),
] : [])
->action(function (array $data, Server $server, ServerDeletionService $service) {
try {
$service->handle($server);
$service->withDeleteBackups($data['delete_backups'] ?? false)->handle($server);
return redirect(ListServers::getUrl(panel: 'admin'));
} catch (ConnectionException) {
@@ -1132,9 +1137,13 @@ class EditServer extends EditRecord
->modalHeading(trans('filament-actions::force-delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
->modalSubmitActionLabel(trans('filament-actions::force-delete.single.label'))
->requiresConfirmation()
->action(function (Server $server, ServerDeletionService $service) {
->form(fn (Server $server) => $server->backups()->where('is_locked', false)->exists() ? [
Checkbox::make('delete_backups')
->label(trans('admin/server.delete_backups', ['count' => $server->backups()->where('is_locked', false)->count()])),
] : [])
->action(function (array $data, Server $server, ServerDeletionService $service) {
try {
$service->withForce()->handle($server);
$service->withForce()->withDeleteBackups($data['delete_backups'] ?? false)->handle($server);
return redirect(ListServers::getUrl(panel: 'admin'));
} catch (ConnectionException) {

View File

@@ -78,11 +78,15 @@ class Console extends Page
$feature = data_get($data, 'key');
$feature = $this->featureService->get($feature);
if (!$feature || $this->getMountedAction()) {
if (!$feature) {
return;
}
$this->mountAction($feature->getId());
sleep(2); // TODO find a better way
if ($this->getMountedAction()) {
$this->replaceMountedAction($feature->getId());
} else {
$this->mountAction($feature->getId());
}
}
public function getWidgetData(): array

View File

@@ -1,114 +0,0 @@
<?php
namespace App\Filament\Server\Pages;
use App\Enums\SubuserPermission;
use App\Enums\TablerIcon;
use App\Facades\Activity;
use App\Models\Mount;
use App\Models\Server;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class Mounts extends ServerFormPage
{
protected static string|BackedEnum|null $navigationIcon = TablerIcon::LayersLinked;
protected static ?int $navigationSort = 11;
public static function canAccess(): bool
{
return parent::canAccess() && user()?->can(SubuserPermission::MountRead, Filament::getTenant());
}
protected function authorizeAccess(): void
{
abort_unless(user()?->can(SubuserPermission::MountRead, Filament::getTenant()), 403);
}
protected function fillForm(): void
{
$this->form->fill([
'mounts' => $this->getRecord()->mounts->pluck('id')->toArray(),
]);
}
public function form(Schema $schema): Schema
{
/** @var Server $server */
$server = $this->getRecord();
$allowedMounts = Mount::query()
->where('user_mountable', true)
->where(function ($query) use ($server) {
$query->whereDoesntHave('nodes')
->orWhereHas('nodes', fn ($q) => $q->where('nodes.id', $server->node_id));
})
->where(function ($query) use ($server) {
$query->whereDoesntHave('eggs')
->orWhereHas('eggs', fn ($q) => $q->where('eggs.id', $server->egg_id));
})
->get();
return parent::form($schema)
->components([
Section::make(trans('server/mount.description'))
->schema([
CheckboxList::make('mounts')
->hiddenLabel()
->relationship('mounts')
->options(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => $mount->name]))
->descriptions(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => "$mount->source -> $mount->target"]))
->helperText(fn () => $allowedMounts->isEmpty() ? trans('server/mount.no_mounts') : null)
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::MountUpdate, $server))
->bulkToggleable()
->live()
->afterStateUpdated(function ($state) {
$this->save();
})
->columnSpanFull(),
]),
]);
}
public function save(): void
{
/** @var Server $server */
$server = $this->getRecord();
abort_unless(user()?->can(SubuserPermission::MountUpdate, $server), 403);
try {
$this->form->getState();
$this->form->saveRelationships();
Activity::event('server:mount.update')
->log();
Notification::make()
->title(trans('server/mount.notification_updated'))
->success()
->send();
} catch (\Exception $exception) {
Notification::make()
->title(trans('server/mount.notification_failed'))
->body($exception->getMessage())
->danger()
->send();
}
}
public function getTitle(): string
{
return trans('server/mount.title');
}
public static function getNavigationLabel(): string
{
return trans('server/mount.title');
}
}

View File

@@ -209,16 +209,18 @@ class ListFiles extends ListRecords
->required()
->live(),
TextEntry::make('new_location')
->state(fn (Get $get, File $file) => resolve_path(join_paths($this->path, $get('location') ?? '/', $file->name))),
->state(fn (Get $get, File $file) => resolve_path(join_paths($this->path, str_ends_with($get('location') ?? '/', '/') ? join_paths($get('location') ?? '/', $file->name) : $get('location') ?? '/'))),
])
->action(function ($data, File $file) {
$location = $data['location'];
$files = [['to' => join_paths($location, $file->name), 'from' => $file->name]];
$endsWithSlash = str_ends_with($location, '/');
$to = $endsWithSlash ? join_paths($location, $file->name) : $location;
$files = [['to' => $to, 'from' => $file->name]];
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
$oldLocation = join_paths($this->path, $file->name);
$newLocation = resolve_path(join_paths($this->path, $location, $file->name));
$newLocation = resolve_path(join_paths($this->path, $to));
Activity::event('server:file.rename')
->property('directory', $this->path)

View File

@@ -74,7 +74,7 @@ class OAuthController extends Controller
$email = $oauthUser->getEmail();
if (!$email) {
return $this->errorRedirect();
return $this->errorRedirect('No email was linked to your account on the OAuth provider.');
}
$user = User::whereEmail($email)->first();

View File

@@ -358,7 +358,7 @@ class Node extends Model implements Validatable
'disk_used' => 0,
];
return cache()->remember("nodes.$this->id.statistics", now()->addSeconds(360), function () use ($default) {
return cache()->flexible("nodes.$this->id.statistics", [5, 30], function () use ($default) {
try {
$data = Http::daemon($this)

View File

@@ -5,6 +5,7 @@ namespace App\Services\Servers;
use App\Exceptions\DisplayException;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Services\Backups\DeleteBackupService;
use App\Services\Databases\DatabaseManagementService;
use Exception;
use Illuminate\Database\ConnectionInterface;
@@ -16,13 +17,16 @@ class ServerDeletionService
{
protected bool $force = false;
protected bool $deleteBackups = false;
/**
* ServerDeletionService constructor.
*/
public function __construct(
private ConnectionInterface $connection,
private DaemonServerRepository $daemonServerRepository,
private DatabaseManagementService $databaseManagementService
private DatabaseManagementService $databaseManagementService,
private DeleteBackupService $deleteBackupService
) {}
/**
@@ -35,6 +39,16 @@ class ServerDeletionService
return $this;
}
/**
* Set if unlocked backups should be deleted from storage when the server is deleted.
*/
public function withDeleteBackups(bool $bool = true): self
{
$this->deleteBackups = $bool;
return $this;
}
/**
* Delete a server from the panel and remove any associated databases from hosts.
*
@@ -78,6 +92,22 @@ class ServerDeletionService
}
}
if ($this->deleteBackups) {
foreach ($server->backups()->where('is_locked', false)->get() as $backup) {
try {
$this->deleteBackupService->handle($backup);
} catch (Exception $exception) {
if (!$this->force) {
throw $exception;
}
$backup->delete();
logger()->warning($exception);
}
}
}
$server->allocations()->update([
'server_id' => null,
'notes' => null,

View File

@@ -17,13 +17,9 @@ return [
'no_mounts' => 'No Mounts',
'eggs' => 'Eggs',
'nodes' => 'Nodes',
'user_mountable' => 'User Mountable?',
'user_mountable_help' => 'Allow users to toggle this mount on or off for their servers.',
'toggles' => [
'writable' => 'Writable',
'read_only' => 'Read Only',
'user_mountable' => 'User Mountable',
'not_user_mountable' => 'Admin Only',
],
'table' => [
'name' => 'Name',

View File

@@ -98,6 +98,7 @@ return [
'delete_db' => 'Are you sure you want to delete :name ?',
'delete_db_heading' => 'Delete Database?',
'backups' => 'Backups',
'delete_backups' => 'Also delete :count backup(s) from storage',
'egg' => 'Egg',
'mounts' => 'Mounts',
'no_mounts' => 'No Mounts exist for this Node',

View File

@@ -1,9 +0,0 @@
<?php
return [
'title' => 'Mounts',
'description' => 'Manage the mounts attached to your server.',
'no_mounts' => 'There are no user-mountable mounts available for this server.',
'notification_updated' => 'Mounts updated successfully.',
'notification_failed' => 'Failed to update mounts.',
];

View File

@@ -69,8 +69,5 @@ return [
'backup_delete' => 'Allows a user to remove backups from the system.',
'backup_download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.',
'backup_restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.',
'mount_desc' => 'Permissions that control a user\'s ability to manage mounts for this server.',
'mount_read' => 'Allows a user to view the mounts page and see available mounts.',
'mount_update' => 'Allows a user to toggle mounts on or off for the server.',
],
];

View File

@@ -5,6 +5,10 @@
RewriteEngine On
# Handle X-Forwarded-Proto Header
RewriteCond %{HTTP:X-Forwarded-Proto} =https [NC]
RewriteRule .* - [E=HTTPS:on]
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]