Compare commits

..

1 Commits

Author SHA1 Message Date
Lance Pioch
380d9900e5 Add user_mountable functionality for Mounts (#2077)
Resolves #2077
2026-02-06 02:42:22 -05:00
15 changed files with 165 additions and 64 deletions

View File

@@ -58,6 +58,9 @@ 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
{
@@ -84,6 +87,7 @@ enum SubuserPermission: string
'schedule' => TablerIcon::Clock,
'settings' => TablerIcon::Settings,
'activity' => TablerIcon::Stack,
'mount' => TablerIcon::LayersLinked,
default => null,
};
}

View File

@@ -143,6 +143,24 @@ 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,7 +42,6 @@ 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,7 +30,6 @@ 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;
@@ -1107,13 +1106,9 @@ class EditServer extends EditRecord
->modalHeading(trans('filament-actions::delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
->modalSubmitActionLabel(trans('filament-actions::delete.single.label'))
->requiresConfirmation()
->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) {
->action(function (Server $server, ServerDeletionService $service) {
try {
$service->withDeleteBackups($data['delete_backups'] ?? false)->handle($server);
$service->handle($server);
return redirect(ListServers::getUrl(panel: 'admin'));
} catch (ConnectionException) {
@@ -1137,13 +1132,9 @@ 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()
->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) {
->action(function (Server $server, ServerDeletionService $service) {
try {
$service->withForce()->withDeleteBackups($data['delete_backups'] ?? false)->handle($server);
$service->withForce()->handle($server);
return redirect(ListServers::getUrl(panel: 'admin'));
} catch (ConnectionException) {

View File

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

View File

@@ -0,0 +1,114 @@
<?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,18 +209,16 @@ class ListFiles extends ListRecords
->required()
->live(),
TextEntry::make('new_location')
->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') ?? '/'))),
->state(fn (Get $get, File $file) => resolve_path(join_paths($this->path, $get('location') ?? '/', $file->name))),
])
->action(function ($data, File $file) {
$location = $data['location'];
$endsWithSlash = str_ends_with($location, '/');
$to = $endsWithSlash ? join_paths($location, $file->name) : $location;
$files = [['to' => $to, 'from' => $file->name]];
$files = [['to' => join_paths($location, $file->name), 'from' => $file->name]];
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
$oldLocation = join_paths($this->path, $file->name);
$newLocation = resolve_path(join_paths($this->path, $to));
$newLocation = resolve_path(join_paths($this->path, $location, $file->name));
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('No email was linked to your account on the OAuth provider.');
return $this->errorRedirect();
}
$user = User::whereEmail($email)->first();

View File

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

View File

@@ -5,7 +5,6 @@ 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;
@@ -17,16 +16,13 @@ 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 DeleteBackupService $deleteBackupService
private DatabaseManagementService $databaseManagementService
) {}
/**
@@ -39,16 +35,6 @@ 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.
*
@@ -92,22 +78,6 @@ 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,9 +17,13 @@ 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,7 +98,6 @@ 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',

9
lang/en/server/mount.php Normal file
View File

@@ -0,0 +1,9 @@
<?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,5 +69,8 @@ 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,10 +5,6 @@
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}]