Compare commits

...

19 Commits

Author SHA1 Message Date
Boy132
2bbfb0eef9 Fix "undefined key" error for TagsFilter (#2312) 2026-05-04 10:48:34 +02:00
Boy132
2a64ea8536 Cleanup node fqdn/ip validation (#2307) 2026-05-04 09:01:03 +02:00
Boy132
4fdbbff74b Correctly store falsy startup variable values (#2305) 2026-04-28 09:35:19 +02:00
Boy132
2ed891633c Add subuser permission for changing server icon (#2304) 2026-04-28 09:34:38 +02:00
Michael (Parker) Parker
58dcbeac0b install plugin packages on start (#2298) 2026-04-23 08:41:44 -05:00
Boy132
91c5ddb2bd Catch icon exception when importing egg (#2299) 2026-04-22 13:52:31 +02:00
Lance Pioch
562be98b20 Add user_mountable functionality for Mounts (#2077) (#2181)
Co-authored-by: Boy132 <mail@boy132.de>
2026-04-21 22:03:10 -04:00
Lance Pioch
fd3b8a7ab3 Laravel 12.56.0 Shift (#2283)
Co-authored-by: Shift <shift@laravelshift.com>
2026-04-21 22:02:48 -04:00
Boy132
1817383bf5 Add changes from upstream (#2293)
Co-authored-by: DaneEveritt <dane@daneeveritt.com>
Co-authored-by: danny6167 <danielb@purpleflaghosting.com>
Co-authored-by: MrSoulPenguin <28676680+MrSoulPenguin@users.noreply.github.com>
2026-04-20 17:25:54 +02:00
Boy132
d39a0c4464 Allow to add translation prefix to custom subuser permissions (#2265) 2026-04-20 17:24:24 +02:00
Boy132
2e48095379 Refactor egg & server icon uploading (#2281) 2026-04-20 17:23:41 +02:00
Jørgen Vatle
06c662988a Feature: Add support for user-configurable egg/nests index file. (#2287)
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2026-04-20 05:06:39 -04:00
Neil Hanlon
98d7158dfc Fix PATCH /api/application/nodes returning 500 due to null timestamps (#2286) 2026-04-20 05:06:01 -04:00
JUBE
e01d9f2cf3 Add sortable feature to name column in ListServers (#2282) 2026-04-08 19:50:01 -04:00
Boy132
b693d0e728 Do not allow all file extensions for egg image in egg importer (#2279) 2026-03-22 19:18:46 +01:00
Boy132
612041e1f8 Run composer update and update model phpdocs (#2275) 2026-03-17 09:09:01 +01:00
Boy132
64bcdb514b Add jobs for plugin install, update and uninstall (#2267) 2026-03-17 09:08:28 +01:00
Lance Pioch
c1105db702 Fix encodings with new tested helper (#2238) 2026-03-16 16:48:13 -04:00
Lance Pioch
761492d09f Laravel 12.53.0 Shift (#2266)
Co-authored-by: Shift <shift@laravelshift.com>
2026-02-26 23:56:15 -05:00
170 changed files with 3346 additions and 2335 deletions

View File

@@ -13,7 +13,7 @@ class UpdateEggIndexCommand extends Command
public function handle(): int
{
try {
$data = Http::timeout(5)->connectTimeout(1)->get('https://raw.githubusercontent.com/pelican-eggs/pelican-eggs.github.io/refs/heads/main/content/pelican.json')->throw()->json();
$data = Http::timeout(5)->connectTimeout(1)->get(config('panel.cdn.egg_index_url'))->throw()->json();
} catch (Exception $exception) {
$this->error($exception->getMessage());

View File

@@ -50,6 +50,9 @@ enum SubuserPermission: string
case ActivityRead = 'activity.read';
case MountRead = 'mount.read';
case MountUpdate = 'mount.update';
case StartupRead = 'startup.read';
case StartupUpdate = 'startup.update';
case StartupDockerImage = 'startup.docker-image';
@@ -57,6 +60,7 @@ enum SubuserPermission: string
case SettingsRename = 'settings.rename';
case SettingsDescription = 'settings.description';
case SettingsReinstall = 'settings.reinstall';
case SettingsChangeIcon = 'settings.change-icon';
/** @return string[] */
public function split(): array
@@ -84,6 +88,7 @@ enum SubuserPermission: string
'schedule' => TablerIcon::Clock,
'settings' => TablerIcon::Settings,
'activity' => TablerIcon::Stack,
'mount' => TablerIcon::LayersLinked,
default => null,
};
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Events\User;
use App\Events\Event;
use App\Models\User;
use Illuminate\Queue\SerializesModels;
class Deleting extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user) {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Events\User;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
final class PasswordChanged
{
use Dispatchable;
public function __construct(public readonly User $user) {}
}

View File

@@ -14,13 +14,14 @@ use Boquizo\FilamentLogViewer\Utils\Level;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Http;
class ListLogs extends BaseListLogs
{
protected string $view = 'filament.components.list-logs';
public function getHeading(): string|null|\Illuminate\Contracts\Support\Htmlable
public function getHeading(): string|null|Htmlable
{
return trans('admin/log.navigation.panel_logs');
}

View File

@@ -5,8 +5,10 @@ namespace App\Filament\Admin\Resources\Eggs\Pages;
use App\Enums\EditorLanguages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Eggs\EggResource;
use App\Filament\Components\Actions\DeleteIcon;
use App\Filament\Components\Actions\ExportEggAction;
use App\Filament\Components\Actions\ImportEggAction;
use App\Filament\Components\Actions\UploadIcon;
use App\Filament\Components\Forms\Fields\CopyFrom;
use App\Filament\Components\Forms\Fields\MonacoEditor;
use App\Models\Egg;
@@ -14,12 +16,10 @@ use App\Models\EggVariable;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use App\Traits\Filament\CanCustomizeTabs;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
@@ -28,11 +28,8 @@ use Filament\Forms\Components\TagsInput;
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\Resources\Pages\EditRecord;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Flex;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Image;
use Filament\Schemas\Components\Tabs;
@@ -40,10 +37,7 @@ use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Support\Enums\IconSize;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\Unique;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class EditEgg extends EditRecord
{
@@ -74,163 +68,17 @@ class EditEgg extends EditRecord
->icon(TablerIcon::Egg)
->schema([
Grid::make(2)
->columnSpan(1)
->columnStart(1)
->schema([
Image::make('', '')
->hidden(fn ($record) => !$record->image)
->url(fn ($record) => $record->image)
->alt('')
->alignJustify()
Image::make('', 'icon')
->hidden(fn ($record) => !$record->icon)
->url(fn ($record) => $record->icon)
->imageSize(150)
->columnSpanFull(),
Flex::make([
Action::make('uploadImage')
->hiddenLabel()
->tooltip(trans('admin/egg.import.import_image'))
->iconSize(IconSize::Large)
->icon(TablerIcon::PhotoUp)
->modal()
->modalHeading('')
->modalSubmitActionLabel(trans('admin/egg.import.import_image'))
->schema([
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
Hidden::make('imageUrl'),
Hidden::make('imageExtension'),
TextInput::make('image_url')
->label(trans('admin/egg.import.image_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('image_url_error', null);
$set('imageUrl', null);
$set('imageExtension', null);
return;
}
try {
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!array_key_exists($extension, Egg::IMAGE_FORMATS)) {
throw new Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Egg::IMAGE_FORMATS))]));
}
$host = parse_url($state, PHP_URL_HOST);
$ip = gethostbyname($host);
if (
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
) {
throw new Exception(trans('admin/egg.import.no_local_ip'));
}
$set('imageUrl', $state);
$set('imageExtension', $extension);
$set('image_url_error', null);
} catch (Exception $e) {
$set('image_url_error', $e->getMessage());
$set('imageUrl', null);
$set('imageExtension', null);
}
}),
TextEntry::make('image_url_error')
->hiddenLabel()
->visible(fn ($get) => $get('image_url_error') !== null)
->afterStateHydrated(fn ($set, $get) => $get('image_url_error')),
Image::make(fn (Get $get) => $get('image_url'), '')
->imageSize(150)
->visible(fn ($get) => $get('image_url') && !$get('image_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('image')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->disk('public')
->directory(Egg::ICON_STORAGE_PATH)
->acceptedFileTypes([
'image/png',
'image/jpeg',
'image/webp',
'image/svg+xml',
])
->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) {
return $record->uuid . '.' . $file->getClientOriginalExtension();
}),
]),
]),
])
->action(function (array $data, $record): void {
if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) {
$this->saveImageFromUrl($data['imageUrl'], $data['imageExtension'], $record);
Notification::make()
->title(trans('admin/egg.import.image_updated'))
->success()
->send();
return;
}
if (!empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.image_updated'))
->success()
->send();
return;
}
if (empty($data['imageUrl']) && empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.no_image'))
->warning()
->send();
}
}),
Action::make('delete_image')
->visible(fn ($record) => $record->image)
->hiddenLabel()
->tooltip(trans('admin/egg.import.delete_image'))
->icon(TablerIcon::Trash)
->iconSize(IconSize::Large)
->color('danger')
->action(function ($record) {
foreach (array_keys(Egg::IMAGE_FORMATS) as $ext) {
$path = Egg::ICON_STORAGE_PATH . "/$record->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
Notification::make()
->title(trans('admin/egg.import.image_deleted'))
->success()
->send();
$record->refresh();
}),
]),
->columnSpanFull()
->alignJustify(),
UploadIcon::make(),
DeleteIcon::make()
->iconStoragePath(Egg::getIconStoragePath()),
]),
TextInput::make('name')
->label(trans('admin/egg.name'))
@@ -469,39 +317,6 @@ class EditEgg extends EditRecord
$this->fillForm();
}
/**
* Save an image from URL download to a file.
*
* @throws Exception
*/
private function saveImageFromUrl(string $imageUrl, string $extension, Egg $egg): void
{
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$normalizedExtension = match ($extension) {
'svg+xml', 'svg' => 'svg',
'jpeg', 'jpg' => 'jpg',
'png' => 'png',
'webp' => 'webp',
default => throw new Exception(trans('admin/egg.import.unknown_extension')),
};
$data = @file_get_contents($imageUrl, false, $context, 0, 1048576); // 1024KB
if (empty($data)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data);
}
protected function getFormActions(): array
{
return [];

View File

@@ -38,6 +38,8 @@ class ListEggs extends ListRecords
*/
public function table(Table $table): Table
{
$defaultEggIcon = 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg')));
return $table
->searchable(true)
->defaultPaginationPageOption(25)
@@ -45,13 +47,11 @@ class ListEggs extends ListRecords
TextColumn::make('id')
->label('Id')
->hidden(),
ImageColumn::make('image')
ImageColumn::make('icon')
->label('')
->alignCenter()
->circular()
->getStateUsing(fn ($record) => $record->image
? $record->image
: 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg')))),
->getStateUsing(fn (Egg $record) => $record->icon ?: $defaultEggIcon),
TextColumn::make('name')
->label(trans('admin/egg.name'))
->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)

View File

@@ -95,6 +95,12 @@ class MountResource extends Resource
->icon(fn ($state) => $state ? TablerIcon::WritingOff : TablerIcon::Writing)
->color(fn ($state) => $state ? 'success' : 'warning')
->formatStateUsing(fn ($state) => $state ? trans('admin/mount.toggles.read_only') : trans('admin/mount.toggles.writable')),
TextColumn::make('user_mountable')
->label(trans('admin/mount.table.user_mountable'))
->badge()
->icon(fn ($state) => $state ? TablerIcon::User : TablerIcon::UserOff)
->color(fn ($state) => $state ? 'success' : 'warning')
->formatStateUsing(fn ($state) => $state ? trans('admin/mount.toggles.user_mountable') : trans('admin/mount.toggles.not_user_mountable')),
])
->recordActions([
ViewAction::make()
@@ -124,7 +130,8 @@ class MountResource extends Resource
->label(trans('admin/mount.name'))
->required()
->helperText(trans('admin/mount.name_help'))
->maxLength(64),
->maxLength(64)
->columnSpanFull(),
ToggleButtons::make('read_only')
->label(trans('admin/mount.read_only'))
->helperText(trans('admin/mount.read_only_help'))
@@ -143,6 +150,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::UserOff,
true => TablerIcon::User,
])
->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

@@ -64,9 +64,8 @@ class CreateNode extends CreateRecord
->icon(TablerIcon::Server)
->columnSpanFull()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'default' => 1,
'md' => 2,
'lg' => 4,
])
->schema([
@@ -76,81 +75,83 @@ class CreateNode extends CreateRecord
->autofocus()
->live(debounce: 1500)
->rules(Node::getRulesForField('fqdn'))
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null)
->validationMessages([
'prohibited' => trans('admin/node.dns_error'),
])
->prohibited(function ($state, Get $get) {
if (!$state) {
return true;
}
if (is_ip($state)) {
return false;
}
$ip = $get('ip');
return !is_ip($ip);
})
->hintColor(function ($state, Get $get) {
if (!$state) {
return null;
}
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.fqdn_help');
return 'warning';
}
} else {
$ip = $get('ip');
return '';
return is_ip($ip) ? 'success' : 'danger';
}
return trans('admin/node.error');
return null;
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return trans('admin/node.ssl_ip');
->hint(function ($state, Get $get) {
if (!$state) {
return null;
}
return '';
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.ssl_ip');
}
} else {
$ip = $get('ip');
return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid');
}
return null;
})
->afterStateUpdated(function (Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
if (!$state) {
return;
}
[$subdomain] = str($state)->explode('.', 2);
if (!is_numeric($subdomain)) {
$set('name', $subdomain);
}
if (!$state || is_ip($state)) {
$set('dns', null);
return;
}
$ip = get_ip_from_hostname($state);
if ($ip) {
$set('dns', true);
$set('ip', $ip);
} else {
$set('dns', false);
if (!is_ip($state)) {
$ip = get_ip_from_hostname($state);
if (is_ip($ip)) {
$set('ip', $ip);
} else {
$set('ip', null);
}
}
})
->maxLength(255),
TextInput::make('ip')
->disabled()
->hidden(),
ToggleButtons::make('dns')
->label(trans('admin/node.dns'))
->helperText(trans('admin/node.dns_help'))
->disabled()
->inline()
->default(null)
->hint(fn (Get $get) => $get('ip'))
->hintColor('success')
->options([
true => trans('admin/node.valid'),
false => trans('admin/node.invalid'),
])
->colors([
true => 'success',
false => 'danger',
])
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Hidden::make('ip')
->saved(false),
TextInput::make('daemon_connect')
->columnSpan(1)
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
@@ -160,7 +161,16 @@ class CreateNode extends CreateRecord
->default(8080)
->required()
->integer(),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
TextInput::make('name')
->label(trans('admin/node.display_name'))
->columnSpan([
@@ -171,27 +181,16 @@ class CreateNode extends CreateRecord
])
->required()
->maxLength(100),
Hidden::make('scheme')
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Hidden::make('behind_proxy')
->default(false),
ToggleButtons::make('connection')
->label(trans('admin/node.ssl'))
->columnSpan(1)
->columnSpan(2)
->inline()
->helperText(function (Get $get) {
->helperText(function () {
if (request()->isSecure()) {
return new HtmlString(trans('admin/node.panel_on_ssl'));
return trans('admin/node.panel_on_ssl');
}
if (is_ip($get('fqdn'))) {
return trans('admin/node.ssl_help');
}
return '';
return null;
})
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([
@@ -219,17 +218,10 @@ class CreateNode extends CreateRecord
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
$set('daemon_listen', 8080);
}),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
Hidden::make('scheme')
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Hidden::make('behind_proxy')
->default(false),
]),
Step::make('advanced')
->label(trans('admin/node.tabs.advanced_settings'))

View File

@@ -137,74 +137,83 @@ class EditNode extends EditRecord
->autofocus()
->live(debounce: 1500)
->rules(Node::getRulesForField('fqdn'))
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null)
->validationMessages([
'prohibited' => trans('admin/node.dns_error'),
])
->prohibited(function ($state, Get $get) {
if (!$state) {
return true;
}
if (is_ip($state)) {
return false;
}
$ip = $get('ip');
return !is_ip($ip);
})
->hintColor(function ($state, Get $get) {
if (!$state) {
return null;
}
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.fqdn_help');
return 'warning';
}
} else {
$ip = $get('ip');
return '';
return is_ip($ip) ? 'success' : 'danger';
}
return trans('admin/node.error');
return null;
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return trans('admin/node.ssl_ip');
->hint(function ($state, Get $get) {
if (!$state) {
return null;
}
return '';
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.ssl_ip');
}
} else {
$ip = $get('ip');
return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid');
}
return null;
})
->afterStateUpdated(function (Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
if (!$state) {
return;
}
[$subdomain] = str($state)->explode('.', 2);
if (!is_numeric($subdomain)) {
$set('name', $subdomain);
}
if (!$state || is_ip($state)) {
$set('dns', null);
return;
}
$ip = get_ip_from_hostname($state);
if ($ip) {
$set('dns', true);
$set('ip', $ip);
} else {
$set('dns', false);
if (!is_ip($state)) {
$ip = get_ip_from_hostname($state);
if (is_ip($ip)) {
$set('ip', $ip);
} else {
$set('ip', null);
}
}
})
->maxLength(255),
TextInput::make('ip')
->disabled()
->hidden(),
ToggleButtons::make('dns')
->label(trans('admin/node.dns'))
->helperText(trans('admin/node.dns_help'))
->disabled()
->inline()
->default(null)
->hint(fn (Get $get) => $get('ip'))
->hintColor('success')
->stateCast(new BooleanStateCast(false, true))
->options([
1 => trans('admin/node.valid'),
0 => trans('admin/node.invalid'),
])
->colors([
1 => 'success',
0 => 'danger',
])
->columnSpan(1),
Hidden::make('ip')
->saved(false),
TextInput::make('daemon_connect')
->columnSpan(1)
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
@@ -214,6 +223,16 @@ class EditNode extends EditRecord
->default(8080)
->required()
->integer(),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
TextInput::make('name')
->label(trans('admin/node.display_name'))
->columnSpan([
@@ -224,22 +243,16 @@ class EditNode extends EditRecord
])
->required()
->maxLength(100),
Hidden::make('scheme'),
Hidden::make('behind_proxy'),
ToggleButtons::make('connection')
->label(trans('admin/node.ssl'))
->columnSpan(1)
->columnSpan(2)
->inline()
->helperText(function (Get $get) {
->helperText(function () {
if (request()->isSecure()) {
return new HtmlString(trans('admin/node.panel_on_ssl'));
return trans('admin/node.panel_on_ssl');
}
if (is_ip($get('fqdn'))) {
return trans('admin/node.ssl_help');
}
return '';
return null;
})
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([
@@ -267,16 +280,10 @@ class EditNode extends EditRecord
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
$set('daemon_listen', 8080);
}),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
Hidden::make('scheme')
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Hidden::make('behind_proxy')
->default(false),
]),
Tab::make('advanced_settings')
->label(trans('admin/node.tabs.advanced_settings'))
@@ -735,7 +742,7 @@ class EditNode extends EditRecord
$set('pulled', false);
$set('uploaded', true);
} catch (\Exception $e) {
} catch (Exception $e) {
Notification::make()
->title(trans('admin/node.diagnostics.upload_failed'))
->body($e->getMessage())

View File

@@ -5,6 +5,9 @@ namespace App\Filament\Admin\Resources\Plugins;
use App\Enums\PluginStatus;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
use App\Jobs\Plugin\InstallPlugin;
use App\Jobs\Plugin\UninstallPlugin;
use App\Jobs\Plugin\UpdatePlugin;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use BackedEnum;
@@ -119,15 +122,14 @@ class PluginResource extends Resource
->icon(TablerIcon::Terminal)
->color('success')
->hidden(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
->action(function (Plugin $plugin) {
try {
$pluginService->installPlugin($plugin, !$plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
InstallPlugin::dispatch(user(), $plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.installed'))
->title(trans('admin/plugin.notifications.install_started'))
->body(trans('admin/plugin.notifications.background_info'))
->send();
} catch (Exception $exception) {
Notification::make()
@@ -143,15 +145,14 @@ class PluginResource extends Resource
->icon(TablerIcon::Download)
->color('success')
->visible(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled && $plugin->isUpdateAvailable())
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
->action(function (Plugin $plugin) {
try {
$pluginService->updatePlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
UpdatePlugin::dispatch(user(), $plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.updated'))
->title(trans('admin/plugin.notifications.update_started'))
->body(trans('admin/plugin.notifications.background_info'))
->send();
} catch (Exception $exception) {
Notification::make()
@@ -220,15 +221,14 @@ class PluginResource extends Resource
->color('danger')
->requiresConfirmation()
->hidden(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled || $plugin->status === PluginStatus::Errored)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
->action(function (Plugin $plugin) {
try {
$pluginService->uninstallPlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
UninstallPlugin::dispatch(user(), $plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.uninstalled'))
->title(trans('admin/plugin.notifications.uninstall_started'))
->body(trans('admin/plugin.notifications.background_info'))
->send();
} catch (Exception $exception) {
Notification::make()

View File

@@ -5,8 +5,9 @@ namespace App\Filament\Admin\Resources\Servers\Pages;
use App\Enums\SuspendAction;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Servers\ServerResource;
use App\Filament\Components\Actions\DeleteServerIcon;
use App\Filament\Components\Actions\DeleteIcon;
use App\Filament\Components\Actions\PreviewStartupAction;
use App\Filament\Components\Actions\UploadIcon;
use App\Filament\Components\Forms\Fields\MonacoEditor;
use App\Filament\Components\Forms\Fields\StartupVariable;
use App\Filament\Components\StateCasts\ServerConditionStateCast;
@@ -31,7 +32,6 @@ use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
@@ -41,7 +41,6 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Schemas\Components\Actions;
@@ -60,9 +59,7 @@ use Filament\Support\Enums\Alignment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use LogicException;
use Random\RandomException;
@@ -111,141 +108,18 @@ class EditServer extends EditRecord
->icon(TablerIcon::InfoCircle)
->schema([
Grid::make()
->columns(2)
->columnStart(1)
->schema([
Image::make('', 'icon')
->hidden(fn ($record) => !$record->icon && !$record->egg->image)
->url(fn ($record) => $record->icon ?: $record->egg->image)
->hidden(fn ($record) => !$record->icon && !$record->egg->icon)
->url(fn ($record) => $record->icon ?: $record->egg->icon)
->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip'))
->columnSpan(2)
->imageSize(150)
->columnSpanFull()
->alignJustify(),
Action::make('uploadIcon')
->hiddenLabel()
->icon(TablerIcon::PhotoUp)
->tooltip(trans('admin/server.import_image'))
->modal()
->modalSubmitActionLabel(trans('server/setting.server_info.icon.upload'))
->schema([
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
Hidden::make('imageUrl'),
Hidden::make('imageExtension'),
TextInput::make('image_url')
->label(trans('admin/egg.import.image_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('image_url_error', null);
$set('imageUrl', null);
$set('imageExtension', null);
return;
}
try {
if (!in_array(parse_url($state, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!array_key_exists($extension, Server::IMAGE_FORMATS)) {
throw new \Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Server::IMAGE_FORMATS))]));
}
$host = parse_url($state, PHP_URL_HOST);
$ip = gethostbyname($host);
if (
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
) {
throw new \Exception(trans('admin/egg.import.no_local_ip'));
}
$set('imageUrl', $state);
$set('imageExtension', $extension);
$set('image_url_error', null);
} catch (\Exception $e) {
$set('image_url_error', $e->getMessage());
$set('imageUrl', null);
$set('imageExtension', null);
}
}),
TextEntry::make('image_url_error')
->hiddenLabel()
->visible(fn (Get $get) => $get('image_url_error') !== null)
->afterStateHydrated(fn (Get $get) => $get('image_url_error')),
Image::make(fn (Get $get) => $get('image_url'), '')
->imageSize(150)
->visible(fn (Get $get) => $get('image_url') && !$get('image_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('image')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->disk('public')
->directory(Server::ICON_STORAGE_PATH)
->acceptedFileTypes([
'image/png',
'image/jpeg',
'image/webp',
'image/svg+xml',
])
->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) {
return $record->uuid . '.' . $file->getClientOriginalExtension();
}),
]),
]),
])
->action(function (array $data, $record): void {
if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) {
$this->saveIconFromUrl($data['imageUrl'], $data['imageExtension'], $record);
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
return;
}
if (!empty($data['image'])) {
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
return;
}
if (empty($data['imageUrl']) && empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.no_image'))
->warning()
->send();
}
}),
DeleteServerIcon::make(),
UploadIcon::make(),
DeleteIcon::make()
->iconStoragePath(Server::getIconStoragePath()),
]),
Grid::make()
->columns(3)
@@ -321,7 +195,7 @@ class EditServer extends EditRecord
try {
$logs = $serverRepository->setServer($server)->getInstallLogs();
return mb_convert_encoding($logs, 'UTF-8', ['UTF-8', 'UTF-16', 'ISO-8859-1', 'ASCII']);
return convert_to_utf8($logs);
} catch (ConnectionException) {
Notification::make()
->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
@@ -1202,37 +1076,4 @@ class EditServer extends EditRecord
{
return null;
}
/**
* Save an icon from URL download to a file.
*
* @throws Exception
*/
private function saveIconFromUrl(string $imageUrl, string $extension, Server $server): void
{
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$normalizedExtension = match ($extension) {
'svg+xml', 'svg' => 'svg',
'jpeg', 'jpg' => 'jpg',
'png' => 'png',
'webp' => 'webp',
default => throw new Exception(trans('admin/egg.import.unknown_extension')),
};
$data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB
if (empty($data)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data);
}
}

View File

@@ -70,7 +70,7 @@ class ListServers extends ListRecords
ImageColumn::make('icon')
->label('')
->imageSize(46)
->state(fn (Server $server) => $server->icon ?: $server->egg->image),
->state(fn (Server $server) => $server->icon ?: $server->egg->icon),
TextColumn::make('condition')
->label(trans('server/dashboard.status'))
->badge()
@@ -81,7 +81,8 @@ class ListServers extends ListRecords
->label(trans('server/dashboard.title'))
->description(fn (Server $server) => $server->description)
->grow()
->searchable(),
->searchable()
->sortable(),
TextColumn::make('allocation.address')
->label('')
->badge()

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Filament\Components\Actions;
use App\Enums\TablerIcon;
use App\Models\Traits\HasIcon;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
class DeleteIcon extends Action
{
/** @var string[] */
protected ?array $iconFormats = null;
protected ?string $iconStoragePath = null;
public static function getDefaultName(): ?string
{
return 'delete_icon';
}
protected function setUp(): void
{
parent::setUp();
$this->visible(fn ($record) => $record->icon);
$this->hiddenLabel();
$this->tooltip(trans('admin/egg.import.delete_icon'));
$this->icon(TablerIcon::Trash);
$this->color('danger');
$this->action(function ($record) {
foreach ($this->getIconFormats() as $ext) {
$path = $this->getIconStoragePath() . "/$record->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
Notification::make()
->title(trans('admin/egg.import.icon_deleted'))
->success()
->send();
$record->refresh();
});
}
/** @param string[] $iconFormats */
public function iconFormats(?array $iconFormats): static
{
$this->iconFormats = $iconFormats;
return $this;
}
public function iconStoragePath(?string $iconStoragePath): static
{
$this->iconStoragePath = $iconStoragePath;
return $this;
}
/** @return string[] */
public function getIconFormats(): array
{
return $this->iconFormats ?? array_keys(HasIcon::$iconFormats);
}
public function getIconStoragePath(): ?string
{
return $this->iconStoragePath;
}
}

View File

@@ -1,48 +0,0 @@
<?php
namespace App\Filament\Components\Actions;
use App\Enums\TablerIcon;
use App\Models\Server;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
class DeleteServerIcon extends Action
{
public static function getDefaultName(): ?string
{
return 'delete_icon';
}
protected function setUp(): void
{
parent::setUp();
$this->visible(fn ($record) => $record->icon);
$this->hiddenLabel();
$this->tooltip(trans('admin/server.import_image'));
$this->icon(TablerIcon::Trash);
$this->color('danger');
$this->action(function ($record) {
foreach (array_keys(Server::IMAGE_FORMATS) as $ext) {
$path = Server::ICON_STORAGE_PATH . "/$record->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
Notification::make()
->title(trans('server/setting.server_info.icon.deleted'))
->success()
->send();
$record->refresh();
});
}
}

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Filament\Components\Actions;
use App\Enums\TablerIcon;
use App\Models\Traits\HasIcon;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Image;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\Facades\Http;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class UploadIcon extends Action
{
/** @var string[] */
protected ?array $iconFormats = null;
public static function getDefaultName(): ?string
{
return 'upload_icon';
}
protected function setUp(): void
{
parent::setUp();
$this->hiddenLabel();
$this->tooltip(trans('admin/egg.import.import_icon'));
$this->icon(TablerIcon::PhotoUp);
$this->modal();
$this->modalHeading('');
$this->modalSubmitActionLabel(trans('admin/egg.import.import_icon'));
$this->schema([
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
TextInput::make('icon_url')
->label(trans('admin/egg.import.icon_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('icon_url_error', null);
return;
}
try {
$this->validateIconUrl($state);
$set('icon_url_error', null);
} catch (Exception $exception) {
$set('icon_url_error', $exception->getMessage());
}
}),
TextEntry::make('icon_url_error')
->hiddenLabel()
->visible(fn (Get $get) => $get('icon_url_error') !== null)
->afterStateHydrated(fn (Get $get) => $get('icon_url_error')),
Image::make(fn (Get $get) => $get('icon_url'), '')
->imageSize(150)
->visible(fn (Get $get) => $get('icon_url') && !$get('icon_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('icon')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->acceptedFileTypes(fn () => $this->getIconFormats())
->saveUploadedFileUsing(fn (TemporaryUploadedFile $file, $record) => $record->writeIcon($file->getClientOriginalExtension(), $file->getContent())),
]),
]),
]);
$this->action(function (array $data, $record) {
if (!empty($data['icon_url'])) {
$this->validateIconUrl($data['icon_url']);
$content = Http::timeout(5)->connectTimeout(1)->withoutRedirecting()->get($data['icon_url'])->body();
if (empty($content)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($data['icon_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
$record->writeIcon($extension, $content);
Notification::make()
->title(trans('admin/egg.import.icon_updated'))
->success()
->send();
} elseif (!empty($data['icon'])) {
Notification::make()
->title(trans('admin/egg.import.icon_updated'))
->success()
->send();
} else {
Notification::make()
->title(trans('admin/egg.import.no_icon'))
->warning()
->send();
}
});
}
protected function validateIconUrl(string $url): void
{
if (!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
if (!filter_var($url, FILTER_VALIDATE_URL)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$host = parse_url($url, PHP_URL_HOST);
$ip = gethostbyname($host);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new Exception(trans('admin/egg.import.no_local_ip'));
}
}
/** @param string[] $iconFormats */
public function iconFormats(?array $iconFormats): static
{
$this->iconFormats = $iconFormats;
return $this;
}
/** @return string[] */
public function getIconFormats(): array
{
return $this->iconFormats ?? array_values(HasIcon::$iconFormats);
}
}

View File

@@ -49,7 +49,7 @@ class StartupVariable extends Field
$this->hintIcon(TablerIcon::Code, fn (StartupVariable $component) => implode('|', $component->getVariableRules()));
$this->helperText(fn (StartupVariable $component) => !$component->getVariableDesc() ? '—' : $component->getVariableDesc());
$this->helperText(fn (StartupVariable $component) => $component->getVariableDesc());
$this->rules(fn (StartupVariable $component) => $component->getVariableRules());
@@ -70,7 +70,7 @@ class StartupVariable extends Field
],
StartupVariableType::Toggle => [
...parent::getDefaultStateCasts(),
new BooleanStateCast(false),
new BooleanStateCast(false, true),
],
default => parent::getDefaultStateCasts()
};

View File

@@ -21,9 +21,9 @@ class TagsFilter extends BaseFilter
{
parent::setUp();
$this->query(fn (Builder $query, array $data) => $query->when($data['tag'], fn (Builder $query, $tag) => $query->whereJsonContains('tags', $tag)));
$this->query(fn (Builder $query, array $data) => $query->when($data['tag'] ?? null, fn (Builder $query, $tag) => $query->whereJsonContains('tags', $tag)));
$this->indicateUsing(fn (array $data) => $data['tag'] ? 'Tag: ' . $data['tag'] : null);
$this->indicateUsing(fn (array $data) => ($data['tag'] ?? null) ? 'Tag: ' . $data['tag'] : null);
$this->resetState(['tag' => null]);

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 Exception;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\HtmlString;
class Mounts extends ServerFormPage
{
protected static string|BackedEnum|null $navigationIcon = TablerIcon::LayersLinked;
protected static ?int $navigationSort = 9;
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
{
$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([
CheckboxList::make('mounts')
->label(trans('server/mount.description'))
->relationship('mounts')
->options(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => $mount->name]))
->descriptions(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => new HtmlString(str("$mount->source -> $mount->target")->stripTags() . ($mount->description ? '<br>' . str($mount->description)->stripTags() : ''))]))
->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
{
abort_unless(user()?->can(SubuserPermission::MountUpdate, $this->getRecord()), 403);
try {
$this->form->getState();
$this->form->saveRelationships();
Activity::event('server:mount.update')
->log();
Notification::make()
->title(trans('server/mount.notification_updated'))
->body(trans('server/mount.notification_updated_body'))
->success()
->send();
} catch (Exception $exception) {
report($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

@@ -5,14 +5,13 @@ namespace App\Filament\Server\Pages;
use App\Enums\SubuserPermission;
use App\Enums\TablerIcon;
use App\Facades\Activity;
use App\Filament\Components\Actions\DeleteServerIcon;
use App\Filament\Components\Actions\DeleteIcon;
use App\Filament\Components\Actions\UploadIcon;
use App\Models\Server;
use App\Services\Servers\ReinstallServerService;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
@@ -21,20 +20,14 @@ use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Image;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Alignment;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class Settings extends ServerFormPage
{
protected static string|BackedEnum|null $navigationIcon = TablerIcon::Settings;
protected static ?int $navigationSort = 10;
protected static ?int $navigationSort = 11;
/**
* @throws Exception
@@ -79,140 +72,20 @@ class Settings extends ServerFormPage
->afterStateUpdated(fn ($state, Server $server) => $this->updateDescription($state ?? '', $server)),
]),
Grid::make()
->columns(2)
->columnStart(6)
->schema([
Image::make('', 'icon')
->hidden(fn ($record) => !$record->icon && !$record->egg->image)
->url(fn ($record) => $record->icon ?: $record->egg->image)
->hidden(fn ($record) => !$record->icon && !$record->egg->icon)
->url(fn ($record) => $record->icon ?: $record->egg->icon)
->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip'))
->columnSpan(2)
->imageSize(150)
->columnSpanFull()
->alignJustify(),
Action::make('uploadIcon')
->hiddenLabel()
->tooltip(trans('admin/server.import_image'))
->icon(TablerIcon::PhotoUp)
->modal()
->modalSubmitActionLabel(trans('server/setting.server_info.icon.upload'))
->schema([
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
Hidden::make('imageUrl'),
Hidden::make('imageExtension'),
TextInput::make('image_url')
->label(trans('admin/egg.import.image_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('image_url_error', null);
$set('imageUrl', null);
$set('imageExtension', null);
return;
}
try {
if (!in_array(parse_url($state, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!array_key_exists($extension, Server::IMAGE_FORMATS)) {
throw new \Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Server::IMAGE_FORMATS))]));
}
$host = parse_url($state, PHP_URL_HOST);
$ip = gethostbyname($host);
if (
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
) {
throw new \Exception(trans('admin/egg.import.no_local_ip'));
}
$set('imageUrl', $state);
$set('imageExtension', $extension);
$set('image_url_error', null);
} catch (\Exception $e) {
$set('image_url_error', $e->getMessage());
$set('imageUrl', null);
$set('imageExtension', null);
}
}),
TextEntry::make('image_url_error')
->hiddenLabel()
->visible(fn (Get $get) => $get('image_url_error') !== null)
->afterStateHydrated(fn (Get $get) => $get('image_url_error')),
Image::make(fn (Get $get) => $get('image_url'), '')
->imageSize(150)
->visible(fn (Get $get) => $get('image_url') && !$get('image_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('image')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->disk('public')
->directory(Server::ICON_STORAGE_PATH)
->acceptedFileTypes([
'image/png',
'image/jpeg',
'image/webp',
'image/svg+xml',
])
->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) {
return $record->uuid . '.' . $file->getClientOriginalExtension();
}),
]),
]),
])
->action(function (array $data, $record): void {
if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) {
$this->saveIconFromUrl($data['imageUrl'], $data['imageExtension'], $record);
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
return;
}
if (!empty($data['image'])) {
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
}
if (empty($data['imageUrl']) && empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.no_image'))
->warning()
->send();
}
}),
DeleteServerIcon::make(),
UploadIcon::make()
->authorize(fn (Server $server) => user()?->can(SubuserPermission::SettingsChangeIcon, $server)),
DeleteIcon::make()
->iconStoragePath(Server::getIconStoragePath())
->authorize(fn (Server $server) => user()?->can(SubuserPermission::SettingsChangeIcon, $server)),
]),
TextInput::make('uuid')
->label(trans('server/setting.server_info.uuid'))
@@ -446,39 +319,6 @@ class Settings extends ServerFormPage
}
}
/**
* Save an icon from URL download to a file.
*
* @throws Exception
*/
private function saveIconFromUrl(string $imageUrl, string $extension, Server $server): void
{
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$normalizedExtension = match ($extension) {
'svg+xml', 'svg' => 'svg',
'jpeg', 'jpg' => 'jpg',
'png' => 'png',
'webp' => 'webp',
default => throw new Exception(trans('admin/egg.import.unknown_extension')),
};
$data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB
if (empty($data)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data);
}
public function getTitle(): string
{
return trans('server/setting.title');

View File

@@ -28,7 +28,7 @@ class Startup extends ServerFormPage
{
protected static string|BackedEnum|null $navigationIcon = TablerIcon::PlayerPlay;
protected static ?int $navigationSort = 9;
protected static ?int $navigationSort = 10;
/**
* @throws Exception
@@ -149,12 +149,16 @@ class Startup extends ServerFormPage
return parent::canAccess() && user()?->can(SubuserPermission::StartupRead, Filament::getTenant());
}
public function update(?string $state, ServerVariable $serverVariable): void
public function update(null|string|bool $state, ServerVariable $serverVariable): void
{
if (!$serverVariable->variable->user_editable) {
return;
}
if (is_bool($state)) {
$state = $state ? '1' : '0';
}
$original = $serverVariable->variable_value;
try {

View File

@@ -139,6 +139,7 @@ class ActivityResource extends Resource
]);
}
/** @return Builder<ActivityLog> */
public static function getEloquentQuery(): Builder
{
/** @var Server $server */

View File

@@ -149,7 +149,7 @@ class EditFiles extends Page
try {
$contents = $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size'));
return mb_convert_encoding($contents, 'UTF-8', ['UTF-8', 'UTF-16', 'ISO-8859-1', 'ASCII']);
return convert_to_utf8($contents);
} catch (FileSizeTooLargeException) {
AlertBanner::make('file_too_large')
->title(trans('server/file.alerts.file_too_large.title', ['name' => basename($this->path)]))
@@ -259,9 +259,9 @@ class EditFiles extends Page
return $this->fileRepository;
}
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false, ?string $configuration = null): string
{
return parent::getUrl($parameters, $isAbsolute, $panel, $tenant) . '/';
return parent::getUrl($parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters, $configuration) . '/';
}
public static function route(string $path): PageRegistration

View File

@@ -79,15 +79,15 @@ class SubuserResource extends Resource
foreach ($data['permissions'] as $permission) {
$options[$permission] = str($permission)->headline();
$descriptions[$permission] = trans('server/user.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$descriptions[$permission] = trans($data['translation_prefix']. '.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$permissionsArray[$data['name']][] = $permission;
}
$tabs[] = Tab::make($data['name'])
->label(str($data['name'])->headline())
->label(trans($data['translation_prefix']. '.' . $data['name'] . '_title'))
->schema([
Section::make()
->description(trans('server/user.permissions.' . $data['name'] . '_desc'))
->description(trans($data['translation_prefix']. '.' . $data['name'] . '_desc'))
->icon($data['icon'])
->contained(false)
->schema([

View File

@@ -13,10 +13,17 @@ use Illuminate\Auth\SessionGuard;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Throwable;
class AccountController extends ClientApiController
{
/**
* The number of seconds that must elapse before the email change throttle resets.
*/
private const EMAIL_UPDATE_THROTTLE = 60 * 60 * 24;
/**
* AccountController constructor.
*/
@@ -63,10 +70,22 @@ class AccountController extends ClientApiController
*/
public function updateEmail(UpdateEmailRequest $request): JsonResponse
{
$original = $request->user()->email;
$this->updateService->handle($request->user(), $request->validated());
$user = $request->user();
// Only allow a user to change their email three times in the span
// of 24 hours. This prevents malicious users from trying to find
// existing accounts in the system by constantly changing their email.
if (RateLimiter::tooManyAttempts($key = "user:update-email:{$user->uuid}", 3)) {
throw new TooManyRequestsHttpException(message: 'Your email address has been changed too many times today. Please try again later.');
}
$original = $user->email;
if (mb_strtolower($original) !== mb_strtolower($request->validated('email'))) {
RateLimiter::hit($key, self::EMAIL_UPDATE_THROTTLE);
$this->updateService->handle($user, $request->validated());
if ($original !== $request->input('email')) {
Activity::event('user:account.email-changed')
->property(['old' => $original, 'new' => $request->input('email')])
->log();
@@ -85,7 +104,9 @@ class AccountController extends ClientApiController
*/
public function updatePassword(UpdatePasswordRequest $request): JsonResponse
{
$user = $this->updateService->handle($request->user(), $request->validated());
$user = Activity::event('user:account.password-changed')->transaction(function () use ($request) {
return $this->updateService->handle($request->user(), $request->validated());
});
$guard = $this->manager->guard();
// If you do not update the user in the session you'll end up working with a
@@ -98,8 +119,6 @@ class AccountController extends ClientApiController
$guard->logoutOtherDevices($request->input('password'));
}
Activity::event('user:account.password-changed')->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
}

View File

@@ -87,7 +87,7 @@ class BackupController extends ClientApiController
}
$backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) {
$server->backups()->lockForUpdate();
$server->backups()->lockForUpdate()->count();
$backup = $action->handle($server, $request->input('name'));

View File

@@ -60,7 +60,7 @@ class DatabaseController extends ClientApiController
public function store(StoreDatabaseRequest $request, Server $server): array
{
$database = Activity::event('server:database.create')->transaction(function ($log) use ($request, $server) {
$server->databases()->lockForUpdate();
$server->databases()->lockForUpdate()->count();
$database = $this->deployDatabaseService->handle($server, $request->validated());
@@ -87,15 +87,12 @@ class DatabaseController extends ClientApiController
*/
public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array
{
$this->managementService->rotatePassword($database);
$database->refresh();
Activity::event('server:database.rotate-password')
->subject($database)
->property('name', $database->database)
->log();
->transaction(fn () => $this->managementService->rotatePassword($database));
return $this->fractal->item($database)
return $this->fractal->item($database->refresh())
->parseIncludes(['password'])
->transformWith($this->getTransformer(DatabaseTransformer::class))
->toArray();

View File

@@ -77,7 +77,7 @@ class FileController extends ClientApiController
->property('file', $request->get('file'))
->log();
return new Response($response, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
return new Response(convert_to_utf8($response), Response::HTTP_OK, ['Content-Type' => 'text/plain; charset=utf-8']);
}
/**

View File

@@ -87,13 +87,13 @@ class StartupController extends ClientApiController
$startup = $this->startupCommandService->handle($server);
if ($variable->env_variable !== $request->input('value')) {
if ($original !== $request->input('value')) {
Activity::event('server:startup.edit')
->subject($variable)
->property([
'variable' => $variable->env_variable,
'old' => $original,
'new' => $request->input('value'),
'new' => $request->input('value') ?? '',
])
->log();
}

View File

@@ -56,7 +56,7 @@ class BackupRemoteUploadController extends Controller
/** @var Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
// Prevent backups that have already been completed from trying to

View File

@@ -47,7 +47,7 @@ class BackupStatusController extends Controller
/** @var Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
if ($model->is_successful) {
@@ -97,6 +97,11 @@ class BackupStatusController extends Controller
/** @var Backup $model */
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$node = $request->attributes->get('node');
if (!$model->server->node->is($node)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$model->server->update(['status' => null]);
Activity::event($request->boolean('successful') ? 'server:backup.restore-complete' : 'server.backup.restore-failed')

View File

@@ -3,18 +3,23 @@
namespace App\Http\Controllers\Api\Remote\Servers;
use App\Enums\ContainerStatus;
use App\Exceptions\Http\HttpForbiddenException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServerContainersController extends Controller
{
/**
* Updates the server container's status on the Panel
*/
public function status(ServerRequest $request, Server $server): JsonResponse
public function status(Request $request, Server $server): JsonResponse
{
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$status = ContainerStatus::tryFrom($request->json('data.new_state')) ?? ContainerStatus::Missing;
cache()->put("servers.$server->uuid.status", $status, now()->addHour());

View File

@@ -3,9 +3,9 @@
namespace App\Http\Controllers\Api\Remote\Servers;
use App\Enums\ServerState;
use App\Exceptions\Http\HttpForbiddenException;
use App\Facades\Activity;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Http\Resources\Daemon\ServerConfigurationCollection;
use App\Models\ActivityLog;
use App\Models\Backup;
@@ -17,6 +17,7 @@ use Illuminate\Database\ConnectionInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
use Webmozart\Assert\Assert;
class ServerDetailsController extends Controller
{
@@ -33,8 +34,21 @@ class ServerDetailsController extends Controller
* Returns details about the server that allows daemon to self-recover and ensure
* that the state of the server matches the Panel at all times.
*/
public function __invoke(ServerRequest $request, Server $server): JsonResponse
public function __invoke(Request $request, Server $server): JsonResponse
{
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
$transfer = $server->transfer;
// If the server is being transferred allow either node to request information about
// the server. If the server is not being transferred only the target node is allowed
// to fetch these details.
$valid = $transfer ? $node->id === $transfer->old_node || $node->id === $transfer->new_node : $node->id === $server->node_id;
if (!$valid) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
return new JsonResponse([
'settings' => $this->configurationStructureService->handle($server),
'process_configuration' => $this->eggConfigurationService->handle($server),

View File

@@ -4,12 +4,13 @@ namespace App\Http\Controllers\Api\Remote\Servers;
use App\Enums\ServerState;
use App\Events\Server\Installed as ServerInstalled;
use App\Exceptions\Http\HttpForbiddenException;
use App\Exceptions\Model\DataValidationException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\InstallationDataRequest;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class ServerInstallController extends Controller
@@ -17,12 +18,18 @@ class ServerInstallController extends Controller
/**
* Returns installation information for a server.
*/
public function index(ServerRequest $request, Server $server): JsonResponse
public function index(Request $request, Server $server): JsonResponse
{
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$egg = $server->egg;
return new JsonResponse([
'container_image' => $server->egg->copy_script_container,
'entrypoint' => $server->egg->copy_script_entry,
'script' => $server->egg->copy_script_install,
'container_image' => $egg->copy_script_container,
'entrypoint' => $egg->copy_script_entry,
'script' => $egg->copy_script_install,
]);
}
@@ -35,6 +42,10 @@ class ServerInstallController extends Controller
{
$status = null;
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$successful = $request->boolean('successful');
// Make sure the type of failure is accurate

View File

@@ -2,17 +2,20 @@
namespace App\Http\Controllers\Api\Remote\Servers;
use App\Exceptions\Http\HttpForbiddenException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Models\Allocation;
use App\Models\Node;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Throwable;
use Webmozart\Assert\Assert;
class ServerTransferController extends Controller
{
@@ -29,13 +32,22 @@ class ServerTransferController extends Controller
*
* @throws Throwable
*/
public function failure(ServerRequest $request, Server $server): JsonResponse
public function failure(Request $request, Server $server): JsonResponse
{
$transfer = $server->transfer;
if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.');
}
/* @var Node $node */
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
// Either node can tell the panel that the transfer has failed. Only the new node
// can tell the panel that it was successful.
if (!$node->is($transfer->newNode) && !$node->is($transfer->oldNode)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$this->connection->transaction(function () use ($transfer) {
$transfer->forceFill(['successful' => false])->saveOrFail();
@@ -53,13 +65,22 @@ class ServerTransferController extends Controller
*
* @throws Throwable
*/
public function success(ServerRequest $request, Server $server): JsonResponse
public function success(Request $request, Server $server): JsonResponse
{
$transfer = $server->transfer;
if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.');
}
/* @var Node $node */
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
// Only the new node communicates a successful state to the panel, so we should
// not allow the old node to hit this endpoint.
if (!$node->is($transfer->newNode)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
/** @var Server $server */
$server = $this->connection->transaction(function () use ($server, $transfer) {
$data = [];

View File

@@ -23,7 +23,7 @@ class RequireTwoFactorAuthentication
* order to perform actions. If so, we check the level at which it is required (all users
* or just admins) and then check if the user has enabled it for their account.
*
* @throws \App\Exceptions\Http\TwoFactorAuthRequiredException
* @throws TwoFactorAuthRequiredException
*/
public function handle(Request $request, \Closure $next): mixed
{

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class SetSecurityHeaders
{
/**
* Ideally we move away from X-Frame-Options/X-XSS-Protection and implement a
* proper standard CSP, but I can guarantee that will break for a lot of folks
* using custom plugins and who knows what image embeds.
*
* We'll circle back to that at a later date when it can be more fully controlled
* by the admin to support those cases without too much trouble.
*
* @var array<string, string>
*/
protected static array $headers = [
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
'Referrer-Policy' => 'no-referrer-when-downgrade',
];
/**
* Enforces some basic security headers on all responses returned by the software.
* If a header has already been set in another location within the code it will be
* skipped over here.
*
* @param (\Closure(mixed): Response) $next
*/
public function handle(Request $request, \Closure $next): mixed
{
$response = $next($request);
foreach (static::$headers as $key => $value) {
if (!$response->headers->has($key)) {
$response->headers->set($key, $value);
}
}
return $response;
}
}

View File

@@ -87,7 +87,7 @@ abstract class ApplicationApiRequest extends FormRequest
$value = $this->route()->parameter($key);
Assert::isInstanceOf($value, $expect);
Assert::isInstanceOf($value, Model::class);
Assert::isInstanceOf($value, Model::class); // @phpstan-ignore staticMethod.alreadyNarrowedType
Assert::true($value->exists);
/* @var T $value */

View File

@@ -2,8 +2,15 @@
namespace App\Http\Requests\Api\Remote;
class InstallationDataRequest extends ServerRequest
use Illuminate\Foundation\Http\FormRequest;
class InstallationDataRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string|string[]>
*/

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Http\Requests\Api\Remote;
use App\Models\Node;
use App\Models\Server;
use Illuminate\Foundation\Http\FormRequest;
class ServerRequest extends FormRequest
{
public function authorize(): bool
{
/** @var Node $node */
$node = $this->attributes->get('node');
/** @var ?Server $server */
$server = $this->route()->parameter('server');
if ($server) {
if ($server->transfer) {
return $server->transfer->old_node === $node->id || $server->transfer->new_node === $node->id;
}
return $server->node_id === $node->id;
}
return false;
}
}

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
abstract class Job
{
/*
|--------------------------------------------------------------------------
| Queueable Jobs
|--------------------------------------------------------------------------
|
| This job base class provides a central location to place any logic that
| is shared across all of your jobs. The trait included with the class
| provides access to the "onQueue" and "delay" queue helper methods.
|
*/
use Queueable;
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Jobs\Plugin;
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
use App\Models\Plugin;
use App\Models\User;
use App\Services\Helpers\PluginService;
use Exception;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class InstallPlugin implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public Plugin $plugin) {}
public function handle(PluginService $pluginService): void
{
try {
$pluginService->installPlugin($this->plugin, !$this->plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
Notification::make()
->success()
->title(trans('admin/plugin.notifications.installed'))
->body($this->plugin->name)
->actions([
Action::make('goto_plugins')
->label(trans('admin/plugin.notifications.goto_plugins'))
->url(ListPlugins::getUrl(panel: 'admin')),
])
->sendToDatabase($this->user);
} catch (Exception $exception) {
report($exception);
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.install_error'))
->body($exception->getMessage())
->sendToDatabase($this->user);
}
}
public function uniqueId(): string
{
return 'plugin:install:' . $this->plugin->id;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Jobs\Plugin;
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
use App\Models\Plugin;
use App\Models\User;
use App\Services\Helpers\PluginService;
use Exception;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class UninstallPlugin implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public Plugin $plugin) {}
public function handle(PluginService $pluginService): void
{
try {
$pluginService->uninstallPlugin($this->plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.uninstalled'))
->body($this->plugin->name)
->actions([
Action::make('goto_plugins')
->label(trans('admin/plugin.notifications.goto_plugins'))
->url(ListPlugins::getUrl(panel: 'admin')),
])
->sendToDatabase($this->user);
} catch (Exception $exception) {
report($exception);
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.uninstall_error'))
->body($exception->getMessage())
->sendToDatabase($this->user);
}
}
public function uniqueId(): string
{
return 'plugin:uninstall:' . $this->plugin->id;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Jobs\Plugin;
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
use App\Models\Plugin;
use App\Models\User;
use App\Services\Helpers\PluginService;
use Exception;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class UpdatePlugin implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public Plugin $plugin) {}
public function handle(PluginService $pluginService): void
{
try {
$pluginService->updatePlugin($this->plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.updated'))
->body($this->plugin->name)
->actions([
Action::make('goto_plugins')
->label(trans('admin/plugin.notifications.goto_plugins'))
->url(ListPlugins::getUrl(panel: 'admin')),
])
->sendToDatabase($this->user);
} catch (Exception $exception) {
report($exception);
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.update_error'))
->body($exception->getMessage())
->sendToDatabase($this->user);
}
}
public function uniqueId(): string
{
return 'plugin:update:' . $this->plugin->id;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Jobs;
use App\Models\Node;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\Attributes\WithoutRelations;
/**
* Revokes all SFTP access for a user on a given node or for a specific server.
*/
#[DeleteWhenMissingModels]
class RevokeSftpAccessJob implements ShouldBeUnique, ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $maxExceptions = 1;
public function __construct(
public readonly string $user,
#[WithoutRelations]
public readonly Server|Node $target,
) {}
public function uniqueId(): string
{
$target = $this->target instanceof Node ? "node:{$this->target->uuid}" : "server:{$this->target->uuid}";
return "revoke-sftp:{$this->user}:{$target}";
}
public function handle(DaemonServerRepository $repository): void
{
try {
if ($this->target instanceof Server) {
$repository->setServer($this->target)->deauthorize($this->user);
} else {
$repository->setNode($this->target)->deauthorize($this->user);
}
} catch (ConnectionException) {
// Keep retrying this job with a longer and longer backoff until we hit three
// attempts at which point we stop and will assume the node is fully offline
// and we are just wasting time.
$this->release($this->attempts() * 10);
}
}
}

View File

@@ -7,6 +7,7 @@ use App\Jobs\Job;
use App\Models\Task;
use Carbon\CarbonImmutable;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
@@ -14,9 +15,10 @@ use Illuminate\Queue\SerializesModels;
use InvalidArgumentException;
use Throwable;
class RunTaskJob extends Job implements ShouldQueue
class RunTaskJob implements ShouldQueue
{
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Listeners;
use App\Events\User\Deleting;
use App\Events\User\PasswordChanged;
use App\Jobs\RevokeSftpAccessJob;
use App\Models\Node;
use Illuminate\Database\Eloquent\Collection;
class RevocationListener
{
public function handle(Deleting|PasswordChanged $event): void
{
$user = $event->user;
// Look at all of the nodes that a user is associated with and trigger a job
// that disconnects them from websockets and SFTP.
Node::query()
->whereIn('nodes.id', $user->directAccessibleServers()->select('servers.node_id')->distinct())
->chunk(50, function (Collection $nodes) use ($user) {
$nodes->each(fn (Node $node) => RevokeSftpAccessJob::dispatch($user->uuid, $node));
});
}
}

View File

@@ -8,6 +8,7 @@ use App\Services\Nodes\NodeJWTService;
use App\Services\Servers\GetUserPermissionsService;
use Filament\Support\Enums\IconSize;
use Filament\Tables\View\Components\Columns\IconColumnComponent\IconComponent;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\ComponentAttributeBag;
use Livewire\Attributes\Locked;
@@ -30,7 +31,7 @@ class NodeClientConnectivity extends Component
$this->nodeJWTService = $nodeJWTService;
}
public function render(): \Illuminate\Contracts\View\View
public function render(): View
{
$httpUrl = $this->node->getConnectionAddress();

View File

@@ -6,7 +6,6 @@ use App\Enums\TablerIcon;
use App\Events\ActivityLogged;
use App\Traits\HasValidation;
use BackedEnum;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
@@ -17,6 +16,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
@@ -31,28 +31,28 @@ use LogicException;
* @property string|null $description
* @property string|null $actor_type
* @property int|null $actor_id
* @property Collection<array-key, mixed> $properties
* @property Carbon $timestamp
* @property int|null $api_key_id
* @property Collection|null $properties
* @property \Carbon\Carbon $timestamp
* @property Model|\Eloquent $actor
* @property \Illuminate\Database\Eloquent\Collection|ActivityLogSubject[] $subjects
* @property int|null $subjects_count
* @property ApiKey|null $apiKey
* @property-read Model|\Eloquent|null $actor
* @property-read ApiKey|null $apiKey
* @property-read \Illuminate\Database\Eloquent\Collection<int, ActivityLogSubject> $subjects
* @property-read int|null $subjects_count
*
* @method static Builder|ActivityLog forActor(Model $actor)
* @method static Builder|ActivityLog forEvent(string $action)
* @method static Builder|ActivityLog newModelQuery()
* @method static Builder|ActivityLog newQuery()
* @method static Builder|ActivityLog query()
* @method static Builder|ActivityLog whereActorId($value)
* @method static Builder|ActivityLog whereActorType($value)
* @method static Builder|ActivityLog whereApiKeyId($value)
* @method static Builder|ActivityLog whereDescription($value)
* @method static Builder|ActivityLog whereEvent($value)
* @method static Builder|ActivityLog whereId($value)
* @method static Builder|ActivityLog whereIp($value)
* @method static Builder|ActivityLog whereProperties($value)
* @method static Builder|ActivityLog whereTimestamp($value)
* @method static Builder<static>|ActivityLog forActor(\Illuminate\Database\Eloquent\Model $actor)
* @method static Builder<static>|ActivityLog forEvent(string $action)
* @method static Builder<static>|ActivityLog newModelQuery()
* @method static Builder<static>|ActivityLog newQuery()
* @method static Builder<static>|ActivityLog query()
* @method static Builder<static>|ActivityLog whereActorId($value)
* @method static Builder<static>|ActivityLog whereActorType($value)
* @method static Builder<static>|ActivityLog whereApiKeyId($value)
* @method static Builder<static>|ActivityLog whereDescription($value)
* @method static Builder<static>|ActivityLog whereEvent($value)
* @method static Builder<static>|ActivityLog whereId($value)
* @method static Builder<static>|ActivityLog whereIp($value)
* @method static Builder<static>|ActivityLog whereProperties($value)
* @method static Builder<static>|ActivityLog whereTimestamp($value)
*/
class ActivityLog extends Model implements HasIcon, HasLabel
{

View File

@@ -14,14 +14,18 @@ use Illuminate\Database\Eloquent\SoftDeletingScope;
*
* @property int $id
* @property int $activity_log_id
* @property int $subject_id
* @property string $subject_type
* @property ActivityLog|null $activityLog
* @property Model|\Eloquent $subject
* @property int $subject_id
* @property-read ActivityLog $activityLog
* @property-read Model|\Eloquent $subject
*
* @method static Builder|ActivityLogSubject newModelQuery()
* @method static Builder|ActivityLogSubject newQuery()
* @method static Builder|ActivityLogSubject query()
* @method static Builder<static>|ActivityLogSubject newModelQuery()
* @method static Builder<static>|ActivityLogSubject newQuery()
* @method static Builder<static>|ActivityLogSubject query()
* @method static Builder<static>|ActivityLogSubject whereActivityLogId($value)
* @method static Builder<static>|ActivityLogSubject whereId($value)
* @method static Builder<static>|ActivityLogSubject whereSubjectId($value)
* @method static Builder<static>|ActivityLogSubject whereSubjectType($value)
*/
class ActivityLogSubject extends Pivot
{

View File

@@ -4,13 +4,12 @@ namespace App\Models;
use App\Exceptions\Service\Allocation\ServerUsingAllocationException;
use App\Traits\HasValidation;
use Carbon\Carbon;
use Database\Factories\AllocationFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* App\Models\Allocation.
@@ -18,32 +17,33 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property int $id
* @property int $node_id
* @property string $ip
* @property string|null $ip_alias
* @property int $port
* @property int|null $server_id
* @property string|null $notes
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string $alias
* @property bool $has_alias
* @property string $address
* @property Server|null $server
* @property Node $node
* @property string|null $ip_alias
* @property string|null $notes
* @property bool $is_locked
* @property-read string $address
* @property-read string $alias
* @property-read bool $has_alias
* @property-read Node $node
* @property-read Server|null $server
*
* @method static AllocationFactory factory(...$parameters)
* @method static Builder|Allocation newModelQuery()
* @method static Builder|Allocation newQuery()
* @method static Builder|Allocation query()
* @method static Builder|Allocation whereCreatedAt($value)
* @method static Builder|Allocation whereId($value)
* @method static Builder|Allocation whereIp($value)
* @method static Builder|Allocation whereIpAlias($value)
* @method static Builder|Allocation whereNodeId($value)
* @method static Builder|Allocation whereNotes($value)
* @method static Builder|Allocation wherePort($value)
* @method static Builder|Allocation whereServerId($value)
* @method static Builder|Allocation whereUpdatedAt($value)
* @method static \Database\Factories\AllocationFactory factory($count = null, $state = [])
* @method static Builder<static>|Allocation newModelQuery()
* @method static Builder<static>|Allocation newQuery()
* @method static Builder<static>|Allocation query()
* @method static Builder<static>|Allocation whereCreatedAt($value)
* @method static Builder<static>|Allocation whereId($value)
* @method static Builder<static>|Allocation whereIp($value)
* @method static Builder<static>|Allocation whereIpAlias($value)
* @method static Builder<static>|Allocation whereIsLocked($value)
* @method static Builder<static>|Allocation whereNodeId($value)
* @method static Builder<static>|Allocation whereNotes($value)
* @method static Builder<static>|Allocation wherePort($value)
* @method static Builder<static>|Allocation whereServerId($value)
* @method static Builder<static>|Allocation whereUpdatedAt($value)
*/
class Allocation extends Model
{

View File

@@ -4,7 +4,6 @@ namespace App\Models;
use App\Services\Acl\Api\AdminAcl;
use App\Traits\HasValidation;
use Database\Factories\ApiKeyFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -17,41 +16,35 @@ use Webmozart\Assert\Assert;
* App\Models\ApiKey.
*
* @property int $id
* @property int $user_id
* @property int $key_type
* @property string $identifier
* @property string $token
* @property string[]|null $permissions
* @property string[]|null $allowed_ips
* @property string|null $memo
* @property Carbon|null $last_used_at
* @property Carbon|null $expires_at
* @property string[] $allowed_ips
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property User $tokenable
* @property User $user
* @property int|null $user_id
* @property string|null $memo
* @property string|null $identifier
* @property int $key_type
* @property Carbon|null $last_used_at
* @property Carbon|null $expires_at
* @property array<string, int> $permissions
* @property-read User|null $user
*
* @method static ApiKeyFactory factory(...$parameters)
* @method static Builder|ApiKey newModelQuery()
* @method static Builder|ApiKey newQuery()
* @method static Builder|ApiKey query()
* @method static Builder|ApiKey whereAllowedIps($value)
* @method static Builder|ApiKey whereCreatedAt($value)
* @method static Builder|ApiKey whereId($value)
* @method static Builder|ApiKey whereIdentifier($value)
* @method static Builder|ApiKey whereKeyType($value)
* @method static Builder|ApiKey whereLastUsedAt($value)
* @method static Builder|ApiKey whereMemo($value)
* @method static Builder|ApiKey whereRAllocations($value)
* @method static Builder|ApiKey whereRDatabaseHosts($value)
* @method static Builder|ApiKey whereREggs($value)
* @method static Builder|ApiKey whereRNodes($value)
* @method static Builder|ApiKey whereRServerDatabases($value)
* @method static Builder|ApiKey whereRServers($value)
* @method static Builder|ApiKey whereRUsers($value)
* @method static Builder|ApiKey whereToken($value)
* @method static Builder|ApiKey whereUpdatedAt($value)
* @method static Builder|ApiKey whereUserId($value)
* @method static \Database\Factories\ApiKeyFactory factory($count = null, $state = [])
* @method static Builder<static>|ApiKey newModelQuery()
* @method static Builder<static>|ApiKey newQuery()
* @method static Builder<static>|ApiKey query()
* @method static Builder<static>|ApiKey whereAllowedIps($value)
* @method static Builder<static>|ApiKey whereCreatedAt($value)
* @method static Builder<static>|ApiKey whereExpiresAt($value)
* @method static Builder<static>|ApiKey whereId($value)
* @method static Builder<static>|ApiKey whereIdentifier($value)
* @method static Builder<static>|ApiKey whereKeyType($value)
* @method static Builder<static>|ApiKey whereLastUsedAt($value)
* @method static Builder<static>|ApiKey whereMemo($value)
* @method static Builder<static>|ApiKey wherePermissions($value)
* @method static Builder<static>|ApiKey whereToken($value)
* @method static Builder<static>|ApiKey whereUpdatedAt($value)
* @method static Builder<static>|ApiKey whereUserId($value)
*/
class ApiKey extends PersonalAccessToken
{

View File

@@ -18,20 +18,44 @@ use Illuminate\Database\Query\Builder;
* @property int $id
* @property int $server_id
* @property string $uuid
* @property bool $is_successful
* @property bool $is_locked
* @property string $name
* @property string[] $ignored_files
* @property string $disk
* @property string|null $checksum
* @property int $bytes
* @property string|null $upload_id
* @property CarbonImmutable|null $completed_at
* @property BackupStatus $status
* @property CarbonImmutable $created_at
* @property CarbonImmutable $updated_at
* @property CarbonImmutable|null $created_at
* @property CarbonImmutable|null $updated_at
* @property CarbonImmutable|null $deleted_at
* @property Server $server
* @property bool $is_successful
* @property string|null $upload_id
* @property bool $is_locked
* @property-read Server $server
* @property-read BackupStatus $status
*
* @method static \Database\Factories\BackupFactory factory($count = null, $state = [])
* @method static BackupQueryBuilder<static>|Backup newModelQuery()
* @method static BackupQueryBuilder<static>|Backup newQuery()
* @method static BackupQueryBuilder<static>|Backup nonFailed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Backup onlyTrashed()
* @method static BackupQueryBuilder<static>|Backup query()
* @method static BackupQueryBuilder<static>|Backup whereBytes($value)
* @method static BackupQueryBuilder<static>|Backup whereChecksum($value)
* @method static BackupQueryBuilder<static>|Backup whereCompletedAt($value)
* @method static BackupQueryBuilder<static>|Backup whereCreatedAt($value)
* @method static BackupQueryBuilder<static>|Backup whereDeletedAt($value)
* @method static BackupQueryBuilder<static>|Backup whereDisk($value)
* @method static BackupQueryBuilder<static>|Backup whereId($value)
* @method static BackupQueryBuilder<static>|Backup whereIgnoredFiles($value)
* @method static BackupQueryBuilder<static>|Backup whereIsLocked($value)
* @method static BackupQueryBuilder<static>|Backup whereIsSuccessful($value)
* @method static BackupQueryBuilder<static>|Backup whereName($value)
* @method static BackupQueryBuilder<static>|Backup whereServerId($value)
* @method static BackupQueryBuilder<static>|Backup whereUpdatedAt($value)
* @method static BackupQueryBuilder<static>|Backup whereUploadId($value)
* @method static BackupQueryBuilder<static>|Backup whereUuid($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Backup withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Backup withoutTrashed()
*/
class Backup extends Model implements Validatable
{

View File

@@ -4,11 +4,11 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Traits\HasValidation;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use PDOException;
/**
@@ -19,12 +19,27 @@ use PDOException;
* @property string $username
* @property string $remote
* @property string $password
* @property ?int $max_connections
* @property string $jdbc
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Server $server
* @property DatabaseHost $host
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property int|null $max_connections
* @property-read DatabaseHost $host
* @property-read string $jdbc
* @property-read Server $server
*
* @method static \Database\Factories\DatabaseFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database whereDatabase($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database whereDatabaseHostId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database whereMaxConnections($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database wherePassword($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database whereRemote($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database whereServerId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Database whereUsername($value)
*/
class Database extends Model implements Validatable
{

View File

@@ -21,13 +21,26 @@ use Illuminate\Support\Facades\DB;
* @property string $username
* @property string $password
* @property int|null $max_databases
* @property int|null $node_id
* @property CarbonImmutable $created_at
* @property CarbonImmutable $updated_at
* @property Collection|Node[] $nodes
* @property int|null $nodes_count
* @property Collection|Database[] $databases
* @property int|null $databases_count
* @property CarbonImmutable|null $created_at
* @property CarbonImmutable|null $updated_at
* @property-read Collection<int, Database> $databases
* @property-read int|null $databases_count
* @property-read Collection<int, Node> $nodes
* @property-read int|null $nodes_count
*
* @method static \Database\Factories\DatabaseHostFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost whereHost($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost whereMaxDatabases($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost wherePassword($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost wherePort($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|DatabaseHost whereUsername($value)
*/
class DatabaseHost extends Model implements Validatable
{

View File

@@ -5,62 +5,96 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Exceptions\Service\Egg\HasChildrenException;
use App\Exceptions\Service\HasActiveServersException;
use App\Models\Traits\HasIcon;
use App\Traits\HasValidation;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* @property int $id
* @property string $uuid
* @property string $author
* @property string $name
* @property string|null $description
* @property string|null $image
* @property string[]|null $features
* @property array<string, string> $docker_images
* @property string|null $update_url
* @property bool $force_outgoing_ip
* @property string[]|null $file_denylist
* @property string|null $config_files
* @property string|null $config_startup
* @property string|null $config_logs
* @property string|null $config_stop
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property int|null $config_from
* @property array<string, string> $startup_commands
* @property bool $script_is_privileged
* @property string|null $config_stop
* @property string|null $config_logs
* @property string|null $config_startup
* @property string|null $config_files
* @property string|null $script_install
* @property bool $script_is_privileged
* @property string $script_entry
* @property string $script_container
* @property int|null $copy_script_from
* @property Carbon $created_at
* @property Carbon $updated_at
* @property string|null $copy_script_install
* @property string $copy_script_entry
* @property string $copy_script_container
* @property string|null $inherit_config_files
* @property string|null $inherit_config_startup
* @property string|null $inherit_config_logs
* @property string|null $inherit_config_stop
* @property string $inherit_file_denylist
* @property string[]|null $inherit_features
* @property string|null $uuid
* @property string $author
* @property string[]|null $features
* @property array<string, string> $docker_images
* @property string|null $update_url
* @property string[]|null $file_denylist
* @property bool $force_outgoing_ip
* @property string[] $tags
* @property Collection|Server[] $servers
* @property int|null $servers_count
* @property Collection|EggVariable[] $variables
* @property int|null $variables_count
* @property \App\Models\Egg|null $scriptFrom
* @property \App\Models\Egg|null $configFrom
* @property array<string, string> $startup_commands
* @property-read Collection<int, Egg> $children
* @property-read int|null $children_count
* @property-read Egg|null $configFrom
* @property-read string $copy_script_container
* @property-read string $copy_script_entry
* @property-read string|null $copy_script_install
* @property-read string|null $icon
* @property-read string|null $inherit_config_files
* @property-read string|null $inherit_config_logs
* @property-read string|null $inherit_config_startup
* @property-read string|null $inherit_config_stop
* @property-read string[]|null $inherit_features
* @property-read string[]|null $inherit_file_denylist
* @property-read Collection<int, Mount> $mounts
* @property-read int|null $mounts_count
* @property-read Egg|null $scriptFrom
* @property-read Collection<int, Server> $servers
* @property-read int|null $servers_count
* @property-read Collection<int, EggVariable> $variables
* @property-read int|null $variables_count
*
* @method static \Database\Factories\EggFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereAuthor($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereConfigFiles($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereConfigFrom($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereConfigLogs($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereConfigStartup($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereConfigStop($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereCopyScriptFrom($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereDockerImages($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereFeatures($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereFileDenylist($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereForceOutgoingIp($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereScriptContainer($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereScriptEntry($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereScriptInstall($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereScriptIsPrivileged($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereStartupCommands($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereTags($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereUpdateUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Egg whereUuid($value)
*/
class Egg extends Model implements Validatable
{
use HasFactory;
use HasIcon;
use HasValidation;
/**
@@ -74,22 +108,6 @@ class Egg extends Model implements Validatable
*/
public const EXPORT_VERSION = 'PLCN_v3';
/**
* Path to store egg icons relative to storage path.
*/
public const ICON_STORAGE_PATH = 'icons/egg';
/**
* Supported image formats: file extension => MIME type
*/
public const IMAGE_FORMATS = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
];
/**
* Fields that are not mass assignable.
*/
@@ -344,16 +362,4 @@ class Egg extends Model implements Validatable
{
return str($this->name)->kebab()->lower()->trim()->split('/[^\w\-]/')->join('');
}
public function getImageAttribute(): ?string
{
foreach (array_keys(static::IMAGE_FORMATS) as $ext) {
$path = static::ICON_STORAGE_PATH . "/$this->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
return Storage::disk('public')->url($path);
}
}
return null;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Traits\HasValidation;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -13,7 +14,6 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
/**
* @property int $id
* @property int $egg_id
* @property null $sort
* @property string $name
* @property string $description
* @property string $env_variable
@@ -21,15 +21,32 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
* @property bool $user_viewable
* @property bool $user_editable
* @property string[] $rules
* @property CarbonImmutable $created_at
* @property CarbonImmutable $updated_at
* @property bool $required
* @property Egg $egg
* @property ServerVariable $serverVariable
* @property CarbonImmutable|null $created_at
* @property CarbonImmutable|null $updated_at
* @property int|null $sort
* @property-read Egg|null $egg
* @property-read bool $required
* @property-read Collection<int, ServerVariable> $serverVariable
* @property-read int|null $server_variable_count
*
* The "server_value" variable is only present on the object if you've loaded this model
* using the server relationship.
* @property string|null $server_value
* @method static \Database\Factories\EggVariableFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereDefaultValue($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereEggId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereEnvVariable($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereRules($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereSort($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereUserEditable($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|EggVariable whereUserViewable($value)
*
* @property string|null $server_value This variable is only present on the object if you've loaded this model using the server relationship.
*/
class EggVariable extends Model implements Validatable
{

View File

@@ -6,12 +6,12 @@ use App\Enums\TablerIcon;
use App\Livewire\AlertBanner;
use App\Repositories\Daemon\DaemonFileRepository;
use BackedEnum;
use Carbon\Carbon;
use Closure;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Carbon;
use Sushi\Sushi;
/**
@@ -28,6 +28,21 @@ use Sushi\Sushi;
* @property bool $is_file
* @property bool $is_symlink
* @property string $mime_type
*
* @method static Builder<static>|File newModelQuery()
* @method static Builder<static>|File newQuery()
* @method static Builder<static>|File query()
* @method static Builder<static>|File whereCreatedAt($value)
* @method static Builder<static>|File whereId($value)
* @method static Builder<static>|File whereIsDirectory($value)
* @method static Builder<static>|File whereIsFile($value)
* @method static Builder<static>|File whereIsSymlink($value)
* @method static Builder<static>|File whereMimeType($value)
* @method static Builder<static>|File whereMode($value)
* @method static Builder<static>|File whereModeBits($value)
* @method static Builder<static>|File whereModifiedAt($value)
* @method static Builder<static>|File whereName($value)
* @method static Builder<static>|File whereSize($value)
*/
class File extends Model
{
@@ -138,6 +153,9 @@ class File extends Model
return [
'created_at' => 'datetime',
'modified_at' => 'datetime',
'is_directory' => 'boolean',
'is_file' => 'boolean',
'is_symlink' => 'boolean',
];
}

View File

@@ -12,14 +12,29 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany;
* @property int $id
* @property string $uuid
* @property string $name
* @property string $description
* @property string|null $description
* @property string $source
* @property string $target
* @property bool $read_only
* @property bool $user_mountable
* @property Egg[]|Collection $eggs
* @property Node[]|Collection $nodes
* @property Server[]|Collection $servers
* @property-read Collection<int, Egg> $eggs
* @property-read int|null $eggs_count
* @property-read Collection<int, Node> $nodes
* @property-read int|null $nodes_count
* @property-read Collection<int, Server> $servers
* @property-read int|null $servers_count
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount whereReadOnly($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount whereSource($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount whereTarget($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount whereUserMountable($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Mount whereUuid($value)
*/
class Mount extends Model implements Validatable
{

View File

@@ -6,7 +6,6 @@ use App\Contracts\Validatable;
use App\Exceptions\Service\HasActiveServersException;
use App\Repositories\Daemon\DaemonSystemRepository;
use App\Traits\HasValidation;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -15,46 +14,84 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\DatabaseNotificationCollection;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
/**
* @property int $id
* @property string $uuid
* @property bool $public
* @property string $name
* @property string|null $description
* @property string $fqdn
* @property string $scheme
* @property bool $behind_proxy
* @property bool $maintenance_mode
* @property int $memory
* @property int $memory_overallocate
* @property int $disk
* @property int $disk_overallocate
* @property int $cpu
* @property int $cpu_overallocate
* @property int $upload_size
* @property string $daemon_token_id
* @property string $daemon_token
* @property int $daemon_listen
* @property int $daemon_connect
* @property int $daemon_sftp
* @property string|null $daemon_sftp_alias
* @property string $daemon_base
* @property string[] $tags
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Mount[]|Collection $mounts
* @property int|null $mounts_count
* @property Server[]|Collection $servers
* @property int|null $servers_count
* @property Allocation[]|Collection $allocations
* @property int|null $allocations_count
* @property Role[]|Collection $roles
* @property int|null $roles_count
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property int $upload_size
* @property bool $behind_proxy
* @property string|null $description
* @property bool $maintenance_mode
* @property string|null $uuid
* @property string|null $daemon_token_id
* @property array<array-key, mixed>|null $tags
* @property int $cpu
* @property int $cpu_overallocate
* @property string|null $daemon_sftp_alias
* @property int $daemon_connect
* @property-read Collection<int, Allocation> $allocations
* @property-read int|null $allocations_count
* @property-read Collection<int, DatabaseHost> $databaseHosts
* @property-read int|null $database_hosts_count
* @property-read Collection<int, Mount> $mounts
* @property-read int|null $mounts_count
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read Collection<int, Role> $roles
* @property-read int|null $roles_count
* @property-read Collection<int, Server> $servers
* @property-read int|null $servers_count
*
* @method static \Database\Factories\NodeFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereBehindProxy($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereCpu($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereCpuOverallocate($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDaemonBase($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDaemonConnect($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDaemonListen($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDaemonSftp($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDaemonSftpAlias($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDaemonToken($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDaemonTokenId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDisk($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereDiskOverallocate($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereFqdn($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereMaintenanceMode($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereMemory($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereMemoryOverallocate($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node wherePublic($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereScheme($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereTags($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereUploadSize($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Node whereUuid($value)
*/
class Node extends Model implements Validatable
{

View File

@@ -4,6 +4,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
/**
* @property int $node_id
* @property int $role_id
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|NodeRole newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|NodeRole newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|NodeRole query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|NodeRole whereNodeId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|NodeRole whereRoleId($value)
*/
class NodeRole extends Pivot
{
protected $table = 'node_role';

View File

@@ -34,6 +34,26 @@ use Sushi\Sushi;
* @property PluginStatus $status
* @property string|null $status_message
* @property int $load_order
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereAuthor($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereCategory($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereClass($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereComposerPackages($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereLoadOrder($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereNamespace($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin wherePanelVersion($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin wherePanels($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereStatusMessage($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereUpdateUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereUrl($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Plugin whereVersion($value)
*/
class Plugin extends Model implements HasPluginSettings
{

View File

@@ -9,6 +9,7 @@ use BackedEnum;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Carbon;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role as BaseRole;
@@ -16,12 +17,27 @@ use Spatie\Permission\Models\Role as BaseRole;
* @property int $id
* @property string $name
* @property string $guard_name
* @property Collection|Permission[] $permissions
* @property int|null $permissions_count
* @property Collection|User[] $users
* @property int|null $users_count
* @property Collection|Node[] $nodes
* @property int|null $nodes_count
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read NodeRole|null $pivot
* @property-read Collection<int, Node> $nodes
* @property-read int|null $nodes_count
* @property-read Collection<int, Permission> $permissions
* @property-read int|null $permissions_count
* @property-read Collection<int, User> $users
* @property-read int|null $users_count
*
* @method static \Database\Factories\RoleFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role permission($permissions, $without = false)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role whereGuardName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Role withoutPermission($permissions)
*/
class Role extends BaseRole
{

View File

@@ -6,34 +6,55 @@ use App\Contracts\Validatable;
use App\Enums\ScheduleStatus;
use App\Helpers\Utilities;
use App\Traits\HasValidation;
use Carbon\Carbon;
use Exception;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $server_id
* @property string $name
* @property string $cron_day_of_week
* @property string $cron_month
* @property string $cron_day_of_month
* @property string $cron_hour
* @property string $cron_minute
* @property bool $is_active
* @property bool $is_processing
* @property bool $only_when_online
* @property Carbon|null $last_run_at
* @property Carbon|null $next_run_at
* @property ScheduleStatus $status
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Server $server
* @property Task[]|Collection $tasks
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string $cron_month
* @property bool $only_when_online
* @property-read Server $server
* @property-read ScheduleStatus $status
* @property-read Collection<int, Task> $tasks
* @property-read int|null $tasks_count
*
* @method static \Database\Factories\ScheduleFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereCronDayOfMonth($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereCronDayOfWeek($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereCronHour($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereCronMinute($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereCronMonth($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereIsActive($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereIsProcessing($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereLastRunAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereNextRunAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereOnlyWhenOnline($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereServerId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Schedule whereUpdatedAt($value)
*/
class Schedule extends Model implements Validatable
{

View File

@@ -7,11 +7,12 @@ use App\Enums\ContainerStatus;
use App\Enums\ServerResourceType;
use App\Enums\ServerState;
use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Models\Traits\HasIcon;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Services\Subusers\SubuserDeletionService;
use App\Traits\HasValidation;
use Carbon\CarbonInterface;
use Database\Factories\ServerFactory;
use Exception;
use Filament\Models\Contracts\HasAvatar;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -30,111 +31,106 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Psr\Http\Message\ResponseInterface;
/**
* \App\Models\Server.
*
* @property int $id
* @property string|null $external_id
* @property string $uuid
* @property string $uuid_short
* @property int $node_id
* @property string $name
* @property string $description
* @property ServerState|null $status
* @property bool $skip_scripts
* @property int $owner_id
* @property int $memory
* @property int $swap
* @property int $disk
* @property int $io
* @property int $cpu
* @property string|null $threads
* @property bool $oom_killer
* @property int|null $allocation_id
* @property int $egg_id
* @property string $startup
* @property string $image
* @property string|null $icon
* @property int|null $allocation_limit
* @property int|null $database_limit
* @property int|null $backup_limit
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property int|null $allocation_id
* @property string $image
* @property string|null $description
* @property bool $skip_scripts
* @property string|null $external_id
* @property int|null $database_limit
* @property int|null $allocation_limit
* @property string|null $threads
* @property int $backup_limit
* @property ServerState|null $status
* @property Carbon|null $installed_at
* @property Collection|ActivityLog[] $activity
* @property int|null $activity_count
* @property Allocation|null $allocation
* @property Collection|Allocation[] $allocations
* @property int|null $allocations_count
* @property Collection|Backup[] $backups
* @property int|null $backups_count
* @property Collection|Database[] $databases
* @property int|null $databases_count
* @property Egg $egg
* @property Collection|Mount[] $mounts
* @property int|null $mounts_count
* @property Node $node
* @property DatabaseNotificationCollection|DatabaseNotification[] $notifications
* @property int|null $notifications_count
* @property Collection|Schedule[] $schedules
* @property int|null $schedules_count
* @property Collection|Subuser[] $subusers
* @property int|null $subusers_count
* @property ServerTransfer|null $transfer
* @property User $user
* @property Collection|EggVariable[] $variables
* @property int|null $variables_count
*
* @method static ServerFactory factory(...$parameters)
* @method static Builder|Server newModelQuery()
* @method static Builder|Server newQuery()
* @method static Builder|Server query()
* @method static Builder|Server whereAllocationId($value)
* @method static Builder|Server whereAllocationLimit($value)
* @method static Builder|Server whereBackupLimit($value)
* @method static Builder|Server whereCpu($value)
* @method static Builder|Server whereCreatedAt($value)
* @method static Builder|Server whereDatabaseLimit($value)
* @method static Builder|Server whereDescription($value)
* @method static Builder|Server whereDisk($value)
* @method static Builder|Server whereEggId($value)
* @method static Builder|Server whereExternalId($value)
* @method static Builder|Server whereId($value)
* @method static Builder|Server whereImage($value)
* @method static Builder|Server whereIo($value)
* @method static Builder|Server whereMemory($value)
* @method static Builder|Server whereName($value)
* @method static Builder|Server whereNodeId($value)
* @method static Builder|Server whereOomKiller($value)
* @method static Builder|Server whereOwnerId($value)
* @method static Builder|Server whereSkipScripts($value)
* @method static Builder|Server whereStartup($value)
* @method static Builder|Server whereStatus($value)
* @method static Builder|Server whereSwap($value)
* @method static Builder|Server whereThreads($value)
* @method static Builder|Server whereUpdatedAt($value)
* @method static Builder|Server whereUuid($value)
* @method static Builder|Server whereuuid_short($value)
*
* @property string[]|null $docker_labels
* @property string|null $ports
* @property-read ContainerStatus|ServerState $condition
* @property bool $oom_killer
* @property array<array-key, mixed>|null $docker_labels
* @property-read Collection<int, ActivityLog> $activity
* @property-read int|null $activity_count
* @property-read Allocation|null $allocation
* @property-read Collection<int, Allocation> $allocations
* @property-read int|null $allocations_count
* @property-read Collection<int, Backup> $backups
* @property-read int|null $backups_count
* @property-read ServerState|ContainerStatus $condition
* @property-read Collection<int, Database> $databases
* @property-read int|null $databases_count
* @property-read Egg $egg
* @property-read Collection<int, EggVariable> $eggVariables
* @property-read int|null $egg_variables_count
* @property-read string|null $icon
* @property-read Collection<int, Mount> $mounts
* @property-read int|null $mounts_count
* @property-read Node $node
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read Collection<int, Schedule> $schedules
* @property-read int|null $schedules_count
* @property-read Collection<int, ServerVariable> $serverVariables
* @property-read int|null $server_variables_count
* @property-read Collection<int, Subuser> $subusers
* @property-read int|null $subusers_count
* @property-read ServerTransfer|null $transfer
* @property-read User $user
* @property-read Collection<int, EggVariable> $variables
* @property-read int|null $variables_count
*
* @method static Builder|Server whereDockerLabels($value)
* @method static Builder|Server whereInstalledAt($value)
* @method static Builder|Server wherePorts($value)
* @method static Builder|Server whereUuidShort($value)
* @method static \Database\Factories\ServerFactory factory($count = null, $state = [])
* @method static Builder<static>|Server newModelQuery()
* @method static Builder<static>|Server newQuery()
* @method static Builder<static>|Server query()
* @method static Builder<static>|Server whereAllocationId($value)
* @method static Builder<static>|Server whereAllocationLimit($value)
* @method static Builder<static>|Server whereBackupLimit($value)
* @method static Builder<static>|Server whereCpu($value)
* @method static Builder<static>|Server whereCreatedAt($value)
* @method static Builder<static>|Server whereDatabaseLimit($value)
* @method static Builder<static>|Server whereDescription($value)
* @method static Builder<static>|Server whereDisk($value)
* @method static Builder<static>|Server whereDockerLabels($value)
* @method static Builder<static>|Server whereEggId($value)
* @method static Builder<static>|Server whereExternalId($value)
* @method static Builder<static>|Server whereId($value)
* @method static Builder<static>|Server whereImage($value)
* @method static Builder<static>|Server whereInstalledAt($value)
* @method static Builder<static>|Server whereIo($value)
* @method static Builder<static>|Server whereMemory($value)
* @method static Builder<static>|Server whereName($value)
* @method static Builder<static>|Server whereNodeId($value)
* @method static Builder<static>|Server whereOomKiller($value)
* @method static Builder<static>|Server whereOwnerId($value)
* @method static Builder<static>|Server whereSkipScripts($value)
* @method static Builder<static>|Server whereStartup($value)
* @method static Builder<static>|Server whereStatus($value)
* @method static Builder<static>|Server whereSwap($value)
* @method static Builder<static>|Server whereThreads($value)
* @method static Builder<static>|Server whereUpdatedAt($value)
* @method static Builder<static>|Server whereUuid($value)
* @method static Builder<static>|Server whereUuidShort($value)
*/
class Server extends Model implements HasAvatar, Validatable
{
use HasFactory;
use HasIcon;
use HasValidation;
use Notifiable;
@@ -144,22 +140,6 @@ class Server extends Model implements HasAvatar, Validatable
*/
public const RESOURCE_NAME = 'server';
/**
* Path to store server icons relative to storage path.
*/
public const ICON_STORAGE_PATH = 'icons/server';
/**
* Supported image formats: file extension => MIME type
*/
public const IMAGE_FORMATS = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
];
/**
* Default values when creating the model. We want to switch to disabling OOM killer
* on server instances unless the user specifies otherwise in the request.
@@ -533,20 +513,8 @@ class Server extends Model implements HasAvatar, Validatable
);
}
public function getIconAttribute(): ?string
{
foreach (array_keys(static::IMAGE_FORMATS) as $ext) {
$path = static::ICON_STORAGE_PATH . "/$this->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
return Storage::disk('public')->url($path);
}
}
return null;
}
public function getFilamentAvatarUrl(): ?string
{
return $this->icon ?? $this->egg->image;
return $this->icon ?? $this->egg->icon;
}
}

View File

@@ -4,30 +4,48 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Traits\HasValidation;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $server_id
* @property bool|null $successful
* @property int $old_node
* @property int $new_node
* @property int|null $old_allocation
* @property int|null $new_allocation
* @property array<int>|null $old_additional_allocations array of allocation.id's
* @property array<int>|null $new_additional_allocations array of allocation.id's
* @property bool|null $successful
* @property int[]|null $old_additional_allocations
* @property int[]|null $new_additional_allocations
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property bool $archived
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Server $server
* @property Node $oldNode
* @property Node $newNode
* @property-read Node|null $newNode
* @property-read Node|null $oldNode
* @property-read Server $server
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereArchived($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereNewAdditionalAllocations($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereNewAllocation($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereNewNode($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereOldAdditionalAllocations($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereOldAllocation($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereOldNode($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereServerId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereSuccessful($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerTransfer whereUpdatedAt($value)
*/
class ServerTransfer extends Model implements Validatable
{
use HasFactory;
use HasValidation;
/**

View File

@@ -15,8 +15,18 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property string $variable_value
* @property CarbonImmutable|null $created_at
* @property CarbonImmutable|null $updated_at
* @property EggVariable $variable
* @property Server $server
* @property-read Server $server
* @property-read EggVariable $variable
*
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerVariable newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerVariable newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerVariable query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerVariable whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerVariable whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerVariable whereServerId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerVariable whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerVariable whereVariableId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|ServerVariable whereVariableValue($value)
*/
class ServerVariable extends Model implements Validatable
{

View File

@@ -5,21 +5,33 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Enums\SubuserPermission;
use App\Traits\HasValidation;
use Carbon\Carbon;
use BackedEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $user_id
* @property int $server_id
* @property string[] $permissions
* @property Carbon $created_at
* @property Carbon $updated_at
* @property User $user
* @property Server $server
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string[]|null $permissions
* @property-read Server $server
* @property-read User $user
*
* @method static \Database\Factories\SubuserFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Subuser newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Subuser newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Subuser query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Subuser whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Subuser whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Subuser wherePermissions($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Subuser whereServerId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Subuser whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Subuser whereUserId($value)
*/
class Subuser extends Model implements Validatable
{
@@ -33,17 +45,21 @@ class Subuser extends Model implements Validatable
*/
public const RESOURCE_NAME = 'server_subuser';
/** @var array<string, array{name: string, hidden: ?bool, icon: ?string, permissions: string[]}> */
/** @var array<string, array{name: string, hidden: ?bool, icon: null|string|BackedEnum, translation_prefix: ?string, permissions: string[]}> */
protected static array $customPermissions = [];
/** @param string[] $permissions */
public static function registerCustomPermissions(string $name, array $permissions, ?string $icon = null, ?bool $hidden = null): void
public static function registerCustomPermissions(string $name, array $permissions, ?string $translationPrefix = null, null|string|BackedEnum $icon = null, ?bool $hidden = null): void
{
$customPermission = static::$customPermissions[$name] ?? [];
$customPermission['name'] = $name;
$customPermission['permissions'] = array_merge($customPermission['permissions'] ?? [], $permissions);
if (!is_null($translationPrefix)) {
$customPermission['translation_prefix'] = $translationPrefix;
}
if (!is_null($icon)) {
$customPermission['icon'] = $icon;
}
@@ -93,7 +109,7 @@ class Subuser extends Model implements Validatable
return $this->belongsTo(User::class);
}
/** @return array<array{name: string, hidden: bool, icon: string, permissions: string[]}> */
/** @return array<array{name: string, hidden: bool, icon: null|string|BackedEnum, translation_prefix: string, permissions: string[]}> */
public static function allPermissionData(): array
{
$allPermissions = [];
@@ -106,6 +122,7 @@ class Subuser extends Model implements Validatable
'hidden' => $subuserPermission->isHidden(),
'icon' => $subuserPermission->getIcon(),
'permissions' => array_merge($allPermissions[$group]['permissions'] ?? [], [$permission]),
'translation_prefix' => 'server/user.permissions',
];
}
@@ -119,6 +136,7 @@ class Subuser extends Model implements Validatable
'hidden' => $customPermission['hidden'] ?? $groupData['hidden'] ?? false,
'icon' => $customPermission['icon'] ?? $groupData['icon'],
'permissions' => array_unique(array_merge($groupData['permissions'] ?? [], $customPermission['permissions'])),
'translation_prefix' => $customPermission['translation_prefix'] ?? $groupData['translation_prefix'] ?? 'server/user.permissions',
];
$allPermissions[$name] = $groupData;

View File

@@ -6,11 +6,11 @@ use App\Contracts\Validatable;
use App\Extensions\Tasks\TaskSchemaInterface;
use App\Extensions\Tasks\TaskService;
use App\Traits\HasValidation;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Support\Carbon;
/**
* @property int $id
@@ -20,11 +20,26 @@ use Illuminate\Database\Eloquent\Relations\HasOneThrough;
* @property string $payload
* @property int $time_offset
* @property bool $is_queued
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property bool $continue_on_failure
* @property Carbon $created_at
* @property Carbon $updated_at
* @property Schedule $schedule
* @property Server $server
* @property-read Schedule $schedule
* @property-read Server|null $server
*
* @method static \Database\Factories\TaskFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereAction($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereContinueOnFailure($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereIsQueued($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task wherePayload($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereScheduleId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereSequenceId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereTimeOffset($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|Task whereUpdatedAt($value)
*/
class Task extends Model implements Validatable
{

View File

@@ -4,13 +4,14 @@ namespace App\Models\Traits;
use App\Extensions\Laravel\Sanctum\NewAccessToken;
use App\Models\ApiKey;
use App\Models\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Sanctum\Sanctum;
/**
* @mixin \App\Models\Model
* @mixin Model
*/
trait HasAccessTokens
{

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models\Traits;
use App\Models\Model;
use Exception;
use Illuminate\Support\Facades\Storage;
/**
* @mixin Model
*/
trait HasIcon
{
/**
* Supported icon formats: file extension => MIME type
*
* @var array<string, string>
*/
public static array $iconFormats = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'webp' => 'image/webp',
];
public static function getIconStoragePath(): string
{
return 'icons/' . static::RESOURCE_NAME;
}
public function getIconAttribute(): ?string
{
foreach (array_keys(static::$iconFormats) as $ext) {
$path = $this->getIconStoragePath() . "/$this->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
return Storage::disk('public')->url($path);
}
}
return null;
}
public function writeIcon(string $extension, string $data): string
{
$normalizedExtension = match (strtolower($extension)) {
'jpeg', 'jpg' => 'jpg',
'png' => 'png',
'webp' => 'webp',
default => null,
};
if (is_null($normalizedExtension)) {
throw new Exception(trans('admin/egg.import.unknown_extension', ['extension' => $extension]));
}
$fileName = static::getIconStoragePath() . "/$this->uuid.$normalizedExtension";
if (!Storage::disk('public')->put($fileName, $data)) {
throw new Exception(trans('admin/egg.import.could_not_write'));
}
foreach (['png', 'jpg', 'jpeg', 'webp', 'svg'] as $ext) {
if ($ext === $normalizedExtension) {
continue;
}
$path = static::getIconStoragePath() . "/$this->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
return $fileName;
}
}

View File

@@ -5,12 +5,12 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Enums\CustomizationKey;
use App\Enums\SubuserPermission;
use App\Events\User\Deleting;
use App\Exceptions\DisplayException;
use App\Extensions\Avatar\AvatarService;
use App\Models\Traits\HasAccessTokens;
use App\Traits\HasValidation;
use BackedEnum;
use Database\Factories\UserFactory;
use DateTimeZone;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
@@ -43,56 +43,75 @@ use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\In;
use ResourceBundle;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Traits\HasRoles;
/**
* App\Models\User.
*
* @property int $id
* @property string|null $external_id
* @property bool $is_managed_externally
* @property string $uuid
* @property string $username
* @property string $email
* @property string $password
* @property string|null $remember_token
* @property string $language
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property string $username
* @property string|null $external_id
* @property string $timezone
* @property string[]|null $oauth
* @property array<string, mixed>|null $oauth
* @property string|array<string, mixed>|null $customization
* @property string|null $mfa_app_secret
* @property string[]|null $mfa_app_recovery_codes
* @property bool $mfa_email_enabled
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property \Illuminate\Database\Eloquent\Collection|ApiKey[] $apiKeys
* @property int|null $api_keys_count
* @property DatabaseNotificationCollection|DatabaseNotification[] $notifications
* @property int|null $notifications_count
* @property \Illuminate\Database\Eloquent\Collection|Server[] $servers
* @property int|null $servers_count
* @property \Illuminate\Database\Eloquent\Collection|UserSSHKey[] $sshKeys
* @property int|null $ssh_keys_count
* @property \Illuminate\Database\Eloquent\Collection|ApiKey[] $tokens
* @property int|null $tokens_count
* @property \Illuminate\Database\Eloquent\Collection|Role[] $roles
* @property int|null $roles_count
* @property string|array<string, mixed>|null $customization
* @property bool $is_managed_externally
* @property-read \Illuminate\Database\Eloquent\Collection<int, ActivityLog> $activity
* @property-read int|null $activity_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, ApiKey> $apiKeys
* @property-read int|null $api_keys_count
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
* @property-read int|null $notifications_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, Permission> $permissions
* @property-read int|null $permissions_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, Role> $roles
* @property-read int|null $roles_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, Server> $servers
* @property-read int|null $servers_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, UserSSHKey> $sshKeys
* @property-read int|null $ssh_keys_count
* @property-read \Illuminate\Database\Eloquent\Collection<int, Server> $subServers
* @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, ApiKey> $tokens
* @property-read int|null $tokens_count
*
* @method static UserFactory factory(...$parameters)
* @method static Builder|User newModelQuery()
* @method static Builder|User newQuery()
* @method static Builder|User query()
* @method static Builder|User whereCreatedAt($value)
* @method static Builder|User whereEmail($value)
* @method static Builder|User whereExternalId($value)
* @method static Builder|User whereId($value)
* @method static Builder|User whereLanguage($value)
* @method static Builder|User whereTimezone($value)
* @method static Builder|User wherePassword($value)
* @method static Builder|User whereRememberToken($value)
* @method static Builder|User whereUpdatedAt($value)
* @method static Builder|User whereUsername($value)
* @method static Builder|User whereUuid($value)
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
* @method static Builder<static>|User newModelQuery()
* @method static Builder<static>|User newQuery()
* @method static Builder<static>|User permission($permissions, $without = false)
* @method static Builder<static>|User query()
* @method static Builder<static>|User role($roles, $guard = null, $without = false)
* @method static Builder<static>|User whereCreatedAt($value)
* @method static Builder<static>|User whereCustomization($value)
* @method static Builder<static>|User whereEmail($value)
* @method static Builder<static>|User whereExternalId($value)
* @method static Builder<static>|User whereId($value)
* @method static Builder<static>|User whereIsManagedExternally($value)
* @method static Builder<static>|User whereLanguage($value)
* @method static Builder<static>|User whereMfaAppRecoveryCodes($value)
* @method static Builder<static>|User whereMfaAppSecret($value)
* @method static Builder<static>|User whereMfaEmailEnabled($value)
* @method static Builder<static>|User whereOauth($value)
* @method static Builder<static>|User wherePassword($value)
* @method static Builder<static>|User whereRememberToken($value)
* @method static Builder<static>|User whereTimezone($value)
* @method static Builder<static>|User whereUpdatedAt($value)
* @method static Builder<static>|User whereUsername($value)
* @method static Builder<static>|User whereUuid($value)
* @method static Builder<static>|User withoutPermission($permissions)
* @method static Builder<static>|User withoutRole($roles, $guard = null)
*/
class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasAvatar, HasEmailAuthentication, HasName, HasTenants, Validatable
{
@@ -183,6 +202,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'is_managed_externally' => 'boolean',
'mfa_app_secret' => 'encrypted',
'mfa_app_recovery_codes' => 'encrypted:array',
'mfa_email_enabled' => 'boolean',
'oauth' => 'array',
'customization' => 'array',
];
@@ -206,6 +226,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
throw_if($user->servers()->count() > 0, new DisplayException(trans('exceptions.users.has_servers')));
throw_if(request()->user()?->id === $user->id, new DisplayException(trans('exceptions.users.is_self')));
event(new Deleting($user));
});
}

View File

@@ -3,7 +3,6 @@
namespace App\Models;
use App\Traits\HasValidation;
use Database\Factories\UserSSHKeyFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -22,23 +21,23 @@ use Illuminate\Support\Carbon;
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $deleted_at
* @property User $user
* @property-read User $user
*
* @method static Builder|UserSSHKey newModelQuery()
* @method static Builder|UserSSHKey newQuery()
* @method static \Illuminate\Database\Query\Builder|UserSSHKey onlyTrashed()
* @method static Builder|UserSSHKey query()
* @method static Builder|UserSSHKey whereCreatedAt($value)
* @method static Builder|UserSSHKey whereDeletedAt($value)
* @method static Builder|UserSSHKey whereFingerprint($value)
* @method static Builder|UserSSHKey whereId($value)
* @method static Builder|UserSSHKey whereName($value)
* @method static Builder|UserSSHKey wherePublicKey($value)
* @method static Builder|UserSSHKey whereUpdatedAt($value)
* @method static Builder|UserSSHKey whereUserId($value)
* @method static \Illuminate\Database\Query\Builder|UserSSHKey withTrashed()
* @method static \Illuminate\Database\Query\Builder|UserSSHKey withoutTrashed()
* @method static UserSSHKeyFactory factory(...$parameters)
* @method static \Database\Factories\UserSSHKeyFactory factory($count = null, $state = [])
* @method static Builder<static>|UserSSHKey newModelQuery()
* @method static Builder<static>|UserSSHKey newQuery()
* @method static Builder<static>|UserSSHKey onlyTrashed()
* @method static Builder<static>|UserSSHKey query()
* @method static Builder<static>|UserSSHKey whereCreatedAt($value)
* @method static Builder<static>|UserSSHKey whereDeletedAt($value)
* @method static Builder<static>|UserSSHKey whereFingerprint($value)
* @method static Builder<static>|UserSSHKey whereId($value)
* @method static Builder<static>|UserSSHKey whereName($value)
* @method static Builder<static>|UserSSHKey wherePublicKey($value)
* @method static Builder<static>|UserSSHKey whereUpdatedAt($value)
* @method static Builder<static>|UserSSHKey whereUserId($value)
* @method static Builder<static>|UserSSHKey withTrashed(bool $withTrashed = true)
* @method static Builder<static>|UserSSHKey withoutTrashed()
*/
class UserSSHKey extends Model
{

View File

@@ -2,19 +2,33 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\MassPrunable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* @property int $id
* @property int $webhook_configuration_id
* @property string $event
* @property string $endpoint
* @property \Illuminate\Support\Carbon|null $successful_at
* @property Carbon|null $successful_at
* @property array<array-key, mixed> $payload
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @method static Builder<static>|Webhook newModelQuery()
* @method static Builder<static>|Webhook newQuery()
* @method static Builder<static>|Webhook query()
* @method static Builder<static>|Webhook whereCreatedAt($value)
* @method static Builder<static>|Webhook whereEndpoint($value)
* @method static Builder<static>|Webhook whereEvent($value)
* @method static Builder<static>|Webhook whereId($value)
* @method static Builder<static>|Webhook wherePayload($value)
* @method static Builder<static>|Webhook whereSuccessfulAt($value)
* @method static Builder<static>|Webhook whereUpdatedAt($value)
* @method static Builder<static>|Webhook whereWebhookConfigurationId($value)
*/
class Webhook extends Model
{

View File

@@ -14,15 +14,36 @@ use Illuminate\Support\Facades\File;
use Livewire\Features\SupportEvents\HandlesEvents;
/**
* @property string|array<string, mixed>|null $payload
* @property int $id
* @property string $endpoint
* @property string $description
* @property string[] $events
* @property WebhookType|string|null $type
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $deleted_at
* @property array<string, string>|null $headers
* @property WebhookType|null $type
* @property string|array<array-key, mixed>|null $payload
* @property array<array-key, mixed>|null $headers
* @property-read \Illuminate\Database\Eloquent\Collection<int, Webhook> $webhooks
* @property-read int|null $webhooks_count
*
* @method static \Database\Factories\WebhookConfigurationFactory factory($count = null, $state = [])
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration onlyTrashed()
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration query()
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration whereDeletedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration whereEndpoint($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration whereEvents($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration whereHeaders($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration wherePayload($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration whereType($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration withTrashed(bool $withTrashed = true)
* @method static \Illuminate\Database\Eloquent\Builder<static>|WebhookConfiguration withoutTrashed()
*/
class WebhookConfiguration extends Model
{

View File

@@ -10,6 +10,7 @@ use App\Checks\NodeVersionsCheck;
use App\Checks\PanelVersionCheck;
use App\Checks\ScheduleCheck;
use App\Checks\UsedDiskSpaceCheck;
use App\Http\Responses\LoginResponse;
use App\Models\Allocation;
use App\Models\ApiKey;
use App\Models\Backup;
@@ -126,7 +127,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
$this->app->bind(LoginResponseContract::class, \App\Http\Responses\LoginResponse::class);
$this->app->bind(LoginResponseContract::class, LoginResponse::class);
Scramble::ignoreDefaultRoutes();

View File

@@ -7,6 +7,7 @@ use App\Filament\Pages\Auth\EditProfile;
use App\Filament\Pages\Auth\Login;
use App\Http\Middleware\LanguageMiddleware;
use App\Http\Middleware\RequireTwoFactorAuthentication;
use App\Http\Middleware\SetSecurityHeaders;
use Filament\Actions\Action;
use Filament\Auth\MultiFactor\App\AppAuthentication;
use Filament\Auth\MultiFactor\Email\EmailAuthentication;
@@ -70,6 +71,7 @@ abstract class PanelProvider extends BasePanelProvider
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
LanguageMiddleware::class,
SetSecurityHeaders::class,
])
->authMiddleware([
Authenticate::class,

View File

@@ -147,7 +147,7 @@ class DaemonServerRepository extends DaemonRepository
}
/**
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server.
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server (or all servers of a node).
*
* @throws ConnectionException
*/
@@ -156,7 +156,7 @@ class DaemonServerRepository extends DaemonRepository
$this->getHttpClient()->post('/api/deauthorize-user', [
'json' => [
'user' => $user,
'servers' => [$this->server->uuid],
'servers' => $this->server ? [$this->server->uuid] : [],
],
]);
}

View File

@@ -56,6 +56,7 @@ class FindAssignableAllocationService
// those belonging to the current server (making it impossible to find unassigned ones)
/** @var Allocation|null $allocation */
$allocation = Allocation::withoutGlobalScopes()
->lockForUpdate()
->where('node_id', $server->node_id)
->when($server->allocation, function ($query) use ($server) {
$query->where('ip', $server->allocation->ip);
@@ -125,6 +126,7 @@ class FindAssignableAllocationService
/** @var Allocation $allocation */
$allocation = Allocation::withoutGlobalScopes()
->lockForUpdate()
->where('node_id', $server->node_id)
->where('ip', $server->allocation->ip)
->where('port', $port)

View File

@@ -21,7 +21,7 @@ class DatabaseManagementService
* The regex used to validate that the database name passed through to the function is
* in the expected format.
*
* @see \App\Services\Databases\DatabaseManagementService::generateUniqueDatabaseName()
* @see DatabaseManagementService::generateUniqueDatabaseName()
*/
private const MATCH_NAME_REGEX = '/^(s[\d]+_)(.*)$/';
@@ -136,7 +136,7 @@ class DatabaseManagementService
/**
* Updates a password for a given database.
*
* @throws \Exception
* @throws Exception
*/
public function rotatePassword(Database $database): void
{

View File

@@ -18,7 +18,7 @@ class EggExporterService
public function handle(int $egg, EggFormat $format): string
{
$egg = Egg::with(['scriptFrom', 'configFrom', 'variables'])->findOrFail($egg);
$imageBase64 = $this->getEggImageAsBase64($egg);
$iconBase64 = $this->getEggIconAsBase64($egg);
$struct = [
'_comment' => 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL',
@@ -31,7 +31,7 @@ class EggExporterService
'author' => $egg->author,
'uuid' => $egg->uuid,
'description' => $egg->description,
'image' => $imageBase64,
'icon' => $iconBase64,
'tags' => $egg->tags,
'features' => $egg->features,
'docker_images' => $egg->docker_images,
@@ -63,16 +63,14 @@ class EggExporterService
}
/**
* Get the egg image as base64 for export.
* Get the egg icon as base64 for export.
*/
private function getEggImageAsBase64(Egg $egg): ?string
private function getEggIconAsBase64(Egg $egg): ?string
{
foreach (array_keys(Egg::IMAGE_FORMATS) as $ext) {
$path = Egg::ICON_STORAGE_PATH . "/$egg->uuid.$ext";
foreach (Egg::$iconFormats as $ext => $mimeType) {
$path = Egg::getIconStoragePath() . "/$egg->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
$mimeType = Egg::IMAGE_FORMATS[$ext];
return 'data:' . $mimeType . ';base64,' . base64_encode(Storage::disk('public')->get($path));
}
}

View File

@@ -6,12 +6,12 @@ use App\Enums\EggFormat;
use App\Exceptions\Service\InvalidFileUploadException;
use App\Models\Egg;
use App\Models\EggVariable;
use Exception;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use JsonException;
use Ramsey\Uuid\Uuid;
use stdClass;
@@ -223,6 +223,11 @@ class EggImporterService
}
}
if (!empty($parsed['image']) && str_starts_with($parsed['image'], 'data:')) {
$parsed['icon'] = $parsed['image'];
unset($parsed['image']);
}
return $parsed;
}
@@ -231,9 +236,9 @@ class EggImporterService
*/
protected function fillFromParsed(Egg $model, array $parsed): Egg
{
// Handle image data if present
if (!empty($parsed['image']) && str_starts_with($parsed['image'], 'data:')) {
$this->saveEggImageFromBase64($parsed['image'], $model);
// Handle icon data if present
if (!empty($parsed['icon']) && str_starts_with($parsed['icon'], 'data:')) {
$this->saveEggIconFromBase64($parsed['icon'], $model);
}
return $model->forceFill([
@@ -256,28 +261,24 @@ class EggImporterService
}
/**
* Save an egg image from base64 data to a file.
* Save an egg icon from base64 data to a file.
*/
private function saveEggImageFromBase64(string $base64String, Egg $egg): void
private function saveEggIconFromBase64(string $base64String, Egg $egg): void
{
if (!preg_match('/^data:image\/([\w+]+);base64,(.+)$/', $base64String, $matches)) {
return;
}
$extension = $matches[1];
$data = base64_decode($matches[2]);
try {
$extension = strtolower($matches[1]);
$data = base64_decode($matches[2]);
if (!$data) {
return;
if ($data) {
$egg->writeIcon($extension, $data);
}
} catch (Exception $exception) {
report($exception);
}
$normalizedExtension = match ($extension) {
'svg+xml' => 'svg',
'jpeg' => 'jpg',
default => $extension,
};
Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data);
}
/**

View File

@@ -348,8 +348,7 @@ class PluginService
$this->manageComposerPackages(oldPackages: $pluginPackages);
// This throws an error when not called with qualifier
foreach (\Filament\Facades\Filament::getPanels() as $panel) {
foreach (Filament::getPanels() as $panel) {
$panel->clearCachedComponents();
}
}

View File

@@ -40,6 +40,7 @@ class NodeUpdateService
/** @var Node $updated */
$updated = $node->replicate();
$updated->exists = true;
$data = array_merge($data, ['created_at' => $node->created_at, 'updated_at' => now()]);
$updated->forceFill($data)->save();
try {
$node->fqdn = $updated->fqdn;

View File

@@ -2,11 +2,10 @@
namespace App\Services\Servers;
use App\Jobs\RevokeSftpAccessJob;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Traits\Services\ReturnsUpdatedModels;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Arr;
use Throwable;
@@ -17,7 +16,7 @@ class DetailsModificationService
/**
* DetailsModificationService constructor.
*/
public function __construct(private ConnectionInterface $connection, private DaemonServerRepository $serverRepository) {}
public function __construct(private ConnectionInterface $connection) {}
/**
* Update the details for a single server instance.
@@ -34,7 +33,7 @@ class DetailsModificationService
public function handle(Server $server, array $data): Server
{
return $this->connection->transaction(function () use ($data, $server) {
$owner = $server->owner_id;
$oldOwner = $server->user;
$server->forceFill([
'external_id' => Arr::get($data, 'external_id'),
@@ -46,14 +45,8 @@ class DetailsModificationService
// If the owner_id value is changed we need to revoke any tokens that exist for the server
// on the daemon instance so that the old owner no longer has any permission to access the
// websockets.
if ($server->owner_id !== $owner) {
try {
$this->serverRepository->setServer($server)->deauthorize($server->user->uuid);
} catch (ConnectionException) {
// Do nothing. A failure here is not ideal, but it is likely to be caused by daemon
// being offline, or in an entirely broken state. Remember, these tokens reset every
// few minutes by default, we're just trying to help it along a little quicker.
}
if ($server->owner_id !== $oldOwner->id) {
RevokeSftpAccessJob::dispatch($oldOwner->uuid, $server);
}
return $server;

View File

@@ -4,17 +4,12 @@ namespace App\Services\Subusers;
use App\Events\Server\SubUserRemoved;
use App\Facades\Activity;
use App\Jobs\RevokeSftpAccessJob;
use App\Models\Server;
use App\Models\Subuser;
use App\Repositories\Daemon\DaemonServerRepository;
use Illuminate\Http\Client\ConnectionException;
class SubuserDeletionService
{
public function __construct(
private DaemonServerRepository $serverRepository,
) {}
public function handle(Subuser $subuser, Server $server): void
{
$log = Activity::event('server:subuser.delete')
@@ -27,14 +22,7 @@ class SubuserDeletionService
event(new SubUserRemoved($subuser->server, $subuser->user));
try {
$this->serverRepository->setServer($server)->deauthorize($subuser->user->uuid);
} catch (ConnectionException $exception) {
// Don't block this request if we can't connect to the daemon instance.
logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);
$instance->property('revoked', false);
}
RevokeSftpAccessJob::dispatch($subuser->user->uuid, $server);
});
}
}

View File

@@ -4,17 +4,12 @@ namespace App\Services\Subusers;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Jobs\RevokeSftpAccessJob;
use App\Models\Server;
use App\Models\Subuser;
use App\Repositories\Daemon\DaemonServerRepository;
use Illuminate\Http\Client\ConnectionException;
class SubuserUpdateService
{
public function __construct(
private DaemonServerRepository $serverRepository,
) {}
/**
* @param string[] $permissions
*/
@@ -42,18 +37,10 @@ class SubuserUpdateService
// Only update the database and hit up the daemon instance to invalidate JTI's if the permissions
// have actually changed for the user.
if ($cleanedPermissions !== $current) {
$log->transaction(function ($instance) use ($subuser, $cleanedPermissions, $server) {
$log->transaction(function () use ($subuser, $cleanedPermissions, $server) {
$subuser->update(['permissions' => $cleanedPermissions]);
try {
$this->serverRepository->setServer($server)->deauthorize($subuser->user->uuid);
} catch (ConnectionException $exception) {
// Don't block this request if we can't connect to the daemon instance. Chances are it is
// offline and the token will be invalid once daemon boots back.
logger()->warning($exception, ['user_id' => $subuser->user_id, 'server_id' => $server->id]);
$instance->property('revoked', false);
}
RevokeSftpAccessJob::dispatch($subuser->user->uuid, $server);
});
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Users;
use App\Events\User\PasswordChanged;
use App\Models\User;
use App\Traits\Services\HasUserLevels;
use Illuminate\Contracts\Hashing\Hasher;
@@ -30,6 +31,10 @@ class UserUpdateService
$user->forceFill($data)->saveOrFail();
if (isset($data['password'])) {
PasswordChanged::dispatch($user);
}
return $user->refresh();
}
}

View File

@@ -46,10 +46,10 @@ class EggTransformer extends BaseTransformer
'name' => $model->name,
'author' => $model->author,
'description' => $model->description,
'image' => $model->image,
'icon' => $model->icon,
'features' => $model->features,
'tags' => $model->tags,
'docker_image' => Arr::first($model->docker_images, default: ''), // docker_images, use startup_commands
'docker_image' => Arr::first($model->docker_images, default: ''), // deprecated, use docker_images
'docker_images' => $model->docker_images,
'config' => [
'files' => $files,

View File

@@ -1,5 +1,7 @@
<?php
use App\Models\User;
if (!function_exists('is_digit')) {
/**
* Deal with normal (and irritating) PHP behavior to determine if
@@ -130,8 +132,29 @@ if (!function_exists('encode_path')) {
}
}
if (!function_exists('convert_to_utf8')) {
/**
* Convert a string to UTF-8 from an unknown encoding
*/
function convert_to_utf8(string $contents): string
{
// Valid UTF-8 passes through unchanged
if (mb_check_encoding($contents, 'UTF-8')) {
return $contents;
}
// Only detect UTF-16 by BOM instead of mb_check_encoding('UTF-16') which can cause false positives
if (str_starts_with($contents, "\xFF\xFE") || str_starts_with($contents, "\xFE\xFF")) {
return mb_convert_encoding($contents, 'UTF-8', 'UTF-16');
}
// ISO-8859-1 serves as a universal fallback since any byte sequence is valid in it
return mb_convert_encoding($contents, 'UTF-8', 'ISO-8859-1');
}
}
if (!function_exists('user')) {
function user(): ?App\Models\User
function user(): ?User
{
return auth(config('auth.defaults.guard', 'web'))->user();
}

View File

@@ -1,8 +1,25 @@
<?php
use App\Console\Kernel;
use App\Exceptions\Handler;
use App\Http\Middleware\Activity\TrackAPIKey;
use App\Http\Middleware\Api\Application\AuthenticateApplicationUser;
use App\Http\Middleware\Api\AuthenticateIPAccess;
use App\Http\Middleware\Api\Client\RequireClientApiKey;
use App\Http\Middleware\Api\Daemon\DaemonAuthenticate;
use App\Http\Middleware\Api\IsValidJson;
use App\Http\Middleware\EnsureStatefulRequests;
use App\Http\Middleware\LanguageMiddleware;
use App\Http\Middleware\MaintenanceMiddleware;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\SetSecurityHeaders;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
return Application::configure(basePath: dirname(__DIR__))
->withProviders()
@@ -12,46 +29,49 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->redirectGuestsTo(fn () => route('filament.app.auth.login'));
$middleware->web(\App\Http\Middleware\LanguageMiddleware::class);
$middleware->web([
LanguageMiddleware::class,
SetSecurityHeaders::class,
]);
$middleware->api([
\App\Http\Middleware\EnsureStatefulRequests::class,
EnsureStatefulRequests::class,
'auth:sanctum',
\App\Http\Middleware\Api\IsValidJson::class,
\App\Http\Middleware\Activity\TrackAPIKey::class,
\App\Http\Middleware\Api\AuthenticateIPAccess::class,
IsValidJson::class,
TrackAPIKey::class,
AuthenticateIPAccess::class,
]);
$middleware->group('application-api', [
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\Api\Application\AuthenticateApplicationUser::class,
SubstituteBindings::class,
AuthenticateApplicationUser::class,
]);
$middleware->group('client-api', [
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\Api\Client\RequireClientApiKey::class,
SubstituteBindings::class,
RequireClientApiKey::class,
]);
$middleware->group('daemon', [
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\Api\Daemon\DaemonAuthenticate::class,
SubstituteBindings::class,
DaemonAuthenticate::class,
]);
$middleware->replaceInGroup('web', \Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, \App\Http\Middleware\VerifyCsrfToken::class);
$middleware->replaceInGroup('web', ValidateCsrfToken::class, VerifyCsrfToken::class);
$middleware->alias([
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'node.maintenance' => \App\Http\Middleware\MaintenanceMiddleware::class,
'bindings' => SubstituteBindings::class,
'guest' => RedirectIfAuthenticated::class,
'node.maintenance' => MaintenanceMiddleware::class,
]);
$middleware->priority([
\Illuminate\Routing\Middleware\SubstituteBindings::class,
SubstituteBindings::class,
]);
})
->withSingletons([
\Illuminate\Contracts\Console\Kernel::class => \App\Console\Kernel::class,
\Illuminate\Contracts\Debug\ExceptionHandler::class => \App\Exceptions\Handler::class,
Illuminate\Contracts\Console\Kernel::class => Kernel::class,
ExceptionHandler::class => Handler::class,
])
->withExceptions(function (Exceptions $exceptions) {})
->create();

View File

@@ -1,19 +1,35 @@
<?php
use App\Providers\ActivityLogServiceProvider;
use App\Providers\AppServiceProvider;
use App\Providers\BackupsServiceProvider;
use App\Providers\EventServiceProvider;
use App\Providers\Extensions\AvatarServiceProvider;
use App\Providers\Extensions\CaptchaServiceProvider;
use App\Providers\Extensions\FeatureServiceProvider;
use App\Providers\Extensions\OAuthServiceProvider;
use App\Providers\Extensions\TaskServiceProvider;
use App\Providers\Filament\AdminPanelProvider;
use App\Providers\Filament\AppPanelProvider;
use App\Providers\Filament\FilamentServiceProvider;
use App\Providers\Filament\ServerPanelProvider;
use App\Providers\RouteServiceProvider;
use SocialiteProviders\Manager\ServiceProvider;
return [
App\Providers\ActivityLogServiceProvider::class,
App\Providers\AppServiceProvider::class,
App\Providers\BackupsServiceProvider::class,
App\Providers\EventServiceProvider::class,
App\Providers\Extensions\AvatarServiceProvider::class,
App\Providers\Extensions\CaptchaServiceProvider::class,
App\Providers\Extensions\FeatureServiceProvider::class,
App\Providers\Extensions\OAuthServiceProvider::class,
App\Providers\Extensions\TaskServiceProvider::class,
App\Providers\Filament\FilamentServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\Filament\AppPanelProvider::class,
App\Providers\Filament\ServerPanelProvider::class,
App\Providers\RouteServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
ActivityLogServiceProvider::class,
AppServiceProvider::class,
BackupsServiceProvider::class,
EventServiceProvider::class,
AvatarServiceProvider::class,
CaptchaServiceProvider::class,
FeatureServiceProvider::class,
OAuthServiceProvider::class,
TaskServiceProvider::class,
FilamentServiceProvider::class,
AdminPanelProvider::class,
AppPanelProvider::class,
ServerPanelProvider::class,
RouteServiceProvider::class,
ServiceProvider::class,
];

View File

@@ -9,51 +9,51 @@
"ext-mbstring": "*",
"ext-pdo": "*",
"ext-zip": "*",
"aws/aws-sdk-php": "^3.369",
"aws/aws-sdk-php": "^3.373",
"calebporzio/sushi": "^2.5",
"dedoc/scramble": "^0.13",
"filament/filament": "^4.5",
"filament/filament": "^4.8",
"gboquizosanchez/filament-log-viewer": "^2.2",
"guzzlehttp/guzzle": "^7.10",
"laravel/framework": "^12.52",
"laravel/framework": "^12.56",
"laravel/helpers": "^1.8",
"laravel/sanctum": "^4.3",
"laravel/socialite": "^5.24",
"laravel/tinker": "^2.10.1",
"laravel/socialite": "^5.25",
"laravel/tinker": "^2.11",
"laravel/ui": "^4.6",
"lcobucci/jwt": "^5.6",
"league/flysystem-aws-s3-v3": "^3.31",
"league/flysystem-aws-s3-v3": "^3.32",
"league/flysystem-memory": "^3.31",
"phiki/phiki": "^2.0",
"phpseclib/phpseclib": "~3.0.18",
"predis/predis": "^2.3",
"s1lentium/iptools": "~1.2.0",
"secondnetwork/blade-tabler-icons": "^3.26",
"socialiteproviders/authentik": "^5.2",
"phiki/phiki": "^2.1",
"phpseclib/phpseclib": "^3.0.49",
"predis/predis": "^2.4",
"s1lentium/iptools": "^1.2",
"secondnetwork/blade-tabler-icons": "^3.40",
"socialiteproviders/authentik": "^5.3",
"socialiteproviders/discord": "^4.2",
"socialiteproviders/steam": "^4.3",
"spatie/laravel-data": "^4.19",
"spatie/laravel-fractal": "^6.3",
"spatie/laravel-health": "^1.37",
"spatie/laravel-permission": "^6.24",
"spatie/laravel-data": "^4.20",
"spatie/laravel-fractal": "^6.4",
"spatie/laravel-health": "^1.39",
"spatie/laravel-permission": "^6.25",
"spatie/laravel-query-builder": "^6.4",
"spatie/temporary-directory": "^2.3",
"symfony/http-client": "^7.2",
"symfony/mailgun-mailer": "^7.2",
"symfony/postmark-mailer": "^7.2",
"symfony/yaml": "^7.2",
"webmozart/assert": "~1.11.0"
"symfony/http-client": "^7.4",
"symfony/mailgun-mailer": "^7.4",
"symfony/postmark-mailer": "^7.4",
"symfony/yaml": "^7.4",
"webmozart/assert": "^1.12"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "^3.6",
"fakerphp/faker": "^1.23.1",
"larastan/larastan": "^3.4",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.15.3",
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6.11",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.7",
"barryvdh/laravel-ide-helper": "^3.7",
"fakerphp/faker": "^1.24",
"larastan/larastan": "^3.9",
"laravel/pail": "^1.2.6",
"laravel/pint": "^1.28",
"laravel/sail": "^1.53",
"mockery/mockery": "^1.6.12",
"nunomaduro/collision": "^8.9",
"pestphp/pest": "^3.8",
"pestphp/pest-plugin-faker": "^3.0",
"pestphp/pest-plugin-livewire": "^3.0"
},

1270
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
<?php
use Illuminate\Support\Str;
use Pdo\Mysql;
$database = env('DB_DATABASE', 'database.sqlite');
$databasePath = database_path($database);
@@ -65,7 +66,7 @@ return [
'strict' => env('DB_STRICT_MODE', false),
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@@ -85,7 +86,7 @@ return [
'strict' => env('DB_STRICT_MODE', false),
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],

View File

@@ -1,5 +1,7 @@
<?php
use League\Fractal\Serializer\JsonApiSerializer;
return [
/*
|--------------------------------------------------------------------------
@@ -12,7 +14,7 @@ return [
|
*/
'default_serializer' => League\Fractal\Serializer\JsonApiSerializer::class,
'default_serializer' => JsonApiSerializer::class,
/*
|--------------------------------------------------------------------------

View File

@@ -1,5 +1,9 @@
<?php
use Spatie\Health\Notifications\CheckFailedNotification;
use Spatie\Health\Notifications\Notifiable;
use Spatie\Health\ResultStores\CacheHealthResultStore;
return [
/*
* A result store is responsible for saving the results of the checks. The
@@ -15,7 +19,7 @@ return [
],
*/
Spatie\Health\ResultStores\CacheHealthResultStore::class => [
CacheHealthResultStore::class => [
'store' => 'file',
],
@@ -40,14 +44,14 @@ return [
'enabled' => false,
'notifications' => [
Spatie\Health\Notifications\CheckFailedNotification::class => ['mail'],
CheckFailedNotification::class => ['mail'],
],
/*
* Here you can specify the notifiable to which the notifications should be sent. The default
* notifiable will use the variables specified in this config file.
*/
'notifiable' => Spatie\Health\Notifications\Notifiable::class,
'notifiable' => Notifiable::class,
/*
* When checks start failing, you could potentially end up getting

View File

@@ -13,9 +13,9 @@ return [
*/
'rate_limit' => [
'client_period' => 1,
'client' => env('APP_API_CLIENT_RATELIMIT', 120),
'client' => env('APP_API_CLIENT_RATELIMIT', 256),
'application_period' => 1,
'application' => env('APP_API_APPLICATION_RATELIMIT', 240),
'application' => env('APP_API_APPLICATION_RATELIMIT', 256),
],
];

View File

@@ -18,6 +18,7 @@ return [
'cdn' => [
'cache_time' => 60,
'egg_index_url' => env('PANEL_EGG_INDEX_URL', 'https://raw.githubusercontent.com/pelican-eggs/pelican-eggs.github.io/refs/heads/main/content/pelican.json'),
],
'client_features' => [

Some files were not shown because too many files have changed in this diff Show More