Merge branch 'main' into filament-v4

This commit is contained in:
notCharles 2025-04-29 18:21:56 -04:00
commit 1ba9a1dab3
36 changed files with 1431 additions and 191 deletions

3
.gitignore vendored
View File

@ -24,8 +24,7 @@ yarn-error.log
/.vscode
public/assets/manifest.json
/database/*.sqlite
/database/*.sqlite-journal
/database/*.sqlite*
filament-monaco-editor/
_ide_helper*
/.phpstorm.meta.php

View File

@ -1,16 +1,9 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Production Dockerfile
# For those who want to build this Dockerfile themselves, uncomment lines 6-12 and replace "localhost:5000/base-php:$TARGETARCH" on lines 17 and 67 with "base".
# FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine as base
# ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
# RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
# RUN rm /usr/local/bin/install-php-extensions
##
# If you want to build this locally you want to run `docker build -f Dockerfile.dev`
##
# ================================
# Stage 1-1: Composer Install
@ -82,15 +75,14 @@ RUN chown root:www-data ./ \
&& chmod 750 ./ \
# Files should not have execute set, but directories need it
&& find ./ -type d -exec chmod 750 {} \; \
# Symlink to env/database path, as www-data won't be able to write to webroot
# Create necessary directories
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& mkdir -p /pelican-data/storage \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage /var/www/html/storage/app/public/avatars \
# Create necessary directories
&& mkdir -p /pelican-data /var/run/supervisord /etc/supercronic \
# Finally allow www-data write permissions where necessary
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
# Allow www-data write permissions where necessary
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord

111
Dockerfile.dev Normal file
View File

@ -0,0 +1,111 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Development Dockerfile
FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine AS base
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
RUN rm /usr/local/bin/install-php-extensions
# ================================
# Stage 1-1: Composer Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH base AS composer
WORKDIR /build
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Copy bare minimum to install Composer dependencies
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts
# ================================
# Stage 1-2: Yarn Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
WORKDIR /build
# Copy bare minimum to install Yarn dependencies
COPY package.json yarn.lock ./
RUN yarn config set network-timeout 300000 \
&& yarn install --frozen-lockfile
# ================================
# Stage 2-1: Composer Optimize
# ================================
FROM --platform=$TARGETOS/$TARGETARCH composer AS composerbuild
# Copy full code to optimize autoload
COPY --exclude=Caddyfile --exclude=docker/ . ./
RUN composer dump-autoload --optimize
# ================================
# Stage 2-2: Build Frontend Assets
# ================================
FROM --platform=$TARGETOS/$TARGETARCH yarn AS yarnbuild
WORKDIR /build
# Copy full code
COPY --exclude=Caddyfile --exclude=docker/ . ./
COPY --from=composer /build .
RUN yarn run build
# ================================
# Stage 5: Build Final Application Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH base AS final
WORKDIR /var/www/html
# Install additional required libraries
RUN apk update && apk add --no-cache \
caddy ca-certificates supervisor supercronic
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
# Set permissions
# First ensure all files are owned by root and restrict www-data to read access
RUN chown root:www-data ./ \
&& chmod 750 ./ \
# Files should not have execute set, but directories need it
&& find ./ -type d -exec chmod 750 {} \; \
# Create necessary directories
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
# Allow www-data write permissions where necessary
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile
# Add Laravel scheduler to crontab
COPY docker/crontab /etc/supercronic/crontab
COPY docker/entrypoint.sh ./docker/entrypoint.sh
HEALTHCHECK --interval=5m --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/up || exit 1
EXPOSE 80 443
VOLUME /pelican-data
USER www-data
ENTRYPOINT [ "/bin/ash", "docker/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View File

@ -14,4 +14,24 @@ enum RolePermissionModels: string
case Server = 'server';
case User = 'user';
case Webhook = 'webhook';
public function viewAny(): string
{
return RolePermissionPrefixes::ViewAny->value . ' ' . $this->value;
}
public function view(): string
{
return RolePermissionPrefixes::View->value . ' ' . $this->value;
}
public function create(): string
{
return RolePermissionPrefixes::Create->value . ' ' . $this->value;
}
public function update(): string
{
return RolePermissionPrefixes::Update->value . ' ' . $this->value;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
use Illuminate\Foundation\Application;
abstract class FeatureProvider
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
/**
* @param string[] $id
* @return self|static[]
*/
public static function getProviders(string|array|null $id = null): array|self
{
if (is_array($id)) {
return array_intersect_key(static::$providers, array_flip($id));
}
return $id ? static::$providers[$id] : static::$providers;
}
protected function __construct(protected Application $app)
{
if (array_key_exists($this->getId(), static::$providers)) {
if (!$this->app->runningUnitTests()) {
logger()->warning("Tried to create duplicate Feature provider with id '{$this->getId()}'");
}
return;
}
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
/**
* A matching subset string (case-insensitive) from the console output
*
* @return array<string>
*/
abstract public function getListeners(): array;
abstract public function getAction(): Action;
}

View File

@ -0,0 +1,122 @@
<?php
namespace App\Extensions\Features;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Repositories\Daemon\DaemonPowerRepository;
use Closure;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Validator;
class GSLToken extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'gsl token expired',
'account not found',
];
}
public function getId(): string
{
return 'gsltoken';
}
public function getAction(): Action
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var ServerVariable $serverVariable */
$serverVariable = $server->serverVariables()->where('env_variable', 'STEAM_ACC')->first();
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Invalid GSL token')
->modalDescription('It seems like your Gameserver Login Token (GSL token) is invalid or has expired.')
->modalSubmitActionLabel('Update GSL Token')
->disabledForm(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_UPDATE, $server))
->form([
Placeholder::make('java')
->label('You can either <x-filament::link href="https://steamcommunity.com/dev/managegameservers" target="_blank">generate a new one</x-filament::link> and enter it below or leave the field blank to remove it
completely.'),
TextInput::make('gsltoken')
->label('GSL Token')
->rules([
fn (): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) {
$validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $serverVariable->variable->rules,
]);
if ($validator->fails()) {
$message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name);
$fail($message);
}
},
])
->hintIcon('tabler-code')
->label(fn () => $serverVariable->variable->name)
->hintIconTooltip(fn () => implode('|', $serverVariable->variable->rules))
->prefix(fn () => '{{' . $serverVariable->variable->env_variable . '}}')
->helperText(fn () => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description),
])
->action(function (array $data, DaemonPowerRepository $powerRepository) use ($server, $serverVariable) {
/** @var Server $server */
$server = Filament::getTenant();
try {
$new = $data['gsltoken'] ?? '';
$original = $serverVariable->variable_value;
$serverVariable->update([
'variable_value' => $new,
]);
if ($original !== $new) {
Activity::event('server:startup.edit')
->property([
'variable' => $serverVariable->variable->env_variable,
'old' => $original,
'new' => $new,
])
->log();
}
$powerRepository->setServer($server)->send('restart');
Notification::make()
->title('GSL Token updated')
->body('Restart the server to use the new token.')
->success()
->send();
} catch (\Exception $e) {
Notification::make()
->title('Error')
->body($e->getMessage())
->danger()
->send();
}
});
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Extensions\Features;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonPowerRepository;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Notifications\Notification;
use Illuminate\Foundation\Application;
class JavaVersion extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'java.lang.UnsupportedClassVersionError',
'minecraft 1.17 requires running the server with java 16 or above',
'minecraft 1.18 requires running the server with java 17 or above',
'unsupported major.minor version',
'has been compiled by a more recent version of the java runtime',
];
}
public function getId(): string
{
return 'java_version';
}
public function getAction(): Action
{
/** @var Server $server */
$server = Filament::getTenant();
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Unsupported Java Version')
->modalDescription('This server is currently running an unsupported version of Java and cannot be started.')
->modalSubmitActionLabel('Update Docker Image')
->disabledForm(fn () => !auth()->user()->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->form([
Placeholder::make('java')
->label('Please select a supported version from the list below to continue starting the server.'),
Select::make('image')
->label('Docker Image')
->disabled(fn () => !in_array($server->image, $server->egg->docker_images))
->options(fn () => collect($server->egg->docker_images)->mapWithKeys(fn ($key, $value) => [$key => $value]))
->selectablePlaceholder(false)
->default(fn () => $server->image)
->notIn(fn () => $server->image)
->required()
->preload()
->native(false),
])
->action(function (array $data, DaemonPowerRepository $powerRepository) use ($server) {
try {
$new = $data['image'];
$original = $server->image;
$server->forceFill(['image' => $new])->saveOrFail();
if ($original !== $server->image) {
Activity::event('server:startup.image')
->property(['old' => $original, 'new' => $new])
->log();
}
$powerRepository->setServer($server)->send('restart');
Notification::make()
->title('Docker image updated')
->body('Restart the server to use the new image.')
->success()
->send();
} catch (\Exception $e) {
Notification::make()
->title('Error')
->body($e->getMessage())
->danger()
->send();
}
});
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Extensions\Features;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Repositories\Daemon\DaemonPowerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class MinecraftEula extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'You need to agree to the EULA in order to run the server',
];
}
public function getId(): string
{
return 'eula';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Minecraft EULA')
->modalDescription(new HtmlString(Blade::render('By pressing "I Accept" below you are indicating your agreement to the <x-filament::link href="https://minecraft.net/eula" target="_blank">Minecraft EULA </x-filament::link>')))
->modalSubmitActionLabel('I Accept')
->action(function (DaemonFileRepository $fileRepository, DaemonPowerRepository $powerRepository) {
try {
/** @var Server $server */
$server = Filament::getTenant();
$content = $fileRepository->setServer($server)->getContent('eula.txt');
$content = preg_replace('/(eula=)false/', '\1true', $content);
$fileRepository->setServer($server)->putContent('eula.txt', $content);
$powerRepository->setServer($server)->send('restart');
Notification::make()
->title('Docker image updated')
->body('Restart the server.')
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title('Error')
->body($e->getMessage())
->danger()
->send();
}
}
);
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class PIDLimit extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'pthread_create failed',
'failed to create thread',
'unable to create thread',
'unable to create native thread',
'unable to create new native thread',
'exception in thread "craft async scheduler management thread"',
];
}
public function getId(): string
{
return 'pid_limit';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->icon('tabler-alert-triangle')
->modalHeading(fn () => auth()->user()->isAdmin() ? 'Memory or process limit reached...' : 'Possible resource limit reached...')
->modalDescription(new HtmlString(Blade::render(
auth()->user()->isAdmin() ? <<<'HTML'
<p>
This server has reached the maximum process or memory limit.
</p>
<p class="mt-4">
Increasing <code>container_pid_limit</code> in the wings
configuration, <code>config.yml</code>, might help resolve
this issue.
</p>
<p class="mt-4">
<b>Note: Wings must be restarted for the configuration file changes to take effect</b>
</p>
HTML
:
<<<'HTML'
<p>
This server is attempting to use more resources than allocated. Please contact the administrator
and give them the error below.
</p>
<p class="mt-4">
<code>
pthread_create failed, Possibly out of memory or process/resource limits reached
</code>
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class SteamDiskSpace extends FeatureProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
/** @return array<string> */
public function getListeners(): array
{
return [
'steamcmd needs 250mb of free disk space to update',
'0x202 after update job',
];
}
public function getId(): string
{
return 'steam_disk_space';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Out of available disk space...')
->modalDescription(new HtmlString(Blade::render(
auth()->user()->isAdmin() ? <<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process.
</p>
<p class="mt-4">
Ensure the machine has enough disk space by typing{' '}
<code class="rounded py-1 px-2">df -h</code> on the machine hosting
this server. Delete files or increase the available disk space to resolve the issue.
</p>
HTML
:
<<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process. Please get in touch with the administrator(s) and inform them of disk space issues.
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -70,6 +70,7 @@ class AllocationsRelationManager extends RelationManager
->label(trans('admin/server.ip_address'))
->inlineLabel()
->ipv4()
->live()
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->required(),
TextInput::make('allocation_alias')
@ -83,6 +84,7 @@ class AllocationsRelationManager extends RelationManager
->label(trans('admin/server.ports'))
->inlineLabel()
->live()
->disabled(fn (Get $get) => empty($get('allocation_ip')))
->afterStateUpdated(fn ($state, Set $set, Get $get) => $set('allocation_ports',
CreateServer::retrieveValidPorts($this->getOwnerRecord()->node, $state, $get('allocation_ip')))
)

View File

@ -5,6 +5,7 @@ namespace App\Filament\Server\Pages;
use App\Enums\ConsoleWidgetPosition;
use App\Enums\ContainerStatus;
use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Extensions\Features\FeatureProvider;
use App\Filament\Server\Widgets\ServerConsole;
use App\Filament\Server\Widgets\ServerCpuChart;
use App\Filament\Server\Widgets\ServerMemoryChart;
@ -13,8 +14,9 @@ use App\Filament\Server\Widgets\ServerOverview;
use App\Livewire\AlertBanner;
use App\Models\Permission;
use App\Models\Server;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Facades\Filament;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Support\Enums\Size;
use Filament\Widgets\Widget;
@ -47,6 +49,30 @@ class Console extends Page
}
}
public function boot(): void
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var FeatureProvider $feature */
foreach ($server->egg->features() as $feature) {
$this->cacheAction($feature->getAction());
}
}
#[On('mount-feature')]
public function mountFeature(string $data): void
{
$data = json_decode($data);
$feature = data_get($data, 'key');
$feature = FeatureProvider::getProviders($feature);
if ($this->getMountedAction()) {
return;
}
$this->mountAction($feature->getId());
sleep(2); // TODO find a better way
}
public function getWidgetData(): array
{
return [

View File

@ -2,7 +2,6 @@
namespace App\Filament\Server\Resources\DatabaseResource\Pages;
use App\Facades\Activity;
use App\Filament\Components\Forms\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\DatabaseResource;
@ -82,12 +81,7 @@ class ListDatabases extends ListRecords
ViewAction::make()
->modalHeading(fn (Database $database) => 'Viewing ' . $database->database),
DeleteAction::make()
->after(function (Database $database) {
Activity::event('server:database.delete')
->subject($database)
->property('name', $database->database)
->log();
}),
->using(fn (Database $database, DatabaseManagementService $service) => $service->delete($database)),
]);
}

View File

@ -22,6 +22,8 @@ use Filament\Resources\Pages\Page;
use Filament\Resources\Pages\PageRegistration;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Alignment;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade;
use Livewire\Attributes\Locked;
@ -118,37 +120,37 @@ class EditFiles extends Page
// TODO MonacoEditor::make('editor')
// ->hiddenLabel()
// ->showPlaceholder(false)
// ->default(function () {
// try {
// return $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size'));
// } catch (FileSizeTooLargeException) {
// AlertBanner::make()
// ->title('File too large!')
// ->body('<code>' . $this->path . '</code> Max is ' . convert_bytes_to_readable(config('panel.files.max_edit_size')))
// ->danger()
// ->body('<code>' . $this->path . '</code> Max is ' . convert_bytes_to_readable(config('panel.files.max_edit_size')))
// ->title('File too large!')
// AlertBanner::make()
// } catch (FileSizeTooLargeException) {
// try {
// ->default(function () {
// return $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size'));
// ->closable()
// ->send();
//
// $this->redirect(ListFiles::getUrl());
// } catch (FileNotFoundException) {
// AlertBanner::make()
// $this->redirect(ListFiles::getUrl());
// ->title('File Not found!')
// ->body('<code>' . $this->path . '</code>')
// ->danger()
// ->closable()
// ->body('<code>' . $this->path . '</code>')
// ->send();
// ->closable()
//
// $this->redirect(ListFiles::getUrl());
// } catch (FileNotEditableException) {
// AlertBanner::make()
// ->title('Could not edit directory!')
// AlertBanner::make()
// ->body('<code>' . $this->path . '</code>')
// ->danger()
// ->closable()
// ->send();
// ->danger()
//
// $this->redirect(ListFiles::getUrl());
// }
// $this->redirect(ListFiles::getUrl());
// })
// ->language(fn (Get $get) => $get('lang'))
// ->view('filament.plugins.monaco-editor'),

View File

@ -11,7 +11,6 @@ use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Livewire\AlertBanner;
use Filament\Actions\Action as HeaderAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
@ -36,7 +35,6 @@ use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Illuminate\Support\Carbon;
@ -53,24 +51,11 @@ class ListFiles extends ListRecords
private DaemonFileRepository $fileRepository;
private bool $isDisabled = false;
public function mount(?string $path = null): void
{
parent::mount();
$this->path = $path ?? '/';
try {
$this->getDaemonFileRepository()->getDirectory('/');
} catch (ConnectionException) {
$this->isDisabled = true;
AlertBanner::make('node_connection_error')
->title('Could not connect to the node!')
->danger()
->send();
}
}
public function getBreadcrumbs(): array
@ -130,21 +115,18 @@ class ListFiles extends ListRecords
->actions([
Action::make('view')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->disabled($this->isDisabled)
->label('Open')
->icon('tabler-eye')
->visible(fn (File $file) => $file->is_directory)
->url(fn (File $file) => self::getUrl(['path' => join_paths($this->path, $file->name)])),
EditAction::make('edit')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->disabled($this->isDisabled)
->icon('tabler-edit')
->visible(fn (File $file) => $file->canEdit())
->url(fn (File $file) => EditFiles::getUrl(['path' => join_paths($this->path, $file->name)])),
ActionGroup::make([
Action::make('rename')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->label('Rename')
->icon('tabler-forms')
->schema([
@ -173,7 +155,6 @@ class ListFiles extends ListRecords
}),
Action::make('copy')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('Copy')
->icon('tabler-copy')
->visible(fn (File $file) => $file->is_file)
@ -193,14 +174,12 @@ class ListFiles extends ListRecords
}),
Action::make('download')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->disabled($this->isDisabled)
->label('Download')
->icon('tabler-download')
->visible(fn (File $file) => $file->is_file)
->url(fn (File $file) => DownloadFiles::getUrl(['path' => join_paths($this->path, $file->name)]), true),
Action::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->label('Move')
->icon('tabler-replace')
->schema([
@ -236,7 +215,6 @@ class ListFiles extends ListRecords
}),
Action::make('permissions')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->label('Permissions')
->icon('tabler-license')
->schema([
@ -293,7 +271,6 @@ class ListFiles extends ListRecords
}),
Action::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->disabled($this->isDisabled)
->label('Archive')
->icon('tabler-archive')
->schema([
@ -321,7 +298,6 @@ class ListFiles extends ListRecords
}),
Action::make('unarchive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->disabled($this->isDisabled)
->label('Unarchive')
->icon('tabler-archive')
->visible(fn (File $file) => $file->isArchive())
@ -343,7 +319,6 @@ class ListFiles extends ListRecords
]),
DeleteAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->disabled($this->isDisabled)
->label('')
->icon('tabler-trash')
->requiresConfirmation()
@ -358,83 +333,77 @@ class ListFiles extends ListRecords
->log();
}),
])
->bulkActions([
BulkActionGroup::make([
BulkAction::make('move')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->disabled($this->isDisabled)
->schema([
TextInput::make('location')
->label('Directory')
->hint('Enter the new directory, relative to the current directory.')
->required()
->live(),
TextEntry::make('new_location')
->state(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location') ?? ''))),
])
->action(function (Collection $files, $data) {
$location = rtrim($data['location'], '/');
$location = rtrim($data['location'], '/');
])
->action(function (Collection $files, $data) {
->content(fn (Get $get) => resolve_path('./' . join_paths($this->path, $get('location') ?? ''))),
Placeholder::make('new_location')
->live(),
->required()
->hint('Enter the new directory, relative to the current directory.')
->label('Directory')
->form([
TextInput::make('location')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_UPDATE, $server))
->groupedBulkActions([
BulkAction::make('move')
$files = $files->map(fn ($file) => ['to' => join_paths($location, $file['name']), 'from' => $file['name']])->toArray();
$this->getDaemonFileRepository()
->renameFiles($this->path, $files);
$files = $files->map(fn ($file) => ['to' => join_paths($location, $file['name']), 'from' => $file['name']])->toArray();
$this->getDaemonFileRepository()->renameFiles($this->path, $files);
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', $files)
->log();
Activity::event('server:file.rename')
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title(count($files) . ' Files were moved to ' . resolve_path(join_paths($this->path, $location)))
->success()
->send();
}),
BulkAction::make('archive')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
->disabled($this->isDisabled)
->schema([
TextInput::make('name')
->label('Archive name')
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->suffix('.tar.gz'),
])
->action(function ($data, Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$files = $files->map(fn ($file) => $file['name'])->toArray();
->action(function ($data, Collection $files) {
])
->suffix('.tar.gz'),
->placeholder(fn () => 'archive-' . str(Carbon::now()->toRfc3339String())->replace(':', '')->before('+0000') . 'Z')
->label('Archive name')
->form([
TextInput::make('name')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_ARCHIVE, $server))
BulkAction::make('archive')
->success()
->title(count($files) . ' Files were moved to ' . resolve_path(join_paths($this->path, $location)))
Notification::make()
}),
->send();
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name']);
$archive = $this->getDaemonFileRepository()->compressFiles($this->path, $files, $data['name']);
Activity::event('server:file.compress')
->property('name', $archive['name'])
->property('directory', $this->path)
->property('files', $files)
->log();
Activity::event('server:file.compress')
->property('name', $archive['name'])
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title('Archive created')
->body($archive['name'])
->success()
->send();
Notification::make()
->title('Archive created')
->body($archive['name'])
->success()
->send();
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->disabled($this->isDisabled)
->action(function (Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$this->getDaemonFileRepository()->deleteFiles($this->path, $files);
return redirect(ListFiles::getUrl(['path' => $this->path]));
}),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_DELETE, $server))
->action(function (Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$this->getDaemonFileRepository()->deleteFiles($this->path, $files);
Activity::event('server:file.delete')
->property('directory', $this->path)
->property('files', $files)
->log();
Activity::event('server:file.delete')
->property('directory', $this->path)
->property('files', $files)
->log();
Notification::make()
->title(count($files) . ' Files deleted.')
->success()
->send();
}),
]),
Notification::make()
->title(count($files) . ' Files deleted.')
->success()
->send();
}),
]);
}
@ -446,7 +415,6 @@ class ListFiles extends ListRecords
return [
HeaderAction::make('new_file')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('New File')
->color('gray')
->keyBindings('')
@ -478,7 +446,6 @@ class ListFiles extends ListRecords
]),
HeaderAction::make('new_folder')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('New Folder')
->color('gray')
->action(function ($data) {
@ -495,7 +462,6 @@ class ListFiles extends ListRecords
]),
HeaderAction::make('upload')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_CREATE, $server))
->disabled($this->isDisabled)
->label('Upload')
->action(function ($data) {
if (count($data['files']) > 0 && !isset($data['url'])) {
@ -545,7 +511,6 @@ class ListFiles extends ListRecords
]),
HeaderAction::make('search')
->authorize(fn () => auth()->user()->can(Permission::ACTION_FILE_READ, $server))
->disabled($this->isDisabled)
->label('Global Search')
->modalSubmitActionLabel('Search')
->schema([

View File

@ -36,26 +36,22 @@ class SettingsController extends ClientApiController
$name = $request->input('name');
$description = $request->has('description') ? (string) $request->input('description') : $server->description;
$server->name = $name;
if (config('panel.editable_server_descriptions')) {
$server->description = $description;
}
$server->save();
if ($server->name !== $name) {
Activity::event('server:settings.rename')
->property(['old' => $server->name, 'new' => $name])
->log();
$server->name = $name;
}
if ($server->description !== $description) {
if ($server->description !== $description && config('panel.editable_server_descriptions')) {
Activity::event('server:settings.description')
->property(['old' => $server->description, 'new' => $description])
->log();
$server->description = $description;
}
$server->save();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}

View File

@ -5,6 +5,7 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Exceptions\Service\Egg\HasChildrenException;
use App\Exceptions\Service\HasActiveServersException;
use App\Extensions\Features\FeatureProvider;
use App\Traits\HasValidation;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -70,19 +71,6 @@ class Egg extends Model implements Validatable
*/
public const EXPORT_VERSION = 'PLCN_v1';
/**
* Different features that can be enabled on any given egg. These are used internally
* to determine which types of frontend functionality should be shown to the user. Eggs
* will automatically inherit features from a parent egg if they are already configured
* to copy configuration values from said egg.
*
* To skip copying the features, an empty array value should be passed in ("[]") rather
* than leaving it null.
*/
public const FEATURE_EULA_POPUP = 'eula';
public const FEATURE_FASTDL = 'fastdl';
/**
* Fields that are not mass assignable.
*/
@ -172,6 +160,12 @@ class Egg extends Model implements Validatable
});
}
/** @return array<FeatureProvider> */
public function features(): array
{
return FeatureProvider::getProviders($this->features);
}
/**
* Returns the install script for the egg; if egg is copying from another
* it will return the copied script.

View File

@ -153,16 +153,10 @@ class File extends Model
try {
$fileRepository = (new DaemonFileRepository())->setServer(self::$server);
$contents = [];
try {
if (!is_null(self::$searchTerm)) {
$contents = cache()->remember('file_search_' . self::$path . '_' . self::$searchTerm, now()->addMinute(), fn () => $fileRepository->search(self::$searchTerm, self::$path));
} else {
$contents = $fileRepository->getDirectory(self::$path ?? '/');
}
} catch (ConnectionException $exception) {
report($exception);
if (!is_null(self::$searchTerm)) {
$contents = cache()->remember('file_search_' . self::$path . '_' . self::$searchTerm, now()->addMinute(), fn () => $fileRepository->search(self::$searchTerm, self::$path));
} else {
$contents = $fileRepository->getDirectory(self::$path ?? '/');
}
if (isset($contents['error'])) {
@ -199,8 +193,12 @@ class File extends Model
$message = $message->after('cURL error 7: ')->before(' after ');
}
if ($exception instanceof ConnectionException) {
$message = str('Node connection failed');
}
AlertBanner::make()
->title('Could not load files')
->title('Could not load files!')
->body($message->toString())
->danger()
->send();

View File

@ -4,12 +4,13 @@ namespace App\Models;
use App\Contracts\Validatable;
use App\Traits\HasValidation;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class Permission extends Model implements Validatable
{
use HasValidation;
use HasFactory, HasValidation;
/**
* The resource name for this model when it is transformed into an

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Spatie\Permission\Models\Role as BaseRole;
/**
@ -17,6 +18,8 @@ use Spatie\Permission\Models\Role as BaseRole;
*/
class Role extends BaseRole
{
use HasFactory;
public const RESOURCE_NAME = 'role';
public const ROOT_ADMIN = 'Root Admin';

View File

@ -15,6 +15,11 @@ use App\Extensions\Avatar\Providers\UiAvatarsProvider;
use App\Extensions\OAuth\Providers\GitlabProvider;
use App\Models;
use App\Extensions\Captcha\Providers\TurnstileProvider;
use App\Extensions\Features\GSLToken;
use App\Extensions\Features\JavaVersion;
use App\Extensions\Features\MinecraftEula;
use App\Extensions\Features\PIDLimit;
use App\Extensions\Features\SteamDiskSpace;
use App\Extensions\OAuth\Providers\AuthentikProvider;
use App\Extensions\OAuth\Providers\CommonProvider;
use App\Extensions\OAuth\Providers\DiscordProvider;
@ -121,6 +126,13 @@ class AppServiceProvider extends ServiceProvider
GravatarProvider::register();
UiAvatarsProvider::register();
// Default Feature providers
GSLToken::register($app);
JavaVersion::register($app);
MinecraftEula::register($app);
PIDLimit::register($app);
SteamDiskSpace::register($app);
FilamentColor::register([
'danger' => Color::Red,
'gray' => Color::Zinc,

View File

@ -118,11 +118,18 @@ class DatabaseManagementService
*/
public function delete(Database $database): ?bool
{
$database->dropDatabase($database->database);
$database->dropUser($database->username, $database->remote);
$database->flush();
return $this->connection->transaction(function () use ($database) {
$database->dropDatabase($database->database);
$database->dropUser($database->username, $database->remote);
$database->flush();
return $database->delete();
Activity::event('server:database.delete')
->subject($database)
->property('name', $database->database)
->log();
return $database->delete();
});
}
/**

View File

@ -101,6 +101,9 @@ class ServerConfigurationStructureService
'egg' => [
'id' => $server->egg->uuid,
'file_denylist' => $server->egg->inherit_file_denylist,
'features' => collect($server->egg->features())->mapWithKeys(fn ($feature) => [
$feature->getId() => $feature->getListeners(),
])->all(),
],
];

View File

@ -46,6 +46,7 @@ class EggTransformer extends BaseTransformer
'name' => $model->name,
'author' => $model->author,
'description' => $model->description,
'features' => $model->features,
// "docker_image" is deprecated, but left here to avoid breaking too many things at once
// in external software. We'll remove it down the road once things have gotten the chance
// to upgrade to using "docker_images".

View File

@ -54,6 +54,8 @@
"mockery/mockery": "^1.6.11",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^3.7",
"pestphp/pest-plugin-faker": "^3.0",
"pestphp/pest-plugin-livewire": "^3.0",
"spatie/laravel-ignition": "^2.9"
},
"autoload": {

133
composer.lock generated
View File

@ -13857,6 +13857,137 @@
],
"time": "2025-04-16T22:59:48+00:00"
},
{
"name": "pestphp/pest-plugin-faker",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/pestphp/pest-plugin-faker.git",
"reference": "48343e2806cfc12a042dead90ffff4a043167e3e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pestphp/pest-plugin-faker/zipball/48343e2806cfc12a042dead90ffff4a043167e3e",
"reference": "48343e2806cfc12a042dead90ffff4a043167e3e",
"shasum": ""
},
"require": {
"fakerphp/faker": "^1.23.1",
"pestphp/pest": "^3.0.0",
"php": "^8.2"
},
"require-dev": {
"pestphp/pest-dev-tools": "^3.0.0"
},
"type": "library",
"autoload": {
"files": [
"src/Faker.php"
],
"psr-4": {
"Pest\\Faker\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "The Pest Faker Plugin",
"keywords": [
"faker",
"framework",
"pest",
"php",
"plugin",
"test",
"testing",
"unit"
],
"support": {
"source": "https://github.com/pestphp/pest-plugin-faker/tree/v3.0.0"
},
"funding": [
{
"url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L",
"type": "custom"
},
{
"url": "https://github.com/nunomaduro",
"type": "github"
},
{
"url": "https://www.patreon.com/nunomaduro",
"type": "patreon"
}
],
"time": "2024-09-08T23:56:08+00:00"
},
{
"name": "pestphp/pest-plugin-livewire",
"version": "v3.0.0",
"source": {
"type": "git",
"url": "https://github.com/pestphp/pest-plugin-livewire.git",
"reference": "e2f2edb0a7d414d6837d87908a0e148256d3bf89"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/pestphp/pest-plugin-livewire/zipball/e2f2edb0a7d414d6837d87908a0e148256d3bf89",
"reference": "e2f2edb0a7d414d6837d87908a0e148256d3bf89",
"shasum": ""
},
"require": {
"livewire/livewire": "^3.5.6",
"pestphp/pest": "^3.0.0",
"php": "^8.1"
},
"require-dev": {
"orchestra/testbench": "^9.4.0",
"pestphp/pest-dev-tools": "^3.0.0"
},
"type": "library",
"autoload": {
"files": [
"src/Autoload.php"
],
"psr-4": {
"Pest\\Livewire\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "The Pest Livewire Plugin",
"keywords": [
"framework",
"livewire",
"pest",
"php",
"plugin",
"test",
"testing",
"unit"
],
"support": {
"source": "https://github.com/pestphp/pest-plugin-livewire/tree/v3.0.0"
},
"funding": [
{
"url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L",
"type": "custom"
},
{
"url": "https://github.com/nunomaduro",
"type": "github"
},
{
"url": "https://www.patreon.com/nunomaduro",
"type": "patreon"
}
],
"time": "2024-09-09T00:05:59+00:00"
},
{
"name": "pestphp/pest-plugin-mutate",
"version": "v3.0.5",
@ -16017,5 +16148,5 @@
"platform-overrides": {
"php": "8.2"
},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.3.0"
}

View File

@ -0,0 +1,18 @@
<?php
namespace Database\Factories;
use App\Models\Permission;
use Illuminate\Database\Eloquent\Factories\Factory;
class PermissionFactory extends Factory
{
protected $model = Permission::class;
public function definition(): array
{
return [
];
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Database\Factories;
use App\Models\Role;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
class RoleFactory extends Factory
{
protected $model = Role::class;
public function definition(): array
{
return [
'name' => $this->faker->name(),
'guard_name' => $this->faker->name(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];
}
}

View File

@ -4,11 +4,11 @@
"version": "PLCN_v1",
"update_url": "https:\/\/github.com\/pelican-dev\/panel\/raw\/main\/database\/Seeders\/eggs\/minecraft\/egg-sponge--sponge-vanilla.json"
},
"exported_at": "2025-03-18T12:35:50+00:00",
"name": "Sponge (SpongeVanilla)",
"exported_at": "2025-04-25T06:05:10+00:00",
"name": "Sponge",
"author": "panel@example.com",
"uuid": "f0d2f88f-1ff3-42a0-b03f-ac44c5571e6d",
"description": "SpongeVanilla is the SpongeAPI implementation for Vanilla Minecraft.",
"description": "A community-driven open source Minecraft: Java Edition modding platform.",
"tags": [
"minecraft"
],
@ -34,28 +34,42 @@
},
"scripts": {
"installation": {
"script": "#!\/bin\/ash\r\n# Sponge Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\ncd \/mnt\/server\r\n\r\ncurl -sSL \"https:\/\/repo.spongepowered.org\/maven\/org\/spongepowered\/spongevanilla\/${SPONGE_VERSION}\/spongevanilla-${SPONGE_VERSION}.jar\" -o ${SERVER_JARFILE}",
"script": "#!\/bin\/ash\r\n# Sponge Installation Script\r\n#\r\n# Server Files: \/mnt\/server\r\n\r\ncd \/mnt\/server\r\n\r\nif [ $MINECRAFT_VERSION = 'latest' ] || [ -z $MINECRAFT_VERSION ]; then\r\n TARGET_VERSION_JSON=$(curl -sSL https:\/\/dl-api.spongepowered.org\/v2\/groups\/org.spongepowered\/artifacts\/${SPONGE_TYPE}\/latest?recommended=true)\r\n if [ -z \"${TARGET_VERSION_JSON}\" ]; then\r\n echo -e \"Failed to find latest recommended version!\"\r\n exit 1\r\n fi\r\n echo -e \"Found latest version for ${SPONGE_TYPE}\"\r\nelse\r\n if [ $SPONGE_TYPE = 'spongevanilla' ]; then \r\n VERSIONS_JSON=$(curl -sSL https:\/\/dl-api.spongepowered.org\/v2\/groups\/org.spongepowered\/artifacts\/${SPONGE_TYPE}\/versions?tags=,minecraft:${MINECRAFT_VERSION}&offset=0&limit=1)\r\n else\r\n FORGETAG='forge'\r\n if [ $SPONGE_TYPE = 'spongeneo' ]; then\r\n FORGETAG='neoforge'\r\n fi\r\n VERSIONS_JSON=$(curl -sSL https:\/\/dl-api.spongepowered.org\/v2\/groups\/org.spongepowered\/artifacts\/${SPONGE_TYPE}\/versions?tags=,minecraft:${MINECRAFT_VERSION},${FORGETAG}:${FORGE_VERSION}&offset=0&limit=1)\r\n fi\r\n \r\n if [ -z \"${VERSIONS_JSON}\" ]; then\r\n echo -e \"Failed to find recommended ${MINECRAFT_VERSION} version for ${SPONGE_TYPE} ${FORGE_VERSION}!\"\r\n exit 1\r\n fi\r\n \r\n VERSION_KEY=$(echo $VERSIONS_JSON | jq -r '.artifacts | to_entries[0].key')\r\n TARGET_VERSION_JSON=$(curl -sSL https:\/\/dl-api.spongepowered.org\/v2\/groups\/org.spongepowered\/artifacts\/${SPONGE_TYPE}\/versions\/${VERSION_KEY})\r\n \r\n if [ -z \"${TARGET_VERSION_JSON}\" ]; then\r\n echo -e \"Failed to find ${VERSION_KEY} for ${SPONGE_TYPE} ${FORGE_VERSION}!\"\r\n exit 1\r\n fi\r\n\r\n echo -e \"Found ${MINECRAFT_VERSION} for ${SPONGE_TYPE}\"\r\nfi\r\n\r\nTARGET_VERSION=`echo $TARGET_VERSION_JSON | jq '.assets[] | select(.classifier == \"universal\")'`\r\nif [ -z \"${TARGET_VERSION}\" ]; then\r\n TARGET_VERSION=`echo $TARGET_VERSION_JSON | jq '.assets[] | select(.classifier == \"\" and .extension == \"jar\")'`\r\nfi\r\n\r\nif [ -z \"${TARGET_VERSION}\" ]; then\r\n echo -e \"Failed to get download url data from the selected version\"\r\n exit 1\r\nfi\r\n\r\nSPONGE_URL=$(echo $TARGET_VERSION | jq -r '.downloadUrl')\r\nCHECKSUM=$(echo $TARGET_VERSION | jq -r '.md5')\r\necho -e \"Found file at ${SPONGE_URL} with checksum ${CHECKSUM}\"\r\n\r\necho -e \"running: curl -o ${SERVER_JARFILE} ${SPONGE_URL}\"\r\ncurl -o ${SERVER_JARFILE} ${SPONGE_URL}\r\n\r\nif [ $(basename $(md5sum ${SERVER_JARFILE})) = ${CHECKSUM} ] ; then\r\n echo \"Checksum passed\"\r\nelse\r\n echo \"Checksum failed\"\r\nfi\r\n\r\necho -e \"Install Complete\"",
"container": "ghcr.io\/parkervcp\/installers:alpine",
"entrypoint": "ash"
}
},
"variables": [
{
"name": "Sponge Version",
"description": "The version of SpongeVanilla to download and use.",
"env_variable": "SPONGE_VERSION",
"default_value": "1.12.2-7.3.0",
"sort": 3,
"name": "Forge\/Neoforge Version",
"description": "The modding api target version if set to `spongeforge` or `spongeneo`. Leave blank if using `spongevanilla`",
"env_variable": "FORGE_VERSION",
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": [
"string"
]
},
{
"sort": 1,
"name": "Minecraft Version",
"description": "The version of Minecraft to target. Use \"latest\" to install the latest version. Go to Settings > Reinstall Server to apply.",
"env_variable": "MINECRAFT_VERSION",
"default_value": "latest",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"regex:\/^([a-zA-Z0-9.\\-_]+)$\/"
],
"sort": 1
"string",
"between:3,15"
]
},
{
"sort": 4,
"name": "Server Jar File",
"description": "The name of the Jarfile to use when running SpongeVanilla.",
"description": "The name of the Jarfile to use when running Sponge.",
"env_variable": "SERVER_JARFILE",
"default_value": "server.jar",
"user_viewable": true,
@ -63,8 +77,20 @@
"rules": [
"required",
"regex:\/^([\\w\\d._-]+)(\\.jar)$\/"
],
"sort": 2
]
},
{
"sort": 2,
"name": "Sponge Type",
"description": "SpongeVanilla if you are only using Sponge plugins.\nSpongeForge when using Forge mods and Sponge plugins.\nSpongeNeo when using NeoForge mods and Sponge plugins.",
"env_variable": "SPONGE_TYPE",
"default_value": "spongevanilla",
"user_viewable": true,
"user_editable": true,
"rules": [
"required",
"in:spongevanilla,spongeforge,spongeneo"
]
}
]
}
}

View File

@ -23,7 +23,7 @@ else
echo -e "APP_INSTALLED=false" >> /pelican-data/.env
fi
mkdir /pelican-data/database /var/www/html/storage/logs/supervisord 2>/dev/null
mkdir -p /pelican-data/database /pelican-data/storage/avatars /var/www/html/storage/logs/supervisord 2>/dev/null
if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then
echo "Generating APP_KEY..."

View File

@ -129,6 +129,9 @@
case 'install output':
handleConsoleOutput(args[0]);
break;
case 'feature match':
Livewire.dispatch('mount-feature', { data: args[0] });
break;
case 'status':
handlePowerChangeEvent(args[0]);

View File

@ -4,4 +4,7 @@
:data="$this->getWidgetData()"
:widgets="$this->getVisibleWidgets()"
/>
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@ -0,0 +1,179 @@
<?php
use App\Enums\ServerState;
use App\Http\Controllers\Api\Client\Servers\SettingsController;
use App\Models\Permission;
use App\Repositories\Daemon\DaemonServerRepository;
use Symfony\Component\HttpFoundation\Response;
pest()->group('API');
covers(SettingsController::class);
it('server name cannot be changed', function () {
[$user, $server] = generateTestAccount([Permission::ACTION_WEBSOCKET_CONNECT]);
$originalName = $server->name;
$this->actingAs($user)
->post("/api/client/servers/$server->uuid/settings/rename", [
'name' => 'Test Server Name',
])
->assertStatus(Response::HTTP_FORBIDDEN);
$server = $server->refresh();
expect()->toLogActivities(0)
->and($server->name)->toBe($originalName);
});
it('server description can be changed', function () {
[$user, $server] = generateTestAccount([Permission::ACTION_SETTINGS_DESCRIPTION]);
$originalDescription = $server->description;
$newDescription = 'Test Server Description';
$this->actingAs($user)
->post("/api/client/servers/$server->uuid/settings/description", [
'description' => $newDescription,
])
->assertStatus(Response::HTTP_NO_CONTENT);
$server = $server->refresh();
$logged = \App\Models\ActivityLog::first();
expect()->toLogActivities(1)
->and($logged->properties['old'])->toBe($originalDescription)
->and($logged->properties['new'])->toBe($newDescription)
->and($server->description)->not()->toBe($originalDescription);
});
it('server description cannot be changed', function () {
[$user, $server] = generateTestAccount([Permission::ACTION_SETTINGS_DESCRIPTION]);
Config::set('panel.editable_server_descriptions', false);
$originalDescription = $server->description;
$this->actingAs($user)
->post("/api/client/servers/$server->uuid/settings/description", [
'description' => 'Test Description',
])
->assertStatus(Response::HTTP_NO_CONTENT);
$server = $server->refresh();
expect()->toLogActivities(0)
->and($server->description)->toBe($originalDescription);
});
it('server name can be changed', function () {
[$user, $server] = generateTestAccount([Permission::ACTION_WEBSOCKET_CONNECT, Permission::ACTION_SETTINGS_RENAME]);
$originalName = $server->name;
$this->actingAs($user)
->post("/api/client/servers/$server->uuid/settings/rename", [
'name' => 'Test Server Name',
])
->assertStatus(Response::HTTP_NO_CONTENT);
$server = $server->refresh();
expect()->toLogActivities(1)
->and($server->name)->not()->toBe($originalName);
});
test('unauthorized user cannot change docker image in use by server', function () {
[$user, $server] = generateTestAccount([Permission::ACTION_WEBSOCKET_CONNECT]);
$originalImage = $server->image;
$this->actingAs($user)
->put("/api/client/servers/$server->uuid/settings/docker-image", [
'docker_image' => 'ghcr.io/pelican-dev/yolks:java_21',
])
->assertStatus(Response::HTTP_FORBIDDEN);
$server = $server->refresh();
expect()->toLogActivities(0)
->and($server->image)->toBe($originalImage);
});
test('cannot change docker image to image not allowed by egg', function () {
[$user, $server] = generateTestAccount([Permission::ACTION_STARTUP_DOCKER_IMAGE]);
$server->image = 'ghcr.io/parkervcp/yolks:java_17';
$server->save();
$newImage = 'ghcr.io/parkervcp/fake:image';
$server = $server->refresh();
$this->actingAs($user)
->putJson("/api/client/servers/$server->uuid/settings/docker-image", [
'docker_image' => $newImage,
])
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
$server->refresh();
expect()->toLogActivities(0)
->and($server->image)->not()->toBe($newImage);
});
test('can change docker image in use by server', function () {
[$user, $server] = generateTestAccount([Permission::ACTION_STARTUP_DOCKER_IMAGE]);
$oldImage = 'ghcr.io/parkervcp/yolks:java_17';
$server->image = $oldImage;
$server->save();
$newImage = 'ghcr.io/parkervcp/yolks:java_21';
$this->actingAs($user)
->putJson("/api/client/servers/$server->uuid/settings/docker-image", [
'docker_image' => $newImage,
])
->assertStatus(Response::HTTP_NO_CONTENT);
$server = $server->refresh();
$logItem = \App\Models\ActivityLog::first();
expect()->toLogActivities(1)
->and($logItem->properties['old'])->toBe($oldImage)
->and($logItem->properties['new'])->toBe($newImage)
->and($server->image)->toBe($newImage);
});
test('unable to change the docker image set by administrator', function () {
[$user, $server] = generateTestAccount([Permission::ACTION_STARTUP_DOCKER_IMAGE]);
$oldImage = 'ghcr.io/parkervcp/yolks:java_custom';
$server->image = $oldImage;
$server->save();
$newImage = 'ghcr.io/parkervcp/yolks:java_8';
$this->actingAs($user)
->putJson("/api/client/servers/$server->uuid/settings/docker-image", [
'docker_image' => $newImage,
])
->assertStatus(Response::HTTP_BAD_REQUEST);
$server = $server->refresh();
expect()->toLogActivities(0)
->and($server->image)->toBe($oldImage);
});
test('can be reinstalled', function () {
[$user, $server] = generateTestAccount([Permission::ACTION_SETTINGS_REINSTALL]);
expect($server->isInstalled())->toBeTrue();
$service = \Mockery::mock(DaemonServerRepository::class);
$this->app->instance(DaemonServerRepository::class, $service);
$service->expects('setServer')
->with(\Mockery::on(function ($value) use ($server) {
return $value->uuid === $server->uuid;
}))
->andReturnSelf()
->getMock()
->expects('reinstall')
->andReturnUndefined();
$this->actingAs($user)->postJson("/api/client/servers/$server->uuid/settings/reinstall")
->assertStatus(Response::HTTP_ACCEPTED);
$server = $server->refresh();
expect()->toLogActivities(1)
->and($server->status)->toBe(ServerState::Installing);
});

View File

@ -0,0 +1,49 @@
<?php
use App\Enums\RolePermissionModels;
use App\Filament\Admin\Resources\EggResource\Pages\ListEggs;
use App\Models\Egg;
use App\Models\Permission;
use App\Models\Role;
use function Pest\Livewire\livewire;
it('root admin can see all eggs', function () {
$eggs = Egg::all();
[$admin] = generateTestAccount([]);
$admin = $admin->syncRoles(Role::getRootAdmin());
$this->actingAs($admin);
livewire(ListEggs::class)
->assertSuccessful()
->assertCountTableRecords($eggs->count())
->assertCanSeeTableRecords($eggs);
});
it('non root admin cannot see any eggs', function () {
$role = Role::factory()->create(['name' => 'Node Viewer', 'guard_name' => 'web']);
// Node Permission is on purpose, we check the wrong permissions.
$permission = Permission::factory()->create(['name' => RolePermissionModels::Node->viewAny(), 'guard_name' => 'web']);
$role->permissions()->attach($permission);
[$user] = generateTestAccount([]);
$this->actingAs($user);
livewire(ListEggs::class)
->assertForbidden();
});
it('non root admin with permissions can see eggs', function () {
$role = Role::factory()->create(['name' => 'Egg Viewer', 'guard_name' => 'web']);
$permission = Permission::factory()->create(['name' => RolePermissionModels::Egg->viewAny(), 'guard_name' => 'web']);
$role->permissions()->attach($permission);
$eggs = Egg::all();
[$user] = generateTestAccount([]);
$user = $user->syncRoles($role);
$this->actingAs($user);
livewire(ListEggs::class)
->assertSuccessful()
->assertCountTableRecords($eggs->count())
->assertCanSeeTableRecords($eggs);
});

View File

@ -0,0 +1,67 @@
<?php
use App\Enums\RolePermissionModels;
use App\Filament\Admin\Resources\NodeResource\Pages\ListNodes;
use App\Models\Node;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Server;
use Filament\Actions\CreateAction;
use Filament\Tables\Actions\CreateAction as TableCreateAction;
use function Pest\Livewire\livewire;
it('root admin can see all nodes', function () {
[$admin] = generateTestAccount([]);
$admin = $admin->syncRoles(Role::getRootAdmin());
$nodes = Node::all();
$this->actingAs($admin);
livewire(ListNodes::class)
->assertSuccessful()
->assertCountTableRecords($nodes->count())
->assertCanSeeTableRecords($nodes);
});
it('non root admin cannot see any nodes', function () {
$role = Role::factory()->create(['name' => 'Egg Viewer', 'guard_name' => 'web']);
// Egg Permission is on purpose, we check the wrong permissions.
$permission = Permission::factory()->create(['name' => RolePermissionModels::Egg->viewAny(), 'guard_name' => 'web']);
$role->permissions()->attach($permission);
[$user] = generateTestAccount();
$this->actingAs($user);
livewire(ListNodes::class)
->assertForbidden();
});
it('non root admin with permissions can see nodes', function () {
$role = Role::factory()->create(['name' => 'Node Viewer', 'guard_name' => 'web']);
$permission = Permission::factory()->create(['name' => RolePermissionModels::Node->viewAny(), 'guard_name' => 'web']);
$role->permissions()->attach($permission);
[$user] = generateTestAccount();
$nodes = Node::all();
$user = $user->syncRoles($role);
$this->actingAs($user);
livewire(ListNodes::class)
->assertSuccessful()
->assertCountTableRecords($nodes->count())
->assertCanSeeTableRecords($nodes);
});
it('displays the create button in the table instead of the header when 0 nodes', function () {
[$admin] = generateTestAccount([]);
$admin = $admin->syncRoles(Role::getRootAdmin());
// Nuke servers & nodes
Server::truncate();
Node::truncate();
$this->actingAs($admin);
livewire(ListNodes::class)
->assertSuccessful()
->assertHeaderMissing(CreateAction::class)
->assertActionExists(TableCreateAction::class);
});

View File

@ -24,10 +24,26 @@
|
*/
use App\Models\ActivityLog;
use App\Models\Allocation;
use App\Models\Egg;
use App\Models\Node;
use App\Models\Server;
use App\Models\Subuser;
use App\Models\User;
use App\Tests\Integration\IntegrationTestCase;
use Ramsey\Uuid\Uuid;
expect()->extend('toBeOne', function () {
return $this->toBe(1);
});
expect()->extend('toLogActivities', function (int $times) {
expect(ActivityLog::count())->toBe($times);
});
uses(IntegrationTestCase::class)->in('Feature', 'Filament');
/*
|--------------------------------------------------------------------------
| Functions
@ -43,3 +59,119 @@ function something()
{
// ..
}
/**
* Generates a user and a server for that user. If an array of permissions is passed it
* is assumed that the user is actually a subuser of the server.
*
* @param string[] $permissions
* @return array{\App\Models\User, \App\Models\Server}
*/
/**
* Creates a server model in the databases for the purpose of testing. If an attribute
* is passed in that normally requires this function to create a model no model will be
* created and that attribute's value will be used.
*
* The returned server model will have all the relationships loaded onto it.
*/
function createServerModel(array $attributes = []): Server
{
if (isset($attributes['user_id'])) {
$attributes['owner_id'] = $attributes['user_id'];
}
if (!isset($attributes['owner_id'])) {
/** @var \App\Models\User $user */
$user = User::factory()->create();
$attributes['owner_id'] = $user->id;
}
if (!isset($attributes['node_id'])) {
/** @var \App\Models\Node $node */
$node = Node::factory()->create();
$attributes['node_id'] = $node->id;
}
if (!isset($attributes['allocation_id'])) {
/** @var \App\Models\Allocation $allocation */
$allocation = Allocation::factory()->create(['node_id' => $attributes['node_id']]);
$attributes['allocation_id'] = $allocation->id;
}
if (empty($attributes['egg_id'])) {
$egg = getBungeecordEgg();
$attributes['egg_id'] = $egg->id;
}
unset($attributes['user_id']);
/** @var \App\Models\Server $server */
$server = Server::factory()->create($attributes);
Allocation::query()->where('id', $server->allocation_id)->update(['server_id' => $server->id]);
return $server->fresh([
'user', 'node', 'allocation', 'egg',
]);
}
/**
* Generates a user and a server for that user. If an array of permissions is passed it
* is assumed that the user is actually a subuser of the server.
*
* @param string[] $permissions
* @return array{\App\Models\User, \App\Models\Server}
*/
function generateTestAccount(array $permissions = []): array
{
/** @var \App\Models\User $user */
$user = User::factory()->create();
if (empty($permissions)) {
return [$user, createServerModel(['user_id' => $user->id])];
}
$server = createServerModel();
Subuser::query()->create([
'user_id' => $user->id,
'server_id' => $server->id,
'permissions' => $permissions,
]);
return [$user, $server];
}
/**
* Clones a given egg allowing us to make modifications that don't affect other
* tests that rely on the egg existing in the correct state.
*/
function cloneEggAndVariables(Egg $egg): Egg
{
$model = $egg->replicate(['id', 'uuid']);
$model->uuid = Uuid::uuid4()->toString();
$model->push();
/** @var \App\Models\Egg $model */
$model = $model->fresh();
foreach ($egg->variables as $variable) {
$variable->replicate(['id', 'egg_id'])->forceFill(['egg_id' => $model->id])->push();
}
return $model->fresh();
}
/**
* Almost every test just assumes it is using BungeeCord this is the critical
* egg model for all tests unless specified otherwise.
*/
function getBungeecordEgg(): Egg
{
/** @var \App\Models\Egg $egg */
$egg = Egg::query()->where('author', 'panel@example.com')->where('name', 'Bungeecord')->firstOrFail();
return $egg;
}