Compare commits

...

43 Commits

Author SHA1 Message Date
Charles
b1e9cadc10 Revert "Update to filament v5, Livewire v4" (#2121) 2026-01-18 17:17:23 -05:00
Charles
7bf1f18c2d Update to filament v5, Livewire v4 (#2114)
Co-authored-by: Lance Pioch <git@lance.sh>
2026-01-18 17:04:13 -05:00
Charles
6fe7d29960 composer update (#2120) 2026-01-18 16:44:16 -05:00
Charles
15172b1d86 Add github eggs to egg importer (#2116) 2026-01-18 16:33:09 -05:00
Boy132
9f744d39a2 Add traits for customizing tabs (#2101) 2026-01-18 22:32:18 +01:00
Boy132
b79511568e Fix allocation policy for admins and update checks (#2090) 2026-01-18 22:26:15 +01:00
Lance Pioch
adeb1b4217 Add parallel flags to github ci (#2109) 2026-01-18 16:24:39 -05:00
JoanFo
d064bf9734 Allow backup transfers (#2068)
Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
2026-01-18 16:23:21 -05:00
Michael (Parker) Parker
107286d618 Multiple Container Fixes (#2063)
Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
2026-01-18 16:21:33 -05:00
Lance Pioch
a3203f7dda Update readme.md 2026-01-16 19:14:05 -05:00
Boy132
e9abd56f7a Add traits for customizing wizard steps (#2100) 2026-01-15 20:55:53 +01:00
PalmarHealer
675ab057b0 fix: Enhance feedback notifications for egg actions (#2042)
Co-authored-by: Charles <charles@pelican.dev>
2026-01-15 12:32:50 -05:00
Boy132
943d9d3ef5 Update translations from crowdin (#2110) 2026-01-15 07:59:55 -05:00
Lance Pioch
c06a525be2 Laravel 12.47.0 Shift (#2103)
Co-authored-by: Shift <shift@laravelshift.com>
2026-01-15 07:57:57 -05:00
Boy132
2ff5fdf831 Fix columns for mount form (#2105) 2026-01-15 13:57:37 +01:00
Boy132
0e810f3110 Throw yarn errors when installing themes (#2104) 2026-01-14 08:23:24 +01:00
Charles
eadbe6e8fd fix client side view database unlimited state (#2047)
Co-authored-by: Boy132 <mail@boy132.de>
2026-01-13 05:33:20 -05:00
Boy132
53aa49b11a Add changes from upstream (#2076)
Co-authored-by: DaneEveritt <dane@daneeveritt.com>
2026-01-13 08:39:50 +01:00
Boy132
6ae4f007c8 Make sure custom pages/relations don't override default pages/relations (#2099) 2026-01-12 18:00:37 +01:00
Boy132
6b9d683f06 Update database config to remove deprecation warning on php 8.5 (#2089) 2026-01-09 14:39:22 +01:00
Boy132
3b24e22316 Set plugin status to "errored" if it errored (#2084) 2026-01-08 17:43:31 +01:00
Boy132
bd012f52a9 Add tests for php 8.5 (#2079) 2026-01-08 17:32:23 +01:00
Boy132
af202d9827 Add user to shouldLink and shouldCreate oauth functions (#2083) 2026-01-08 15:13:15 +01:00
Boy132
6ebeb40ba0 Make rule for user language less restrictive (#2075) 2026-01-06 08:45:53 +01:00
Boy132
333eeda065 Disable field if server variable is not user_editable (#2074) 2026-01-06 08:45:40 +01:00
MartinOscar
fcfafadec7 Return if no egg was selected in the Installer (#2073) 2026-01-05 14:21:34 +01:00
Boy132
76b6118fd1 Fix typo in method name (#2062) 2026-01-04 15:17:48 -05:00
PalmarHealer
3141fe61b4 fix: plugin migration rollback and cache clearing on uninstall (#2033)
Co-authored-by: Boy132 <mail@boy132.de>
2026-01-03 23:44:33 +01:00
Charles
bed9dbeb2b Add Eggs to Installer (#2004)
Co-authored-by: Boy132 <mail@boy132.de>
2025-12-29 17:24:02 -05:00
Boy132
976cb00c0d Replace Artisan::call in plugin service for better error handling (#2031) 2025-12-28 14:44:39 +01:00
Quinten
e3534bbb29 Bungeecord: Fix Download (#2055) 2025-12-28 13:48:22 +01:00
xDev789
5740c93032 Per request cache for permission checks (#2029)
Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
Co-authored-by: Lance Pioch <lancepioch@gmail.com>
2025-12-28 02:00:59 +01:00
MartinOscar
d72e075977 chore: Prevent users from caching Config (#2048) 2025-12-28 01:50:36 +01:00
Boy132
9af608f808 Fix relation managers for admin server resource (#2050) 2025-12-25 00:44:30 +01:00
Boy132
ac36e7a4b5 Fix oauth providers with no color (#2044) 2025-12-24 14:38:47 +01:00
Boy132
b1c64e2ef1 Add error notification when plugin install, update or uninstall fails (#2032) 2025-12-24 14:38:25 +01:00
PalmarHealer
da2e930d4d Correct bounty link (#2039)
Co-authored-by: MartinOscar <40749467+rmartinoscar@users.noreply.github.com>
2025-12-23 15:46:00 -05:00
Charles
460a5dfaf8 composer update (#2030) 2025-12-22 19:01:52 -05:00
killerbite95
576f04be58 fix: use correct log path for upload action (#2016)
Co-authored-by: Charles <charles@pelican.dev>
2025-12-22 19:01:44 -05:00
Boy132
43fb030133 Don't log yarn exceptions as error but warning (#2022) 2025-12-21 15:37:21 +01:00
Boy132
ae054f6e9b Fix actions when plugin is "errored" (#2027) 2025-12-21 15:37:07 +01:00
Boy132
fef91791c3 Fix plugin settings not showing on non-admin plugins (#2023) 2025-12-21 15:36:39 +01:00
Boy132
1d5ace3a6d Clear filament cache when installing a plugin (#2017) 2025-12-20 02:00:57 +01:00
836 changed files with 19469 additions and 11736 deletions

View File

@@ -26,7 +26,7 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4]
php: [8.2, 8.3, 8.4, 8.5]
env:
DB_CONNECTION: sqlite
DB_DATABASE: testing.sqlite
@@ -61,14 +61,17 @@ jobs:
- name: Create SQLite file
run: touch database/testing.sqlite
- name: Run Migrations
run: php artisan migrate --force --seed
- name: Unit tests
run: vendor/bin/pest tests/Unit
run: vendor/bin/pest tests/Unit --parallel
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration
run: vendor/bin/pest tests/Integration --parallel
mysql:
name: MySQL
@@ -76,7 +79,7 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4]
php: [8.2, 8.3, 8.4, 8.5]
database: ["mysql:8"]
services:
database:
@@ -120,14 +123,20 @@ jobs:
- name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Run Migrations
run: php artisan migrate --force --seed
env:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
- name: Unit tests
run: vendor/bin/pest tests/Unit
run: vendor/bin/pest tests/Unit --parallel
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration
run: vendor/bin/pest tests/Integration --parallel
env:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
@@ -138,7 +147,7 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4]
php: [8.2, 8.3, 8.4, 8.5]
database: ["mariadb:10.6", "mariadb:10.11", "mariadb:11.4"]
services:
database:
@@ -182,14 +191,20 @@ jobs:
- name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Run Migrations
run: php artisan migrate --force --seed
env:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
- name: Unit tests
run: vendor/bin/pest tests/Unit
run: vendor/bin/pest tests/Unit --parallel
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration
run: vendor/bin/pest tests/Integration --parallel
env:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
@@ -200,7 +215,7 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4]
php: [8.2, 8.3, 8.4, 8.5]
database: ["postgres:14"]
services:
database:
@@ -238,6 +253,7 @@ jobs:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-${{ matrix.php }}-
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -250,11 +266,14 @@ jobs:
- name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Run Migrations
run: php artisan migrate --force --seed
- name: Unit tests
run: vendor/bin/pest tests/Unit
run: vendor/bin/pest tests/Unit --parallel
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration
run: vendor/bin/pest tests/Integration --parallel

View File

@@ -3,7 +3,7 @@ name: Lint
on:
pull_request:
branches:
- '**'
- "**"
jobs:
pint:
@@ -16,7 +16,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
php-version: "8.4"
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
@@ -35,7 +35,7 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [ 8.2, 8.3, 8.4 ]
php: [8.2, 8.3, 8.4, 8.5]
steps:
- name: Code Checkout
uses: actions/checkout@v4

View File

@@ -38,7 +38,7 @@ RUN yarn config set network-timeout 300000 \
FROM --platform=$TARGETOS/$TARGETARCH composer AS composerbuild
# Copy full code to optimize autoload
COPY --exclude=Caddyfile --exclude=docker/ . ./
COPY --exclude=docker/ . ./
RUN composer dump-autoload --optimize
@@ -50,7 +50,7 @@ FROM --platform=$TARGETOS/$TARGETARCH yarn AS yarnbuild
WORKDIR /build
# Copy full code
COPY --exclude=Caddyfile --exclude=docker/ . ./
COPY --exclude=docker/ . ./
COPY --from=composer /build .
RUN yarn run build
@@ -62,38 +62,34 @@ FROM --platform=$TARGETOS/$TARGETARCH localhost:5000/base-php:$TARGETARCH AS fin
WORKDIR /var/www/html
# Install additional required libraries
RUN apk add --no-cache \
caddy ca-certificates supervisor supercronic fcgi
# packages for running the panel
caddy ca-certificates supervisor supercronic fcgi \
# required for installing plugins. Pulled from https://github.com/pelican-dev/panel/pull/2034
zip unzip 7zip bzip2-dev yarn git
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
COPY --chown=root:www-data --chmod=770 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=770 --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 /pelican-data/plugins /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, storage, and plugins
&& 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 \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
&& ln -s /pelican-data/plugins /var/www/html/plugins \
# 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 \
&& chown -R www-data: /usr/local/etc/php/
# Create and remove directories
RUN mkdir -p /pelican-data/storage /pelican-data/plugins /var/run/supervisord \
&& rm -rf /var/www/html/plugins \
# Symlinks for env, database, storage, and plugins
&& ln -s /pelican-data/.env /var/www/html/.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -s /pelican-data/storage /var/www/html/public/storage \
&& ln -s /pelican-data/storage /var/www/html/storage/app/public \
&& ln -s /pelican-data/plugins /var/www/html \
# Allow www-data write permissions where necessary
&& chown -R www-data: /pelican-data .env ./storage ./plugins ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/
# 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/crontab /etc/crontabs/crontab
COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/healthcheck.sh /healthcheck.sh

View File

@@ -5,6 +5,6 @@ FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
RUN install-php-extensions bcmath gd intl zip pcntl pdo_mysql pdo_pgsql bz2
RUN rm /usr/local/bin/install-php-extensions

View File

@@ -5,7 +5,7 @@ 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 install-php-extensions bcmath gd intl zip pcntl pdo_mysql pdo_pgsql bz2
RUN rm /usr/local/bin/install-php-extensions
@@ -42,7 +42,7 @@ RUN yarn config set network-timeout 300000 \
FROM --platform=$TARGETOS/$TARGETARCH composer AS composerbuild
# Copy full code to optimize autoload
COPY --exclude=Caddyfile --exclude=docker/ . ./
COPY --exclude=docker/ . ./
RUN composer dump-autoload --optimize
@@ -54,7 +54,7 @@ FROM --platform=$TARGETOS/$TARGETARCH yarn AS yarnbuild
WORKDIR /build
# Copy full code
COPY --exclude=Caddyfile --exclude=docker/ . ./
COPY --exclude=docker/ . ./
COPY --from=composer /build .
RUN yarn run build
@@ -68,36 +68,33 @@ WORKDIR /var/www/html
# Install additional required libraries
RUN apk add --no-cache \
caddy ca-certificates supervisor supercronic fcgi coreutils
# packages for running the panel
caddy ca-certificates supervisor supercronic fcgi coreutils \
# required for installing plugins. Pulled from https://github.com/pelican-dev/panel/pull/2034
zip unzip 7zip bzip2-dev yarn git
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
COPY --chown=root:www-data --chmod=770 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=770 --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 /pelican-data/plugins /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, storage, and plugins
&& 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 \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
&& ln -s /pelican-data/plugins /var/www/html/plugins \
# 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 \
&& chown -R www-data: /usr/local/etc/php/
# Create and remove directories
RUN mkdir -p /pelican-data/storage /pelican-data/plugins /var/run/supervisord \
&& rm -rf /var/www/html/plugins \
# Symlinks for env, database, storage, and plugins
&& ln -s /pelican-data/.env /var/www/html/.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -s /pelican-data/storage /var/www/html/public/storage \
&& ln -s /pelican-data/storage /var/www/html/storage/app/public \
&& ln -s /pelican-data/plugins /var/www/html \
# Allow www-data write permissions where necessary
&& chown -R www-data: /pelican-data .env ./storage ./plugins ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R 770 /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/ /usr/local/etc/php-fpm.d/
# 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/crontab /etc/crontabs/crontab
COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/healthcheck.sh /healthcheck.sh

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Console\Commands\Overrides;
use Illuminate\Foundation\Console\ConfigCacheCommand as BaseConfigCacheCommand;
class ConfigCacheCommand extends BaseConfigCacheCommand
{
/**
* Prevent config from being cached
*/
public function handle()
{
$this->components->warn('Configuration caching has been disabled.');
$this->line(' Reason: This application uses dynamic plugins. Caching config');
$this->line(' prevents /plugins/config/*.php files from being loaded correctly.');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Console\Commands\Overrides;
use Illuminate\Foundation\Console\OptimizeCommand as BaseOptimizeCommand;
class OptimizeCommand extends BaseOptimizeCommand
{
/**
* Prevent config from being cached
*
* @return array<string, string>
*/
protected function getOptimizeTasks()
{
return array_except(parent::getOptimizeTasks(), 'config');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands\Plugin;
use App\Enums\PluginStatus;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use Exception;
use Illuminate\Console\Command;
class InstallPluginCommand extends Command
@@ -31,8 +32,12 @@ class InstallPluginCommand extends Command
return;
}
$pluginService->installPlugin($plugin);
try {
$pluginService->installPlugin($plugin);
$this->info('Plugin installed and enabled.');
$this->info('Plugin installed and enabled.');
} catch (Exception $exception) {
$this->error('Could not install plugin: ' . $exception->getMessage());
}
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands\Plugin;
use App\Enums\PluginStatus;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use Exception;
use Illuminate\Console\Command;
class UninstallPluginCommand extends Command
@@ -36,8 +37,12 @@ class UninstallPluginCommand extends Command
$deleteFiles = $this->confirm('Do you also want to delete the plugin files?');
}
$pluginService->uninstallPlugin($plugin, $deleteFiles);
try {
$pluginService->uninstallPlugin($plugin, $deleteFiles);
$this->info('Plugin uninstalled' . ($deleteFiles ? ' and files deleted' : '') . '.');
$this->info('Plugin uninstalled' . ($deleteFiles ? ' and files deleted' : '') . '.');
} catch (Exception $exception) {
$this->error('Could not uninstall plugin: ' . $exception->getMessage());
}
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands\Plugin;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use Exception;
use Illuminate\Console\Command;
class UpdatePluginCommand extends Command
@@ -30,8 +31,12 @@ class UpdatePluginCommand extends Command
return;
}
$pluginService->updatePlugin($plugin);
try {
$pluginService->updatePlugin($plugin);
$this->info('Plugin updated.');
$this->info('Plugin updated.');
} catch (Exception $exception) {
$this->error('Could not update plugin: ' . $exception->getMessage());
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Enums;
use App\Models\Server;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Support\Facades\RateLimiter;
use Webmozart\Assert\Assert;
/**
* A basic resource throttler for individual servers. This is applied in addition
* to existing rate limits and allows the code to slow down speedy users that might
* be creating resources a little too quickly for comfort. This throttle generally
* only applies to creation flows, and not general view/edit/delete flows.
*/
enum ResourceLimit: string
{
case Websocket = 'websocket';
case AllocationCreate = 'allocation-create';
case BackupRestore = 'backup-restore';
case DatabaseCreate = 'database-create';
case ScheduleCreate = 'schedule-create';
case SubuserCreate = 'subuser-create';
case FilePull = 'file-pull';
public function throttleKey(): string
{
return "api.client:server-resource:{$this->name}";
}
/**
* Returns a middleware that will throttle the specific resource by server. This
* throttle applies to any user making changes to that resource on the specific
* server, it is NOT per-user.
*/
public function middleware(): string
{
return ThrottleRequests::using($this->throttleKey());
}
public function limit(): Limit
{
return match ($this) {
self::Websocket => Limit::perMinute(5),
self::BackupRestore => Limit::perMinutes(15, 3),
self::DatabaseCreate => Limit::perMinute(2),
self::SubuserCreate => Limit::perMinutes(15, 10),
self::FilePull => Limit::perMinutes(10, 5),
default => Limit::perMinute(2),
};
}
public static function boot(): void
{
foreach (self::cases() as $case) {
RateLimiter::for($case->throttleKey(), function (Request $request) use ($case) {
Assert::isInstanceOf($server = $request->route()->parameter('server'), Server::class);
return $case->limit()->by($server->uuid);
});
}
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum StepPosition: string
{
case Before = 'before';
case After = 'after';
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum TabPosition: string
{
case Before = 'before';
case After = 'after';
}

View File

@@ -2,8 +2,10 @@
namespace App\Extensions\OAuth;
use App\Models\User;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Wizard\Step;
use Laravel\Socialite\Contracts\User as OAuthUser;
interface OAuthSchemaInterface
{
@@ -33,7 +35,7 @@ interface OAuthSchemaInterface
public function isEnabled(): bool;
public function shouldCreateMissingUsers(): bool;
public function shouldCreateMissingUser(OAuthUser $user): bool;
public function shouldLinkMissingUsers(): bool;
public function shouldLinkMissingUser(User $user, OAuthUser $oauthUser): bool;
}

View File

@@ -18,11 +18,11 @@ final class GithubSchema extends OAuthSchema
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Github OAuth App')
Step::make('Register new GitHub OAuth App')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://github.com/settings/developers" target="_blank">Github Developer Dashboard</x-filament::link>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>'))),
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://github.com/settings/developers" target="_blank">GitHub Developer Dashboard</x-filament::link>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>'))),
TextInput::make('_noenv_callback')
->label('Authorization callback URL')
->dehydrated()

View File

@@ -3,12 +3,14 @@
namespace App\Extensions\OAuth\Schemas;
use App\Extensions\OAuth\OAuthSchemaInterface;
use App\Models\User;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as OAuthUser;
abstract class OAuthSchema implements OAuthSchemaInterface
{
@@ -121,14 +123,14 @@ abstract class OAuthSchema implements OAuthSchemaInterface
return env("OAUTH_{$id}_ENABLED", false);
}
public function shouldCreateMissingUsers(): bool
public function shouldCreateMissingUser(OAuthUser $user): bool
{
$id = Str::upper($this->getId());
return env("OAUTH_{$id}_SHOULD_CREATE_MISSING_USERS", false);
}
public function shouldLinkMissingUsers(): bool
public function shouldLinkMissingUser(User $user, OAuthUser $oauthUser): bool
{
$id = Str::upper($this->getId());

View File

@@ -56,7 +56,9 @@ class ListLogs extends BaseListLogs
->modalHeading(trans('admin/log.actions.upload_logs'))
->modalDescription(fn ($record) => trans('admin/log.actions.upload_logs_description', ['file' => $record['date'], 'url' => 'https://logs.pelican.dev']))
->action(function ($record) {
$logPath = storage_path('logs/' . $record['date']);
$prefix = config('filament-log-viewer.pattern.prefix', 'laravel-');
$extension = config('filament-log-viewer.pattern.extension', '.log');
$logPath = storage_path('logs/' . $prefix . $record['date'] . $extension);
if (!file_exists($logPath)) {
Notification::make()
@@ -73,18 +75,12 @@ class ListLogs extends BaseListLogs
$uploadLines = $totalLines <= 1000 ? $lines : array_slice($lines, -1000);
$content = implode("\n", $uploadLines);
$logUrl = 'https://logs.pelican.dev';
try {
$response = Http::timeout(10)->asMultipart()->post($logUrl, [
[
'name' => 'c',
'contents' => $content,
],
[
'name' => 'e',
'contents' => '14d',
],
]);
$response = Http::timeout(10)
->asMultipart()
->attach('c', $content)
->attach('e', '14d')
->post('https://logs.pelican.dev');
if ($response->failed()) {
Notification::make()

View File

@@ -10,6 +10,7 @@ use App\Notifications\MailTested;
use App\Traits\EnvironmentWriterTrait;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use App\Traits\Filament\CanCustomizeTabs;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
@@ -53,6 +54,7 @@ class Settings extends Page implements HasSchemas
CanCustomizeHeaderActions::getHeaderActions insteadof InteractsWithHeaderActions;
}
use CanCustomizeHeaderWidgets;
use CanCustomizeTabs;
use EnvironmentWriterTrait;
use InteractsWithForms;
@@ -96,11 +98,7 @@ class Settings extends Page implements HasSchemas
return trans('admin/setting.title');
}
/**
* @return array<Component>
*
* @throws Exception
*/
/** @return array<Component> */
protected function getFormSchema(): array
{
return [
@@ -108,34 +106,44 @@ class Settings extends Page implements HasSchemas
->columns()
->persistTabInQueryString()
->disabled(fn () => !user()?->can('update settings'))
->tabs([
Tab::make('general')
->label(trans('admin/setting.navigation.general'))
->icon('tabler-home')
->schema($this->generalSettings()),
Tab::make('captcha')
->label(trans('admin/setting.navigation.captcha'))
->icon('tabler-shield')
->schema($this->captchaSettings())
->columns(1),
Tab::make('mail')
->label(trans('admin/setting.navigation.mail'))
->icon('tabler-mail')
->schema($this->mailSettings()),
Tab::make('backup')
->label(trans('admin/setting.navigation.backup'))
->icon('tabler-box')
->schema($this->backupSettings()),
Tab::make('oauth')
->label(trans('admin/setting.navigation.oauth'))
->icon('tabler-brand-oauth')
->schema($this->oauthSettings())
->columns(1),
Tab::make('misc')
->label(trans('admin/setting.navigation.misc'))
->icon('tabler-tool')
->schema($this->miscSettings()),
]),
->tabs($this->getTabs()),
];
}
/**
* @return Tab[]
*
* @throws Exception
*/
protected function getDefaultTabs(): array
{
return [
Tab::make('general')
->label(trans('admin/setting.navigation.general'))
->icon('tabler-home')
->schema($this->generalSettings()),
Tab::make('captcha')
->label(trans('admin/setting.navigation.captcha'))
->icon('tabler-shield')
->schema($this->captchaSettings())
->columns(1),
Tab::make('mail')
->label(trans('admin/setting.navigation.mail'))
->icon('tabler-mail')
->schema($this->mailSettings()),
Tab::make('backup')
->label(trans('admin/setting.navigation.backup'))
->icon('tabler-box')
->schema($this->backupSettings()),
Tab::make('oauth')
->label(trans('admin/setting.navigation.oauth'))
->icon('tabler-brand-oauth')
->schema($this->oauthSettings())
->columns(1),
Tab::make('misc')
->label(trans('admin/setting.navigation.misc'))
->icon('tabler-tool')
->schema($this->miscSettings()),
];
}
@@ -256,7 +264,7 @@ class Settings extends Page implements HasSchemas
->connectTimeout(3)
->get('https://api.cloudflare.com/client/v4/ips');
if ($response->getStatusCode() === 200) {
if ($response->status() === 200) {
$result = $response->json('result');
foreach (['ipv4_cidrs', 'ipv6_cidrs'] as $value) {
$ips->push(...data_get($result, $value));
@@ -823,7 +831,6 @@ class Settings extends Page implements HasSchemas
$this->writeToEnvironment($data);
Artisan::call('config:clear');
Artisan::call('queue:restart');
$this->redirect($this->getUrl());

View File

@@ -33,7 +33,9 @@ class ViewLogs extends BaseViewLog
->modalHeading(trans('admin/log.actions.upload_logs'))
->modalDescription(fn () => trans('admin/log.actions.upload_logs_description', ['file' => $this->resolveRecordDate(), 'url' => 'https://logs.pelican.dev']))
->action(function () {
$logPath = storage_path('logs/' . $this->resolveRecordDate());
$prefix = config('filament-log-viewer.pattern.prefix', 'laravel-');
$extension = config('filament-log-viewer.pattern.extension', '.log');
$logPath = storage_path('logs/' . $prefix . $this->resolveRecordDate() . $extension);
if (!file_exists($logPath)) {
Notification::make()
@@ -50,18 +52,12 @@ class ViewLogs extends BaseViewLog
$uploadLines = $totalLines <= 1000 ? $lines : array_slice($lines, -1000);
$content = implode("\n", $uploadLines);
$logUrl = 'https://logs.pelican.dev';
try {
$response = Http::timeout(10)->asMultipart()->post($logUrl, [
[
'name' => 'c',
'contents' => $content,
],
[
'name' => 'e',
'contents' => '14d',
],
]);
$response = Http::timeout(10)
->asMultipart()
->attach('c', $content)
->attach('e', '14d')
->post('https://logs.pelican.dev');
if ($response->failed()) {
Notification::make()

View File

@@ -37,7 +37,7 @@ class DatabasesRelationManager extends RelationManager
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? trans('admin/databasehost.anywhere'). ' ( % )' : $record->remote),
TextInput::make('max_connections')
->label(trans('admin/databasehost.table.max_connections'))
->formatStateUsing(fn (Database $record) => $record->max_connections === 0 ? trans('admin/databasehost.unlimited') : $record->max_connections),
->formatStateUsing(fn (Database $record) => $record->max_connections ?: trans('admin/databasehost.unlimited')),
TextInput::make('jdbc')
->label(trans('admin/databasehost.table.connection_string'))
->columnSpanFull()
@@ -63,7 +63,7 @@ class DatabasesRelationManager extends RelationManager
->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])),
TextColumn::make('max_connections')
->label(trans('admin/databasehost.table.max_connections'))
->formatStateUsing(fn ($record) => $record->max_connections === 0 ? trans('admin/databasehost.unlimited') : $record->max_connections),
->formatStateUsing(fn ($record) => $record->max_connections ?: trans('server/database.unlimited')),
DateTimeColumn::make('created_at')
->label(trans('admin/databasehost.table.created_at')),
])

View File

@@ -9,7 +9,7 @@ use App\Filament\Components\Forms\Fields\MonacoEditor;
use App\Models\EggVariable;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Exception;
use App\Traits\Filament\CanCustomizeTabs;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\Checkbox;
@@ -37,6 +37,7 @@ class CreateEgg extends CreateRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
use CanCustomizeTabs;
protected static string $resource = EggResource::class;
@@ -57,227 +58,231 @@ class CreateEgg extends CreateRecord
return [];
}
/**
* @throws Exception
*/
public function form(Schema $schema): Schema
{
return $schema
->components([
Tabs::make()->tabs([
Tab::make('configuration')
->label(trans('admin/egg.tabs.configuration'))
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
Tabs::make()
->tabs($this->getTabs())
->columnSpanFull()
->persistTabInQueryString(),
]);
}
/** @return Tab[] */
protected function getDefaultTabs(): array
{
return [
Tab::make('configuration')
->label(trans('admin/egg.tabs.configuration'))
->columns(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 4])
->schema([
TextInput::make('name')
->label(trans('admin/egg.name'))
->required()
->maxLength(255)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText(trans('admin/egg.name_help')),
TextInput::make('author')
->label(trans('admin/egg.author'))
->maxLength(255)
->required()
->email()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText(trans('admin/egg.author_help')),
Textarea::make('description')
->label(trans('admin/egg.description'))
->rows(2)
->columnSpanFull()
->helperText(trans('admin/egg.description_help')),
KeyValue::make('startup_commands')
->label(trans('admin/egg.startup_commands'))
->live()
->columnSpanFull()
->required()
->addActionLabel(trans('admin/egg.add_startup'))
->keyLabel(trans('admin/egg.startup_name'))
->keyPlaceholder('Default')
->valueLabel(trans('admin/egg.startup_command'))
->valuePlaceholder('java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}')
->helperText(trans('admin/egg.startup_help')),
TagsInput::make('file_denylist')
->label(trans('admin/egg.file_denylist'))
->placeholder('denied-file.txt')
->helperText(trans('admin/egg.file_denylist_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TagsInput::make('features')
->label(trans('admin/egg.features'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 1]),
Toggle::make('force_outgoing_ip')
->label(trans('admin/egg.force_ip'))
->hintIcon('tabler-question-mark', trans('admin/egg.force_ip_help')),
Hidden::make('script_is_privileged')
->default(1),
TagsInput::make('tags')
->label(trans('admin/egg.tags'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TextInput::make('update_url')
->label(trans('admin/egg.update_url'))
->hintIcon('tabler-question-mark', trans('admin/egg.update_url_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->url(),
KeyValue::make('docker_images')
->label(trans('admin/egg.docker_images'))
->live()
->columnSpanFull()
->required()
->addActionLabel(trans('admin/egg.add_image'))
->keyLabel(trans('admin/egg.docker_name'))
->keyPlaceholder('Java 21')
->valueLabel(trans('admin/egg.docker_uri'))
->valuePlaceholder('ghcr.io/pelican-eggs/yolks:java_21')
->helperText(trans('admin/egg.docker_help')),
]),
Tab::make('process_management')
->label(trans('admin/egg.tabs.process_management'))
->columns()
->schema([
CopyFrom::make('copy_process_from')
->process(),
TextInput::make('config_stop')
->label(trans('admin/egg.stop_command'))
->required()
->maxLength(255)
->helperText(trans('admin/egg.stop_command_help')),
Textarea::make('config_startup')->rows(10)->json()
->label(trans('admin/egg.start_config'))
->default('{}')
->helperText(trans('admin/egg.start_config_help')),
Textarea::make('config_files')->rows(10)->json()
->label(trans('admin/egg.config_files'))
->default('{}')
->helperText(trans('admin/egg.config_files_help')),
Textarea::make('config_logs')->rows(10)->json()
->label(trans('admin/egg.log_config'))
->default('{}')
->helperText(trans('admin/egg.log_config_help')),
]),
Tab::make('egg_variables')
->label(trans('admin/egg.tabs.egg_variables'))
->columnSpanFull()
->schema([
Repeater::make('variables')
->hiddenLabel()
->addActionLabel(trans('admin/egg.add_new_variable'))
->grid()
->relationship('variables')
->reorderable()->orderColumn()
->collapsible()->collapsed()
->columnSpan(2)
->defaultItems(0)
->itemLabel(fn (array $state) => $state['name'])
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
return $data;
})
->mutateRelationshipDataBeforeSaveUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
return $data;
})
->schema([
TextInput::make('name')
->label(trans('admin/egg.name'))
->required()
->maxLength(255)
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText(trans('admin/egg.name_help')),
TextInput::make('author')
->label(trans('admin/egg.author'))
->maxLength(255)
->required()
->email()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->helperText(trans('admin/egg.author_help')),
Textarea::make('description')
->label(trans('admin/egg.description'))
->rows(2)
->columnSpanFull()
->helperText(trans('admin/egg.description_help')),
KeyValue::make('startup_commands')
->label(trans('admin/egg.startup_commands'))
->live()
->columnSpanFull()
->required()
->addActionLabel(trans('admin/egg.add_startup'))
->keyLabel(trans('admin/egg.startup_name'))
->keyPlaceholder('Default')
->valueLabel(trans('admin/egg.startup_command'))
->valuePlaceholder('java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}')
->helperText(trans('admin/egg.startup_help')),
TagsInput::make('file_denylist')
->label(trans('admin/egg.file_denylist'))
->placeholder('denied-file.txt')
->helperText(trans('admin/egg.file_denylist_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TagsInput::make('features')
->label(trans('admin/egg.features'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 1]),
Toggle::make('force_outgoing_ip')
->label(trans('admin/egg.force_ip'))
->hintIcon('tabler-question-mark', trans('admin/egg.force_ip_help')),
Hidden::make('script_is_privileged')
->default(1),
TagsInput::make('tags')
->label(trans('admin/egg.tags'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TextInput::make('update_url')
->label(trans('admin/egg.update_url'))
->hintIcon('tabler-question-mark', trans('admin/egg.update_url_help'))
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->url(),
KeyValue::make('docker_images')
->label(trans('admin/egg.docker_images'))
->live()
->columnSpanFull()
->required()
->addActionLabel(trans('admin/egg.add_image'))
->keyLabel(trans('admin/egg.docker_name'))
->keyPlaceholder('Java 21')
->valueLabel(trans('admin/egg.docker_uri'))
->valuePlaceholder('ghcr.io/pelican-eggs/yolks:java_21')
->helperText(trans('admin/egg.docker_help')),
]),
Tab::make('process_management')
->label(trans('admin/egg.tabs.process_management'))
->columns()
->schema([
CopyFrom::make('copy_process_from')
->process(),
TextInput::make('config_stop')
->label(trans('admin/egg.stop_command'))
->required()
->debounce(750)
->maxLength(255)
->helperText(trans('admin/egg.stop_command_help')),
Textarea::make('config_startup')->rows(10)->json()
->label(trans('admin/egg.start_config'))
->default('{}')
->helperText(trans('admin/egg.start_config_help')),
Textarea::make('config_files')->rows(10)->json()
->label(trans('admin/egg.config_files'))
->default('{}')
->helperText(trans('admin/egg.config_files_help')),
Textarea::make('config_logs')->rows(10)->json()
->label(trans('admin/egg.log_config'))
->default('{}')
->helperText(trans('admin/egg.log_config_help')),
]),
Tab::make('egg_variables')
->label(trans('admin/egg.tabs.egg_variables'))
->columnSpanFull()
->schema([
Repeater::make('variables')
->hiddenLabel()
->addActionLabel(trans('admin/egg.add_new_variable'))
->grid()
->relationship('variables')
->reorderable()->orderColumn()
->collapsible()->collapsed()
->columnSpan(2)
->defaultItems(0)
->itemLabel(fn (array $state) => $state['name'])
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
return $data;
})
->mutateRelationshipDataBeforeSaveUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
return $data;
})
->schema([
TextInput::make('name')
->label(trans('admin/egg.name'))
->live()
->debounce(750)
->maxLength(255)
->columnSpanFull()
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString()))
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
])
->required(),
Textarea::make('description')->label(trans('admin/egg.description'))->columnSpanFull(),
TextInput::make('env_variable')
->label(trans('admin/egg.environment_variable'))
->maxLength(255)
->prefix('{{')
->suffix('}}')
->hintIcon('tabler-code', fn ($state) => "{{{$state}}}")
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')))
->rules(EggVariable::getRulesForField('env_variable'))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
'required' => trans('admin/egg.error_required'),
'*' => trans('admin/egg.error_reserved'),
])
->required(),
TextInput::make('default_value')->label(trans('admin/egg.default_value')),
Fieldset::make(trans('admin/egg.user_permissions'))
->schema([
Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')),
Checkbox::make('user_editable')->label(trans('admin/egg.editable')),
]),
TagsInput::make('rules')
->label(trans('admin/egg.rules'))
->columnSpanFull()
->reorderable()
->suggestions([
'required',
'nullable',
'string',
'integer',
'numeric',
'boolean',
'alpha',
'alpha_dash',
'alpha_num',
'url',
'email',
'regex:',
'min:',
'max:',
'between:',
'between:1024,65535',
'in:',
'in:true,false',
]),
]),
]),
Tab::make('install_script')
->label(trans('admin/egg.tabs.install_script'))
->columns(3)
->schema([
CopyFrom::make('copy_script_from')
->script(),
TextInput::make('script_container')
->label(trans('admin/egg.script_container'))
->required()
->maxLength(255)
->default('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->selectablePlaceholder(false)
->default('bash')
->options([
'bash' => 'bash',
'ash' => 'ash',
'/bin/bash' => '/bin/bash',
->columnSpanFull()
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString()))
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
])
->required(),
MonacoEditor::make('script_install')
->label(trans('admin/egg.script_install'))
->language(EditorLanguages::shell)
Textarea::make('description')->label(trans('admin/egg.description'))->columnSpanFull(),
TextInput::make('env_variable')
->label(trans('admin/egg.environment_variable'))
->maxLength(255)
->prefix('{{')
->suffix('}}')
->hintIcon('tabler-code', fn ($state) => "{{{$state}}}")
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')))
->rules(EggVariable::getRulesForField('env_variable'))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
'required' => trans('admin/egg.error_required'),
'*' => trans('admin/egg.error_reserved'),
])
->required(),
TextInput::make('default_value')->label(trans('admin/egg.default_value')),
Fieldset::make(trans('admin/egg.user_permissions'))
->schema([
Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')),
Checkbox::make('user_editable')->label(trans('admin/egg.editable')),
]),
TagsInput::make('rules')
->label(trans('admin/egg.rules'))
->columnSpanFull()
->lazy(),
->reorderable()
->suggestions([
'required',
'nullable',
'string',
'integer',
'numeric',
'boolean',
'alpha',
'alpha_dash',
'alpha_num',
'url',
'email',
'regex:',
'min:',
'max:',
'between:',
'between:1024,65535',
'in:',
'in:true,false',
]),
]),
])->columnSpanFull()->persistTabInQueryString(),
]);
]),
Tab::make('install_script')
->label(trans('admin/egg.tabs.install_script'))
->columns(3)
->schema([
CopyFrom::make('copy_script_from')
->script(),
TextInput::make('script_container')
->label(trans('admin/egg.script_container'))
->required()
->maxLength(255)
->default('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->selectablePlaceholder(false)
->default('bash')
->options([
'bash' => 'bash',
'ash' => 'ash',
'/bin/bash' => '/bin/bash',
])
->required(),
MonacoEditor::make('script_install')
->label(trans('admin/egg.script_install'))
->language(EditorLanguages::shell)
->columnSpanFull()
->lazy(),
]),
];
}
protected function handleRecordCreation(array $data): Model

View File

@@ -12,6 +12,7 @@ use App\Models\Egg;
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;
@@ -47,391 +48,398 @@ class EditEgg extends EditRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
use CanCustomizeTabs;
protected static string $resource = EggResource::class;
/**
* @throws Exception
*/
public function form(Schema $schema): Schema
{
return $schema
->components([
Tabs::make()->tabs([
Tab::make('configuration')
->label(trans('admin/egg.tabs.configuration'))
->columns(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 6])
->icon('tabler-egg')
Tabs::make()
->tabs($this->getTabs())
->columnSpanFull()
->persistTabInQueryString(),
]);
}
/** @return Tab[] */
protected function getDefaultTabs(): array
{
return [
Tab::make('configuration')
->label(trans('admin/egg.tabs.configuration'))
->columns(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 6])
->icon('tabler-egg')
->schema([
Grid::make(2)
->columnSpan(1)
->schema([
Grid::make(2)
->columnSpan(1)
->schema([
Image::make('', '')
->hidden(fn ($record) => !$record->image)
->url(fn ($record) => $record->image)
->alt('')
->alignJustify()
->imageSize(150)
->columnSpanFull(),
Flex::make([
Action::make('uploadImage')
->iconButton()
->iconSize(IconSize::Large)
->icon('tabler-photo-up')
->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);
Image::make('', '')
->hidden(fn ($record) => !$record->image)
->url(fn ($record) => $record->image)
->alt('')
->alignJustify()
->imageSize(150)
->columnSpanFull(),
Flex::make([
Action::make('uploadImage')
->iconButton()
->iconSize(IconSize::Large)
->icon('tabler-photo-up')
->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;
}
return;
}
try {
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
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));
$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))]));
}
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);
$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'));
}
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);
$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();
}),
]),
} 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(),
]),
])
->action(function (array $data, $record): void {
if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) {
$this->saveImageFromUrl($data['imageUrl'], $data['imageExtension'], $record);
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();
Notification::make()
->title(trans('admin/egg.import.image_updated'))
->success()
->send();
return;
}
return;
}
if (!empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.image_updated'))
->success()
->send();
if (!empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.image_updated'))
->success()
->send();
return;
}
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()
->icon('tabler-trash')
->iconButton()
->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);
}
}
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()
->icon('tabler-trash')
->iconButton()
->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();
Notification::make()
->title(trans('admin/egg.import.image_deleted'))
->success()
->send();
$record->refresh();
}),
]),
]),
$record->refresh();
}),
]),
]),
TextInput::make('name')
->label(trans('admin/egg.name'))
->required()
->maxLength(255)
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 3, 'lg' => 2])
->helperText(trans('admin/egg.name_help')),
Textarea::make('description')
->label(trans('admin/egg.description'))
->rows(3)
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 3])
->helperText(trans('admin/egg.description_help')),
TextInput::make('id')
->label(trans('admin/egg.egg_id'))
->columnSpan(1)
->disabled(),
TextInput::make('uuid')
->label(trans('admin/egg.egg_uuid'))
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
->helperText(trans('admin/egg.uuid_help')),
TextInput::make('author')
->label(trans('admin/egg.author'))
->required()
->maxLength(255)
->email()
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
->helperText(trans('admin/egg.author_help_edit')),
Toggle::make('force_outgoing_ip')
->inline(false)
->label(trans('admin/egg.force_ip'))
->columnSpan(1)
->hintIcon('tabler-question-mark', trans('admin/egg.force_ip_help')),
KeyValue::make('startup_commands')
->label(trans('admin/egg.startup_commands'))
->live()
->columnSpanFull()
->required()
->addActionLabel(trans('admin/egg.add_startup'))
->keyLabel(trans('admin/egg.startup_name'))
->valueLabel(trans('admin/egg.startup_command'))
->helperText(trans('admin/egg.startup_help')),
TagsInput::make('file_denylist')
->label(trans('admin/egg.file_denylist'))
->placeholder('denied-file.txt')
->helperText(trans('admin/egg.file_denylist_help'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
TextInput::make('update_url')
->label(trans('admin/egg.update_url'))
->url()
->hintIcon('tabler-question-mark', trans('admin/egg.update_url_help'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
TagsInput::make('features')
->label(trans('admin/egg.features'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
Hidden::make('script_is_privileged')
->helperText('The docker images available to servers using this egg.'),
TagsInput::make('tags')
->label(trans('admin/egg.tags'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
KeyValue::make('docker_images')
->label(trans('admin/egg.docker_images'))
->live()
->columnSpanFull()
->required()
->addActionLabel(trans('admin/egg.add_image'))
->keyLabel(trans('admin/egg.docker_name'))
->valueLabel(trans('admin/egg.docker_uri'))
->helperText(trans('admin/egg.docker_help')),
]),
Tab::make('process_management')
->label(trans('admin/egg.tabs.process_management'))
->columns()
->icon('tabler-server-cog')
->schema([
CopyFrom::make('copy_process_from')
->process(),
TextInput::make('config_stop')
->label(trans('admin/egg.stop_command'))
->maxLength(255)
->helperText(trans('admin/egg.stop_command_help')),
Textarea::make('config_startup')->rows(10)->json()
->label(trans('admin/egg.start_config'))
->helperText(trans('admin/egg.start_config_help')),
Textarea::make('config_files')->rows(10)->json()
->label(trans('admin/egg.config_files'))
->helperText(trans('admin/egg.config_files_help')),
Textarea::make('config_logs')->rows(10)->json()
->label(trans('admin/egg.log_config'))
->helperText(trans('admin/egg.log_config_help')),
]),
Tab::make('egg_variables')
->label(trans('admin/egg.tabs.egg_variables'))
->columnSpanFull()
->icon('tabler-variable')
->schema([
Repeater::make('variables')
->hiddenLabel()
->grid()
->relationship('variables')
->reorderable()
->collapsible()->collapsed()
->orderColumn()
->addActionLabel(trans('admin/egg.add_new_variable'))
->itemLabel(fn (array $state) => $state['name'])
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
return $data;
})
->mutateRelationshipDataBeforeSaveUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
return $data;
})
->schema([
TextInput::make('name')
->label(trans('admin/egg.name'))
->required()
->maxLength(255)
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 3, 'lg' => 2])
->helperText(trans('admin/egg.name_help')),
Textarea::make('description')
->label(trans('admin/egg.description'))
->rows(3)
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 4, 'lg' => 3])
->helperText(trans('admin/egg.description_help')),
TextInput::make('id')
->label(trans('admin/egg.egg_id'))
->columnSpan(1)
->disabled(),
TextInput::make('uuid')
->label(trans('admin/egg.egg_uuid'))
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
->helperText(trans('admin/egg.uuid_help')),
TextInput::make('author')
->label(trans('admin/egg.author'))
->required()
->maxLength(255)
->email()
->disabled()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
->helperText(trans('admin/egg.author_help_edit')),
Toggle::make('force_outgoing_ip')
->inline(false)
->label(trans('admin/egg.force_ip'))
->columnSpan(1)
->hintIcon('tabler-question-mark', trans('admin/egg.force_ip_help')),
KeyValue::make('startup_commands')
->label(trans('admin/egg.startup_commands'))
->live()
->columnSpanFull()
->required()
->addActionLabel(trans('admin/egg.add_startup'))
->keyLabel(trans('admin/egg.startup_name'))
->valueLabel(trans('admin/egg.startup_command'))
->helperText(trans('admin/egg.startup_help')),
TagsInput::make('file_denylist')
->label(trans('admin/egg.file_denylist'))
->placeholder('denied-file.txt')
->helperText(trans('admin/egg.file_denylist_help'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
TextInput::make('update_url')
->label(trans('admin/egg.update_url'))
->url()
->hintIcon('tabler-question-mark', trans('admin/egg.update_url_help'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
TagsInput::make('features')
->label(trans('admin/egg.features'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
Hidden::make('script_is_privileged')
->helperText('The docker images available to servers using this egg.'),
TagsInput::make('tags')
->label(trans('admin/egg.tags'))
->columnSpan(['default' => 2, 'sm' => 2, 'md' => 2, 'lg' => 3]),
KeyValue::make('docker_images')
->label(trans('admin/egg.docker_images'))
->live()
->columnSpanFull()
->required()
->addActionLabel(trans('admin/egg.add_image'))
->keyLabel(trans('admin/egg.docker_name'))
->valueLabel(trans('admin/egg.docker_uri'))
->helperText(trans('admin/egg.docker_help')),
]),
Tab::make('process_management')
->label(trans('admin/egg.tabs.process_management'))
->columns()
->icon('tabler-server-cog')
->schema([
CopyFrom::make('copy_process_from')
->process(),
TextInput::make('config_stop')
->label(trans('admin/egg.stop_command'))
->debounce(750)
->maxLength(255)
->helperText(trans('admin/egg.stop_command_help')),
Textarea::make('config_startup')->rows(10)->json()
->label(trans('admin/egg.start_config'))
->helperText(trans('admin/egg.start_config_help')),
Textarea::make('config_files')->rows(10)->json()
->label(trans('admin/egg.config_files'))
->helperText(trans('admin/egg.config_files_help')),
Textarea::make('config_logs')->rows(10)->json()
->label(trans('admin/egg.log_config'))
->helperText(trans('admin/egg.log_config_help')),
]),
Tab::make('egg_variables')
->label(trans('admin/egg.tabs.egg_variables'))
->columnSpanFull()
->icon('tabler-variable')
->schema([
Repeater::make('variables')
->hiddenLabel()
->grid()
->relationship('variables')
->reorderable()
->collapsible()->collapsed()
->orderColumn()
->addActionLabel(trans('admin/egg.add_new_variable'))
->itemLabel(fn (array $state) => $state['name'])
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
return $data;
})
->mutateRelationshipDataBeforeSaveUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
return $data;
})
->schema([
TextInput::make('name')
->label(trans('admin/egg.name'))
->live()
->debounce(750)
->maxLength(255)
->columnSpanFull()
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString()))
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
])
->required(),
Textarea::make('description')->label(trans('admin/egg.description'))->columnSpanFull(),
TextInput::make('env_variable')
->label(trans('admin/egg.environment_variable'))
->maxLength(255)
->prefix('{{')
->suffix('}}')
->hintIcon('tabler-code', fn ($state) => "{{{$state}}}")
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')))
->rules(EggVariable::getRulesForField('env_variable'))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
'required' => trans('admin/egg.error_required'),
'*' => trans('admin/egg.error_reserved'),
])
->required(),
TextInput::make('default_value')->label(trans('admin/egg.default_value')),
Fieldset::make(trans('admin/egg.user_permissions'))
->schema([
Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')),
Checkbox::make('user_editable')->label(trans('admin/egg.editable')),
]),
TagsInput::make('rules')
->label(trans('admin/egg.rules'))
->columnSpanFull()
->reorderable()
->suggestions([
'required',
'nullable',
'string',
'integer',
'numeric',
'boolean',
'alpha',
'alpha_dash',
'alpha_num',
'url',
'email',
'regex:',
'min:',
'max:',
'between:',
'between:1024,65535',
'in:',
'in:true,false',
]),
]),
]),
Tab::make('install_script')
->label(trans('admin/egg.tabs.install_script'))
->columns(3)
->icon('tabler-file-download')
->schema([
CopyFrom::make('copy_script_from')
->script(),
TextInput::make('script_container')
->label(trans('admin/egg.script_container'))
->required()
->maxLength(255)
->placeholder('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->selectablePlaceholder(false)
->options([
'bash' => 'bash',
'ash' => 'ash',
'/bin/bash' => '/bin/bash',
->columnSpanFull()
->afterStateUpdated(fn (Set $set, $state) => $set('env_variable', str($state)->trim()->snake()->upper()->toString()))
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
])
->required(),
MonacoEditor::make('script_install')
->hiddenLabel()
->language(EditorLanguages::shell)
->columnSpanFull(),
Textarea::make('description')->label(trans('admin/egg.description'))->columnSpanFull(),
TextInput::make('env_variable')
->label(trans('admin/egg.environment_variable'))
->maxLength(255)
->prefix('{{')
->suffix('}}')
->hintIcon('tabler-code', fn ($state) => "{{{$state}}}")
->unique(modifyRuleUsing: fn (Unique $rule, Get $get) => $rule->where('egg_id', $get('../../id')))
->rules(EggVariable::getRulesForField('env_variable'))
->validationMessages([
'unique' => trans('admin/egg.error_unique'),
'required' => trans('admin/egg.error_required'),
'*' => trans('admin/egg.error_reserved'),
])
->required(),
TextInput::make('default_value')->label(trans('admin/egg.default_value')),
Fieldset::make(trans('admin/egg.user_permissions'))
->schema([
Checkbox::make('user_viewable')->label(trans('admin/egg.viewable')),
Checkbox::make('user_editable')->label(trans('admin/egg.editable')),
]),
TagsInput::make('rules')
->label(trans('admin/egg.rules'))
->columnSpanFull()
->reorderable()
->suggestions([
'required',
'nullable',
'string',
'integer',
'numeric',
'boolean',
'alpha',
'alpha_dash',
'alpha_num',
'url',
'email',
'regex:',
'min:',
'max:',
'between:',
'between:1024,65535',
'in:',
'in:true,false',
]),
]),
])->columnSpanFull()->persistTabInQueryString(),
]);
]),
Tab::make('install_script')
->label(trans('admin/egg.tabs.install_script'))
->columns(3)
->icon('tabler-file-download')
->schema([
CopyFrom::make('copy_script_from')
->script(),
TextInput::make('script_container')
->label(trans('admin/egg.script_container'))
->required()
->maxLength(255)
->placeholder('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->label(trans('admin/egg.script_entry'))
->selectablePlaceholder(false)
->options([
'bash' => 'bash',
'ash' => 'ash',
'/bin/bash' => '/bin/bash',
])
->required(),
MonacoEditor::make('script_install')
->hiddenLabel()
->language(EditorLanguages::shell)
->columnSpanFull(),
]),
];
}
/** @return array<Action|ActionGroup> */
@@ -441,6 +449,16 @@ class EditEgg extends EditRecord
DeleteAction::make()
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? trans('filament-actions::delete.single.label') : trans('admin/egg.in_use'))
->successNotification(fn (Egg $egg) => Notification::make()
->success()
->title(trans('admin/egg.delete_success'))
->body(trans('admin/egg.deleted', ['egg' => $egg->name]))
)
->failureNotification(fn (Egg $egg) => Notification::make()
->danger()
->title(trans('admin/egg.delete_failed'))
->body(trans('admin/egg.could_not_delete', ['egg' => $egg->name]))
)
->iconButton()->iconSize(IconSize::ExtraLarge),
ExportEggAction::make(),
ImportEggAction::make()

View File

@@ -18,11 +18,13 @@ use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ReplicateAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class ListEggs extends ListRecords
@@ -88,15 +90,45 @@ class ListEggs extends ListRecords
])
->groupedBulkActions([
DeleteBulkAction::make()
->before(fn (&$records) => $records = $records->filter(function ($egg) {
/** @var Egg $egg */
return $egg->servers_count <= 0;
})),
->before(function (Collection &$records) {
$eggsWithServers = $records->filter(fn (Egg $egg) => $egg->servers_count > 0);
if ($eggsWithServers->isNotEmpty()) {
$eggNames = $eggsWithServers->map(fn (Egg $egg) => sprintf('%s (%d server%s)', $egg->name, $egg->servers_count, $egg->servers_count > 1 ? 's' : ''))
->join(', ');
Notification::make()
->danger()
->title(trans('admin/egg.cannot_delete', ['count' => $eggsWithServers->count()]))
->body(trans('admin/egg.eggs_have_servers', ['eggs' => $eggNames]))
->send();
}
$records = $records->filter(fn (Egg $egg) => $egg->servers_count <= 0);
if ($records->isEmpty()) {
$this->halt();
}
}),
UpdateEggBulkAction::make()
->before(fn (&$records) => $records = $records->filter(function ($egg) {
/** @var Egg $egg */
return cache()->get("eggs.$egg->uuid.update", false);
})),
->before(function (Collection &$records) {
$eggsWithoutUpdateUrl = $records->filter(fn (Egg $egg) => $egg->update_url === null);
if ($eggsWithoutUpdateUrl->isNotEmpty()) {
$eggNames = $eggsWithoutUpdateUrl->pluck('name')->join(', ');
Notification::make()
->warning()
->title(trans('admin/egg.cannot_update', ['count' => $eggsWithoutUpdateUrl->count()]))
->body(trans('admin/egg.no_update_url', ['eggs' => $eggNames]))
->send();
}
$records = $records->filter(fn (Egg $egg) => $egg->update_url !== null);
if ($records->isEmpty()) {
$this->halt();
}
}),
])
->emptyStateIcon('tabler-eggs')
->emptyStateDescription('')

View File

@@ -21,7 +21,6 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\StateCasts\BooleanStateCast;
use Filament\Schemas\Schema;
@@ -151,30 +150,32 @@ class MountResource extends Resource
->label(trans('admin/mount.description'))
->helperText(trans('admin/mount.description_help'))
->columnSpanFull(),
])->columnSpan(1)->columns([
'default' => 1,
'lg' => 2,
]),
Group::make()->schema([
Section::make()->schema([
Select::make('eggs')->multiple()
->label(trans('admin/mount.eggs'))
// Selecting only non-json fields to prevent Postgres from choking on DISTINCT JSON columns
->relationship('eggs', 'name', fn (Builder $query) => $query->select(['eggs.id', 'eggs.name']))
->preload(),
Select::make('nodes')->multiple()
->label(trans('admin/mount.nodes'))
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id')))
->searchable(['name', 'fqdn'])
->preload(),
])
->columnSpan([
'default' => 1,
'lg' => 2,
])
->columns([
'default' => 1,
'xl' => 2,
]),
])->columns([
'default' => 1,
'lg' => 2,
Section::make()->schema([
Select::make('eggs')
->multiple()
->label(trans('admin/mount.eggs'))
// Selecting only non-json fields to prevent Postgres from choking on DISTINCT JSON columns
->relationship('eggs', 'name', fn (Builder $query) => $query->select(['eggs.id', 'eggs.name']))
->preload(),
Select::make('nodes')
->multiple()
->label(trans('admin/mount.nodes'))
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id')))
->searchable(['name', 'fqdn'])
->preload(),
]),
])->columns([
'default' => 1,
'lg' => 2,
'lg' => 3,
]);
}

View File

@@ -6,7 +6,7 @@ use App\Filament\Admin\Resources\Nodes\NodeResource;
use App\Models\Node;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Exception;
use App\Traits\Filament\CanCustomizeSteps;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\TagsInput;
@@ -27,394 +27,397 @@ class CreateNode extends CreateRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
use CanCustomizeSteps;
protected static string $resource = NodeResource::class;
protected static bool $canCreateAnother = false;
/**
* @throws Exception
*/
public function form(Schema $schema): Schema
{
return $schema
->components([
Wizard::make([
Step::make('basic')
->label(trans('admin/node.tabs.basic_settings'))
->icon('tabler-server')
->columnSpanFull()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->schema([
TextInput::make('fqdn')
->columnSpan(2)
->required()
->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) {
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.fqdn_help');
}
return '';
}
return trans('admin/node.error');
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return trans('admin/node.ssl_ip');
}
return '';
})
->afterStateUpdated(function (Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
[$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);
}
})
->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,
]),
TextInput::make('daemon_connect')
->columnSpan(1)
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer(),
TextInput::make('name')
->label(trans('admin/node.display_name'))
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
])
->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)
->inline()
->helperText(function (Get $get) {
if (request()->isSecure()) {
return new HtmlString(trans('admin/node.panel_on_ssl'));
}
if (is_ip($get('fqdn'))) {
return trans('admin/node.ssl_help');
}
return '';
})
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
'https_proxy' => 'HTTPS with (reverse) proxy',
])
->colors([
'http' => 'warning',
'https' => 'success',
'https_proxy' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
'https_proxy' => 'tabler-shield-lock',
])
->default(fn () => request()->isSecure() ? 'https' : 'http')
->live()
->dehydrated(false)
->afterStateUpdated(function ($state, Set $set) {
$set('scheme', $state === 'http' ? 'http' : 'https');
$set('behind_proxy', $state === 'https_proxy');
$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'),
]),
Step::make('advanced')
->label(trans('admin/node.tabs.advanced_settings'))
->icon('tabler-server-cog')
->columnSpanFull()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->schema([
ToggleButtons::make('maintenance_mode')
->label(trans('admin/node.maintenance_mode'))->inline()
->columnSpan(1)
->default(false)
->hintIcon('tabler-question-mark', trans('admin/node.maintenance_mode_help'))
->options([
true => trans('admin/node.enabled'),
false => trans('admin/node.disabled'),
])
->colors([
true => 'danger',
false => 'success',
]),
ToggleButtons::make('public')
->default(true)
->columnSpan(1)
->label(trans('admin/node.use_for_deploy'))->inline()
->options([
true => trans('admin/node.yes'),
false => trans('admin/node.no'),
])
->colors([
true => 'success',
false => 'danger',
]),
TagsInput::make('tags')
->label(trans('admin/node.tags'))
->columnSpan(2),
TextInput::make('upload_size')
->label(trans('admin/node.upload_limit'))
->helperText(trans('admin/node.upload_limit_help.0'))
->hintIcon('tabler-question-mark', trans('admin/node.upload_limit_help.1'))
->columnSpan(1)
->numeric()->required()
->default(256)
->minValue(1)
->maxValue(1024)
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
TextInput::make('daemon_sftp')
->columnSpan(1)
->label(trans('admin/node.sftp_port'))
->minValue(1)
->maxValue(65535)
->default(2022)
->required()
->integer(),
TextInput::make('daemon_sftp_alias')
->columnSpan(2)
->label(trans('admin/node.sftp_alias'))
->helperText(trans('admin/node.sftp_alias_help')),
Grid::make()
->columns(6)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_mem')
->dehydrated()
->label(trans('admin/node.memory'))->inlineLabel()->inline()
->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->afterStateUpdated(fn (Set $set) => $set('memory_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('memory') == 0)
->live()
->options([
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->label(trans('admin/node.memory_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->columnSpan(2)
->numeric()
->minValue(0)
->default(0)
->required(),
TextInput::make('memory_overallocate')
->dehydratedWhenHidden()
->label(trans('admin/node.overallocate'))->inlineLabel()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->default(0)
->suffix('%')
->required(),
]),
Grid::make()
->columns(6)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_disk')
->dehydrated()
->label(trans('admin/node.disk'))->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->afterStateUpdated(fn (Set $set) => $set('disk_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('disk') == 0)
->options([
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label(trans('admin/node.disk_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->columnSpan(2)
->numeric()
->minValue(0)
->default(0)
->required(),
TextInput::make('disk_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label(trans('admin/node.overallocate'))->inlineLabel()
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->default(0)
->suffix('%')
->required(),
]),
Grid::make()
->columns(6)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->dehydrated()
->label(trans('admin/node.cpu'))->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->afterStateUpdated(fn (Set $set) => $set('cpu_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('cpu') == 0)
->options([
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label(trans('admin/node.cpu_limit'))->inlineLabel()
->suffix('%')
->columnSpan(2)
->numeric()
->default(0)
->minValue(0)
->required(),
TextInput::make('cpu_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label(trans('admin/node.overallocate'))->inlineLabel()
->columnSpan(2)
->numeric()
->default(0)
->minValue(-1)
->maxValue(100)
->suffix('%')
->required(),
]),
]),
])->columnSpanFull()
->nextAction(fn (Action $action) => $action->label(trans('admin/node.next_step'))->iconButton()->iconSize(IconSize::ExtraLarge)->icon('tabler-arrow-right'))
Wizard::make($this->getSteps())
->columnSpanFull()
->nextAction(fn (Action $action) => $action->iconButton()->iconSize(IconSize::ExtraLarge)->icon('tabler-arrow-right'))
->previousAction(fn (Action $action) => $action->iconButton()->iconSize(IconSize::ExtraLarge)->icon('tabler-arrow-left'))
->submitAction(new HtmlString(Blade::render(<<<'BLADE'
<x-filament::icon-button
type="submit"
iconSize="xl"
icon="tabler-file-plus"
>
{{ trans('admin/node.create') }}
</x-filament::icon-button>
BLADE))),
<x-filament::icon-button
type="submit"
iconSize="xl"
icon="tabler-file-plus"
>
{{ trans('admin/node.create') }}
</x-filament::icon-button>
BLADE))),
]);
}
/** @return Step[] */
protected function getDefaultSteps(): array
{
return [
Step::make('basic')
->label(trans('admin/node.tabs.basic_settings'))
->icon('tabler-server')
->columnSpanFull()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->schema([
TextInput::make('fqdn')
->columnSpan(2)
->required()
->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) {
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.fqdn_help');
}
return '';
}
return trans('admin/node.error');
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return trans('admin/node.ssl_ip');
}
return '';
})
->afterStateUpdated(function (Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
[$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);
}
})
->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,
]),
TextInput::make('daemon_connect')
->columnSpan(1)
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
->helperText(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port_help') : trans('admin/node.port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer(),
TextInput::make('name')
->label(trans('admin/node.display_name'))
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 2,
])
->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)
->inline()
->helperText(function (Get $get) {
if (request()->isSecure()) {
return new HtmlString(trans('admin/node.panel_on_ssl'));
}
if (is_ip($get('fqdn'))) {
return trans('admin/node.ssl_help');
}
return '';
})
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([
'http' => 'HTTP',
'https' => 'HTTPS (SSL)',
'https_proxy' => 'HTTPS with (reverse) proxy',
])
->colors([
'http' => 'warning',
'https' => 'success',
'https_proxy' => 'success',
])
->icons([
'http' => 'tabler-lock-open-off',
'https' => 'tabler-lock',
'https_proxy' => 'tabler-shield-lock',
])
->default(fn () => request()->isSecure() ? 'https' : 'http')
->live()
->dehydrated(false)
->afterStateUpdated(function ($state, Set $set) {
$set('scheme', $state === 'http' ? 'http' : 'https');
$set('behind_proxy', $state === 'https_proxy');
$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'),
]),
Step::make('advanced')
->label(trans('admin/node.tabs.advanced_settings'))
->icon('tabler-server-cog')
->columnSpanFull()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 4,
])
->schema([
ToggleButtons::make('maintenance_mode')
->label(trans('admin/node.maintenance_mode'))->inline()
->columnSpan(1)
->default(false)
->hintIcon('tabler-question-mark', trans('admin/node.maintenance_mode_help'))
->options([
true => trans('admin/node.enabled'),
false => trans('admin/node.disabled'),
])
->colors([
true => 'danger',
false => 'success',
]),
ToggleButtons::make('public')
->default(true)
->columnSpan(1)
->label(trans('admin/node.use_for_deploy'))->inline()
->options([
true => trans('admin/node.yes'),
false => trans('admin/node.no'),
])
->colors([
true => 'success',
false => 'danger',
]),
TagsInput::make('tags')
->label(trans('admin/node.tags'))
->columnSpan(2),
TextInput::make('upload_size')
->label(trans('admin/node.upload_limit'))
->hintIcon('tabler-question-mark', trans('admin/node.upload_limit_help'))
->columnSpan(1)
->numeric()->required()
->default(256)
->minValue(1)
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
TextInput::make('daemon_sftp')
->columnSpan(1)
->label(trans('admin/node.sftp_port'))
->minValue(1)
->maxValue(65535)
->default(2022)
->required()
->integer(),
TextInput::make('daemon_sftp_alias')
->columnSpan(2)
->label(trans('admin/node.sftp_alias'))
->helperText(trans('admin/node.sftp_alias_help')),
Grid::make()
->columns(6)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_mem')
->dehydrated()
->label(trans('admin/node.memory'))->inlineLabel()->inline()
->afterStateUpdated(fn (Set $set) => $set('memory', 0))
->afterStateUpdated(fn (Set $set) => $set('memory_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('memory') == 0)
->live()
->options([
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('memory')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->label(trans('admin/node.memory_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->columnSpan(2)
->numeric()
->minValue(0)
->default(0)
->required(),
TextInput::make('memory_overallocate')
->dehydratedWhenHidden()
->label(trans('admin/node.overallocate'))->inlineLabel()
->hidden(fn (Get $get) => $get('unlimited_mem'))
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->default(0)
->suffix('%')
->required(),
]),
Grid::make()
->columns(6)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_disk')
->dehydrated()
->label(trans('admin/node.disk'))->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Set $set) => $set('disk', 0))
->afterStateUpdated(fn (Set $set) => $set('disk_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('disk') == 0)
->options([
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('disk')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label(trans('admin/node.disk_limit'))->inlineLabel()
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->columnSpan(2)
->numeric()
->minValue(0)
->default(0)
->required(),
TextInput::make('disk_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_disk'))
->label(trans('admin/node.overallocate'))->inlineLabel()
->columnSpan(2)
->numeric()
->minValue(-1)
->maxValue(100)
->default(0)
->suffix('%')
->required(),
]),
Grid::make()
->columns(6)
->columnSpanFull()
->schema([
ToggleButtons::make('unlimited_cpu')
->dehydrated()
->label(trans('admin/node.cpu'))->inlineLabel()->inline()
->live()
->afterStateUpdated(fn (Set $set) => $set('cpu', 0))
->afterStateUpdated(fn (Set $set) => $set('cpu_overallocate', 0))
->formatStateUsing(fn (Get $get) => $get('cpu') == 0)
->options([
true => trans('admin/node.unlimited'),
false => trans('admin/node.limited'),
])
->colors([
true => 'primary',
false => 'warning',
])
->columnSpan(2),
TextInput::make('cpu')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label(trans('admin/node.cpu_limit'))->inlineLabel()
->suffix('%')
->columnSpan(2)
->numeric()
->default(0)
->minValue(0)
->required(),
TextInput::make('cpu_overallocate')
->dehydratedWhenHidden()
->hidden(fn (Get $get) => $get('unlimited_cpu'))
->label(trans('admin/node.overallocate'))->inlineLabel()
->columnSpan(2)
->numeric()
->default(0)
->minValue(-1)
->maxValue(100)
->suffix('%')
->required(),
]),
]),
];
}
protected function getRedirectUrlParameters(): array
{
return [

File diff suppressed because it is too large Load Diff

View File

@@ -119,14 +119,22 @@ class PluginResource extends Resource
->color('success')
->hidden(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
$pluginService->installPlugin($plugin, !$plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
try {
$pluginService->installPlugin($plugin, !$plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
Notification::make()
->success()
->title(trans('admin/plugin.notifications.installed'))
->send();
Notification::make()
->success()
->title(trans('admin/plugin.notifications.installed'))
->send();
} catch (Exception $exception) {
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.install_error'))
->body($exception->getMessage())
->send();
}
}),
Action::make('update')
->label(trans('admin/plugin.update'))
@@ -135,14 +143,22 @@ class PluginResource extends Resource
->color('success')
->visible(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled && $plugin->isUpdateAvailable())
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
$pluginService->updatePlugin($plugin);
try {
$pluginService->updatePlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
Notification::make()
->success()
->title(trans('admin/plugin.notifications.updated'))
->send();
Notification::make()
->success()
->title(trans('admin/plugin.notifications.updated'))
->send();
} catch (Exception $exception) {
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.update_error'))
->body($exception->getMessage())
->send();
}
}),
Action::make('enable')
->label(trans('admin/plugin.enable'))
@@ -160,7 +176,7 @@ class PluginResource extends Resource
Notification::make()
->success()
->title(trans('admin/plugin.notifications.updated'))
->title(trans('admin/plugin.notifications.enabled'))
->send();
}),
Action::make('disable')
@@ -202,16 +218,24 @@ class PluginResource extends Resource
->icon('tabler-terminal')
->color('danger')
->requiresConfirmation()
->hidden(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled)
->hidden(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled || $plugin->status === PluginStatus::Errored)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
$pluginService->uninstallPlugin($plugin);
try {
$pluginService->uninstallPlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
Notification::make()
->success()
->title(trans('admin/plugin.notifications.uninstalled'))
->send();
Notification::make()
->success()
->title(trans('admin/plugin.notifications.uninstalled'))
->send();
} catch (Exception $exception) {
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.uninstall_error'))
->body($exception->getMessage())
->send();
}
}),
]),
])

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -49,7 +49,7 @@ class DatabasesRelationManager extends RelationManager
->formatStateUsing(fn (Database $record) => $record->remote === '%' ? trans('admin/databasehost.anywhere'). ' ( % )' : $record->remote),
TextInput::make('max_connections')
->label(trans('admin/databasehost.table.max_connections'))
->formatStateUsing(fn (Database $record) => $record->max_connections === 0 ? trans('admin/databasehost.unlimited') : $record->max_connections),
->formatStateUsing(fn (Database $record) => $record->max_connections ?: trans('admin/databasehost.unlimited')),
TextInput::make('jdbc')
->label(trans('admin/databasehost.table.connection_string'))
->columnSpanFull()
@@ -75,7 +75,7 @@ class DatabasesRelationManager extends RelationManager
->url(fn (Database $database) => route('filament.admin.resources.servers.edit', ['record' => $database->server_id])),
TextColumn::make('max_connections')
->label(trans('admin/databasehost.table.max_connections'))
->formatStateUsing(fn ($record) => $record->max_connections === 0 ? trans('admin/databasehost.unlimited') : $record->max_connections),
->formatStateUsing(fn ($record) => $record->max_connections ?: trans('admin/databasehost.unlimited')),
DateTimeColumn::make('created_at')
->label(trans('admin/databasehost.table.created_at')),
])

View File

@@ -7,6 +7,7 @@ use App\Filament\Admin\Resources\Servers\Pages\CreateServer;
use App\Filament\Admin\Resources\Servers\Pages\EditServer;
use App\Filament\Admin\Resources\Servers\Pages\ListServers;
use App\Filament\Admin\Resources\Servers\RelationManagers\AllocationsRelationManager;
use App\Filament\Admin\Resources\Servers\RelationManagers\DatabasesRelationManager;
use App\Models\Mount;
use App\Models\Server;
use App\Traits\Filament\CanCustomizePages;
@@ -86,6 +87,7 @@ class ServerResource extends Resource
{
return [
AllocationsRelationManager::class,
DatabasesRelationManager::class,
];
}

View File

@@ -18,6 +18,7 @@ use App\Models\UserSSHKey;
use App\Services\Helpers\LanguageService;
use App\Traits\Filament\CanCustomizePages;
use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanCustomizeStaticTabs;
use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable;
use DateTimeZone;
@@ -59,6 +60,7 @@ class UserResource extends Resource
{
use CanCustomizePages;
use CanCustomizeRelations;
use CanCustomizeStaticTabs;
use CanModifyForm;
use CanModifyTable;
@@ -146,328 +148,339 @@ class UserResource extends Resource
->columns(['default' => 1, 'lg' => 3, 'md' => 2])
->components([
Tabs::make()
->schema([
Tab::make('account')
->label(trans('profile.tabs.account'))
->icon('tabler-user-cog')
->columns([
'default' => 1,
'md' => 3,
'lg' => 3,
])
->schema([
TextInput::make('username')
->label(trans('admin/user.username'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->required()
->unique()
->maxLength(255),
TextInput::make('email')
->label(trans('admin/user.email'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->email()
->required()
->unique()
->maxLength(255),
TextInput::make('password')
->label(trans('admin/user.password'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->hintIcon(fn ($operation) => $operation === 'create' ? 'tabler-question-mark' : null, fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null)
->password()
->hintAction(
Action::make('password_reset')
->label(trans('admin/user.password_reset'))
->hidden(fn (string $operation) => $operation === 'create' || config('mail.default', 'log') === 'log')
->icon('tabler-send')
->action(function (User $user) {
$status = Password::broker(Filament::getPanel('app')->getAuthPasswordBroker())->sendResetLink([
'email' => $user->email,
],
function (User $user, string $token) {
$notification = new ResetPassword($token);
$notification->url = Filament::getPanel('app')->getResetPasswordUrl($token, $user);
->schema(static::getTabs())
->columnSpanFull(),
]);
}
$user->notify($notification);
/** @return Tab[] */
protected static function getDefaultTabs(): array
{
return [
Tab::make('account')
->label(trans('profile.tabs.account'))
->icon('tabler-user-cog')
->columns([
'default' => 1,
'md' => 3,
'lg' => 3,
])
->schema([
TextInput::make('username')
->label(trans('admin/user.username'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->required()
->unique()
->maxLength(255),
TextInput::make('email')
->label(trans('admin/user.email'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->email()
->required()
->unique()
->maxLength(255),
TextInput::make('password')
->label(trans('admin/user.password'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->hintIcon(fn ($operation) => $operation === 'create' ? 'tabler-question-mark' : null, fn ($operation) => $operation === 'create' ? trans('admin/user.password_help') : null)
->password()
->hintAction(
Action::make('password_reset')
->label(trans('admin/user.password_reset'))
->hidden(fn (string $operation) => $operation === 'create' || config('mail.default', 'log') === 'log')
->icon('tabler-send')
->action(function (User $user) {
$status = Password::broker(Filament::getPanel('app')->getAuthPasswordBroker())->sendResetLink([
'email' => $user->email,
],
function (User $user, string $token) {
$notification = new ResetPassword($token);
$notification->url = Filament::getPanel('app')->getResetPasswordUrl($token, $user);
event(new PasswordResetLinkSent($user));
},
);
$user->notify($notification);
if ($status === Password::RESET_LINK_SENT) {
Notification::make()
->title(trans('admin/user.password_reset_sent'))
->success()
->send();
} else {
Notification::make()
->title(trans('admin/user.password_reset_failed'))
->body($status)
->danger()
->send();
}
})),
TextInput::make('external_id')
->label(trans('admin/user.external_id'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
]),
Toggle::make('is_managed_externally')
->label(trans('admin/user.is_managed_externally'))
->hintIcon('tabler-question-mark', trans('admin/user.is_managed_externally_helper'))
->inline(false)
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
]),
Section::make(trans('profile.tabs.customization'))
->collapsible()
->columnSpanFull()
->columns(2)
->schema([
Select::make('timezone')
->label(trans('profile.timezone'))
->required()
->prefixIcon('tabler-clock-pin')
->default(fn () => config('app.timezone', 'UTC'))
->selectablePlaceholder(false)
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
->searchable(),
Select::make('language')
->label(trans('profile.language'))
->required()
->prefixIcon('tabler-flag')
->live()
->default('en')
->searchable()
->selectablePlaceholder(false)
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()),
FileUpload::make('avatar')
->visible(fn (?User $user, FileUpload $fileUpload) => $user ? $fileUpload->getDisk()->exists($fileUpload->getDirectory() . '/' . $user->id . '.png') : false)
->columnSpanFull()
->avatar()
->directory('avatars')
->disk('public')
->formatStateUsing(function (FileUpload $fileUpload, ?User $user) {
if (!$user) {
return null;
}
$path = $fileUpload->getDirectory() . '/' . $user->id . '.png';
if ($fileUpload->getDisk()->exists($path)) {
return $path;
}
})
->deleteUploadedFileUsing(function (FileUpload $fileUpload, $file) {
if ($file instanceof TemporaryUploadedFile) {
return $file->delete();
}
event(new PasswordResetLinkSent($user));
},
);
if ($fileUpload->getDisk()->exists($file)) {
return $fileUpload->getDisk()->delete($file);
}
}),
]),
Section::make(trans('profile.tabs.oauth'))
->visible(fn (?User $user) => $user)
->collapsible()
->columnSpanFull()
->schema(function (OAuthService $oauthService, ?User $user) {
if (!$user) {
return;
}
if ($status === Password::RESET_LINK_SENT) {
Notification::make()
->title(trans('admin/user.password_reset_sent'))
->success()
->send();
} else {
Notification::make()
->title(trans('admin/user.password_reset_failed'))
->body($status)
->danger()
->send();
}
})),
TextInput::make('external_id')
->label(trans('admin/user.external_id'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
]),
Toggle::make('is_managed_externally')
->label(trans('admin/user.is_managed_externally'))
->hintIcon('tabler-question-mark', trans('admin/user.is_managed_externally_helper'))
->inline(false)
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
]),
Section::make(trans('profile.tabs.customization'))
->collapsible()
->columnSpanFull()
->columns(2)
->schema([
Select::make('timezone')
->label(trans('profile.timezone'))
->required()
->prefixIcon('tabler-clock-pin')
->default(fn () => config('app.timezone', 'UTC'))
->selectablePlaceholder(false)
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
->searchable(),
Select::make('language')
->label(trans('profile.language'))
->required()
->prefixIcon('tabler-flag')
->live()
->default('en')
->searchable()
->selectablePlaceholder(false)
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()),
FileUpload::make('avatar')
->visible(fn (?User $user, FileUpload $fileUpload) => $user ? $fileUpload->getDisk()->exists($fileUpload->getDirectory() . '/' . $user->id . '.png') : false)
->columnSpanFull()
->avatar()
->directory('avatars')
->disk('public')
->formatStateUsing(function (FileUpload $fileUpload, ?User $user) {
if (!$user) {
return null;
}
$path = $fileUpload->getDirectory() . '/' . $user->id . '.png';
if ($fileUpload->getDisk()->exists($path)) {
return $path;
}
})
->deleteUploadedFileUsing(function (FileUpload $fileUpload, $file) {
if ($file instanceof TemporaryUploadedFile) {
return $file->delete();
}
$actions = [];
foreach ($user->oauth ?? [] as $schema => $_) {
$schema = $oauthService->get($schema);
if (!$schema) {
return;
if ($fileUpload->getDisk()->exists($file)) {
return $fileUpload->getDisk()->delete($file);
}
}),
]),
Section::make(trans('profile.tabs.oauth'))
->visible(fn (?User $user) => $user)
->collapsible()
->columnSpanFull()
->schema(function (OAuthService $oauthService, ?User $user) {
if (!$user) {
return;
}
$actions = [];
foreach ($user->oauth ?? [] as $schema => $_) {
$schema = $oauthService->get($schema);
if (!$schema) {
return;
}
$id = $schema->getId();
$name = $schema->getName();
$color = $schema->getHexColor();
$color = is_string($color) ? Color::hex($color) : null;
$actions[] = Action::make("oauth_$id")
->label(trans('profile.unlink', ['name' => $name]))
->icon('tabler-unlink')
->requiresConfirmation()
->color($color)
->action(function ($livewire) use ($oauthService, $user, $name, $schema) {
$oauthService->unlinkUser($user, $schema);
$livewire->form->fill($user->attributesToArray());
Notification::make()
->title(trans('profile.unlinked', ['name' => $name]))
->success()
->send();
});
}
if (!$actions) {
return [
TextEntry::make('no_oauth')
->state(trans('profile.no_oauth'))
->hiddenLabel(),
];
}
return [Actions::make($actions)];
}),
]),
Tab::make('roles')
->label(trans('admin/user.roles'))
->icon('tabler-users-group')
->schema([
CheckboxList::make('roles')
->hidden(fn (?User $user) => $user && $user->isRootAdmin())
->relationship('roles', 'name', fn (Builder $query) => $query->whereNot('id', Role::getRootAdmin()->id))
->saveRelationshipsUsing(fn (User $user, array $state) => $user->syncRoles(collect($state)->map(fn ($role) => Role::findById($role))))
->dehydrated()
->label(trans('admin/user.admin_roles'))
->columnSpanFull()
->bulkToggleable(false),
CheckboxList::make('root_admin_role')
->visible(fn (?User $user) => $user && $user->isRootAdmin())
->disabled()
->options([
'root_admin' => Role::ROOT_ADMIN,
])
->descriptions([
'root_admin' => trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]),
])
->formatStateUsing(fn () => ['root_admin'])
->dehydrated(false)
->label(trans('admin/user.admin_roles'))
->columnSpanFull(),
]),
Tab::make('keys')
->visible(fn (?User $user) => $user)
->label(trans('profile.tabs.keys'))
->icon('tabler-key')
->schema([
Section::make(trans('profile.api_keys'))
->columnSpan(2)
->schema([
Repeater::make('api_keys')
->hiddenLabel()
->inlineLabel(false)
->relationship('apiKeys')
->addable(false)
->itemLabel(fn ($state) => $state['identifier'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, ?User $user) {
$items = $component->getState();
$key = $items[$arguments['item']] ?? null;
if ($key) {
$apiKey = ApiKey::find($key['id']);
if ($apiKey?->exists()) {
$apiKey->delete();
Activity::event('user:api-key.delete')
->actor(user())
->subject($user)
->subject($apiKey)
->property('identifier', $apiKey->identifier)
->log();
}
$id = $schema->getId();
$name = $schema->getName();
$actions[] = Action::make("oauth_$id")
->label(trans('profile.unlink', ['name' => $name]))
->icon('tabler-unlink')
->requiresConfirmation()
->color(Color::hex($schema->getHexColor()))
->action(function ($livewire) use ($oauthService, $user, $name, $schema) {
$oauthService->unlinkUser($user, $schema);
$livewire->form->fill($user->attributesToArray());
Notification::make()
->title(trans('profile.unlinked', ['name' => $name]))
->success()
->send();
});
unset($items[$arguments['item']]);
$component->state($items);
$component->callAfterStateUpdated();
}
});
})
->schema([
TextEntry::make('memo')
->hiddenLabel()
->state(fn (ApiKey $key) => $key->memo),
])
->visible(fn (User $user) => $user->apiKeys()->exists()),
TextEntry::make('no_api_keys')
->state(trans('profile.no_api_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->apiKeys()->exists()),
]),
Section::make(trans('profile.ssh_keys'))->columnSpan(2)
->schema([
Repeater::make('ssh_keys')
->hiddenLabel()
->inlineLabel(false)
->relationship('sshKeys')
->addable(false)
->itemLabel(fn ($state) => $state['name'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) {
$items = $component->getState();
$key = $items[$arguments['item']];
$sshKey = UserSSHKey::find($key['id'] ?? null);
if ($sshKey->exists()) {
$sshKey->delete();
Activity::event('user:ssh-key.delete')
->actor(user())
->subject($user)
->subject($sshKey)
->property('fingerprint', $sshKey->fingerprint)
->log();
}
if (!$actions) {
return [
TextEntry::make('no_oauth')
->state(trans('profile.no_oauth'))
->hiddenLabel(),
];
}
unset($items[$arguments['item']]);
return [Actions::make($actions)];
}),
]),
Tab::make('roles')
->label(trans('admin/user.roles'))
->icon('tabler-users-group')
->components([
CheckboxList::make('roles')
->hidden(fn (?User $user) => $user && $user->isRootAdmin())
->relationship('roles', 'name', fn (Builder $query) => $query->whereNot('id', Role::getRootAdmin()->id))
->saveRelationshipsUsing(fn (User $user, array $state) => $user->syncRoles(collect($state)->map(fn ($role) => Role::findById($role))))
->dehydrated()
->label(trans('admin/user.admin_roles'))
->columnSpanFull()
->bulkToggleable(false),
CheckboxList::make('root_admin_role')
->visible(fn (?User $user) => $user && $user->isRootAdmin())
->disabled()
->options([
'root_admin' => Role::ROOT_ADMIN,
])
->descriptions([
'root_admin' => trans('admin/role.root_admin', ['role' => Role::ROOT_ADMIN]),
])
->formatStateUsing(fn () => ['root_admin'])
->dehydrated(false)
->label(trans('admin/user.admin_roles'))
->columnSpanFull(),
]),
Tab::make('keys')
->visible(fn (?User $user) => $user)
->label(trans('profile.tabs.keys'))
->icon('tabler-key')
->schema([
Section::make(trans('profile.api_keys'))
->columnSpan(2)
->schema([
Repeater::make('api_keys')
->hiddenLabel()
->inlineLabel(false)
->relationship('apiKeys')
->addable(false)
->itemLabel(fn ($state) => $state['identifier'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, ?User $user) {
$items = $component->getState();
$key = $items[$arguments['item']] ?? null;
$component->state($items);
if ($key) {
$apiKey = ApiKey::find($key['id']);
if ($apiKey?->exists()) {
$apiKey->delete();
$component->callAfterStateUpdated();
});
})
->schema(fn () => [
TextEntry::make('fingerprint')
->hiddenLabel()
->state(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"),
])
->visible(fn (User $user) => $user->sshKeys()->exists()),
Activity::event('user:api-key.delete')
->actor(user())
->subject($user)
->subject($apiKey)
->property('identifier', $apiKey->identifier)
->log();
}
unset($items[$arguments['item']]);
$component->state($items);
$component->callAfterStateUpdated();
}
});
})
->schema([
TextEntry::make('memo')
->hiddenLabel()
->state(fn (ApiKey $key) => $key->memo),
])
->visible(fn (User $user) => $user->apiKeys()->exists()),
TextEntry::make('no_api_keys')
->state(trans('profile.no_api_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->apiKeys()->exists()),
]),
Section::make(trans('profile.ssh_keys'))->columnSpan(2)
->schema([
Repeater::make('ssh_keys')
->hiddenLabel()
->inlineLabel(false)
->relationship('sshKeys')
->addable(false)
->itemLabel(fn ($state) => $state['name'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) {
$items = $component->getState();
$key = $items[$arguments['item']];
$sshKey = UserSSHKey::find($key['id'] ?? null);
if ($sshKey->exists()) {
$sshKey->delete();
Activity::event('user:ssh-key.delete')
->actor(user())
->subject($user)
->subject($sshKey)
->property('fingerprint', $sshKey->fingerprint)
->log();
}
unset($items[$arguments['item']]);
$component->state($items);
$component->callAfterStateUpdated();
});
})
->schema(fn () => [
TextEntry::make('fingerprint')
->hiddenLabel()
->state(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"),
])
->visible(fn (User $user) => $user->sshKeys()->exists()),
TextEntry::make('no_ssh_keys')
->state(trans('profile.no_ssh_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->sshKeys()->exists()),
]),
]),
Tab::make('activity')
->visible(fn (?User $user) => $user)
->disabledOn('create')
->label(trans('profile.tabs.activity'))
->icon('tabler-history')
->schema([
Repeater::make('activity')
->hiddenLabel()
->inlineLabel(false)
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderBy('timestamp', 'desc');
})
->schema([
TextEntry::make('log')
->hiddenLabel()
->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]),
]),
])->columnSpanFull(),
]);
TextEntry::make('no_ssh_keys')
->state(trans('profile.no_ssh_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->sshKeys()->exists()),
]),
]),
Tab::make('activity')
->visible(fn (?User $user) => $user)
->disabledOn('create')
->label(trans('profile.tabs.activity'))
->icon('tabler-history')
->schema([
Repeater::make('activity')
->hiddenLabel()
->inlineLabel(false)
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderBy('timestamp', 'desc');
})
->schema([
TextEntry::make('log')
->hiddenLabel()
->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]),
]),
];
}
/** @return class-string<RelationManager>[] */

View File

@@ -3,24 +3,24 @@
namespace App\Filament\Components\Actions;
use App\Console\Commands\Egg\UpdateEggIndexCommand;
use App\Jobs\InstallEgg;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggImporterService;
use Closure;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
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\Support\Enums\IconSize;
use Filament\Support\Enums\Width;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Str;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class ImportEggAction extends Action
@@ -42,10 +42,31 @@ class ImportEggAction extends Action
$this->iconSize(IconSize::ExtraLarge);
$this->modalWidth(Width::ScreenExtraLarge);
$this->authorize(fn () => user()?->can('import egg'));
$this->action(function (array $data, EggImporterService $eggImportService): void {
$gitHubEggs = array_get($this->data, 'eggs', []);
$eggs = array_merge(collect($data['urls'])->flatten()->whereNotNull()->unique()->all(), Arr::wrap($data['files']));
if ($gitHubEggs) {
foreach ($gitHubEggs as $category => $sortedEggs) {
foreach ($sortedEggs as $downloadUrl) {
InstallEgg::dispatch($downloadUrl);
}
}
Notification::make()
->title(trans('installer.egg.background_install_started'))
->body(trans('installer.egg.background_install_description', ['count' => array_sum(array_map('count', $gitHubEggs))]))
->success()
->persistent()
->send();
}
if (empty($eggs)) {
return;
}
@@ -89,18 +110,20 @@ class ImportEggAction extends Action
}
}
if ($failed->count() > 0) {
$bodyParts = collect([
$success->isNotEmpty() ? trans('admin/egg.import.imported_eggs', ['eggs' => $success->join(', ')]) : null,
$failed->isNotEmpty() ? trans('admin/egg.import.failed_import_eggs', ['eggs' => $failed->join(', ')]) : null,
])->filter();
if ($bodyParts->isNotEmpty()) {
Notification::make()
->title(trans('admin/egg.import.import_failed'))
->body($failed->join(', '))
->danger()
->send();
}
if ($success->count() > 0) {
Notification::make()
->title(trans('admin/egg.import.import_success'))
->body($success->join(', '))
->success()
->title(trans('admin/egg.import.import_result', [
'success' => $success->count(),
'failed' => $failed->count(),
'total' => $success->count() + $failed->count(),
]))
->body($bodyParts->join(' | '))
->status($failed->isEmpty() ? 'success' : ($success->isEmpty() ? 'danger' : 'warning'))
->send();
}
});
@@ -113,6 +136,7 @@ class ImportEggAction extends Action
Tabs::make('Tabs')
->contained(false)
->tabs([
$this->importEggsFromGitHub(),
Tab::make('file')
->label(trans('admin/egg.import.file'))
->icon('tabler-file-upload')
@@ -130,30 +154,6 @@ class ImportEggAction extends Action
->label(trans('admin/egg.import.url'))
->icon('tabler-world-upload')
->schema([
Select::make('github')
->label(trans('admin/egg.import.github'))
->options(fn () => cache('eggs.index'))
->selectablePlaceholder(false)
->searchable()
->preload()
->live()
->hintAction(
Action::make('refresh')
->iconButton()
->icon('tabler-refresh')
->tooltip(trans('admin/egg.import.refresh'))
->action(function () {
Artisan::call(UpdateEggIndexCommand::class);
})
)
->afterStateUpdated(function ($state, Set $set, Get $get) use ($isMultiple) {
if ($state) {
$urls = $isMultiple ? $get('urls') : [];
$urls[Str::uuid()->toString()] = ['url' => $state];
$set('urls', $urls);
$set('github', null);
}
}),
Repeater::make('urls')
->hiddenLabel()
->itemLabel(fn (array $state) => str($state['url'])->afterLast('/egg-')->beforeLast('.')->headline())
@@ -179,4 +179,49 @@ class ImportEggAction extends Action
return $this;
}
public function importEggsFromGitHub(): Tab
{
if (!cache()->get('eggs.index')) {
Artisan::call(UpdateEggIndexCommand::class);
}
$eggs = cache()->get('eggs.index', []);
$categories = array_keys($eggs);
$tabs = array_map(function (string $label) use ($eggs) {
$id = str_slug($label, '_');
$eggCount = count($eggs[$label]);
return Tab::make($id)
->label($label)
->badge($eggCount)
->schema([
CheckboxList::make("eggs.$id")
->hiddenLabel()
->options(fn () => array_sort($eggs[$label]))
->searchable($eggCount > 0)
->bulkToggleable($eggCount > 0)
->columns(4),
]);
}, $categories);
if (empty($tabs)) {
$tabs[] = Tab::make('no_eggs')
->label(trans('installer.egg.no_eggs'))
->schema([
TextEntry::make('no_eggs')
->hiddenLabel()
->state(trans('installer.egg.exceptions.no_eggs')),
]);
}
return Tab::make('github')
->label(trans('admin/egg.import.github'))
->icon('tabler-brand-github')
->columnSpanFull()
->schema([
Tabs::make('egg_tabs')
->tabs($tabs),
]);
}
}

View File

@@ -47,8 +47,8 @@ class UpdateEggAction extends Action
cache()->forget("eggs.$egg->uuid.update");
} catch (Exception $exception) {
Notification::make()
->title(trans('admin/egg.update_failed'))
->body($exception->getMessage())
->title(trans('admin/egg.update_failed', ['egg' => $egg->name]))
->body(trans('admin/egg.update_error', ['error' => $exception->getMessage()]))
->danger()
->send();
@@ -58,8 +58,8 @@ class UpdateEggAction extends Action
}
Notification::make()
->title(trans_choice('admin/egg.updated', 1))
->body($egg->name)
->title(trans('admin/egg.update_success', ['egg' => $egg->name]))
->body(trans('admin/egg.updated_from', ['url' => $egg->update_url]))
->success()
->send();
});

View File

@@ -47,39 +47,40 @@ class UpdateEggBulkAction extends BulkAction
return;
}
$success = 0;
$failed = 0;
$skipped = 0;
$successEggs = collect();
$failedEggs = collect();
$skippedEggs = collect();
/** @var Egg $egg */
foreach ($records as $egg) {
if ($egg->update_url === null) {
$skipped++;
$skippedEggs->push($egg->name);
continue;
}
try {
$eggImporterService->fromUrl($egg->update_url, $egg);
$success++;
$successEggs->push($egg->name);
cache()->forget("eggs.$egg->uuid.update");
} catch (Exception $exception) {
$failed++;
$failedEggs->push($egg->name);
report($exception);
}
}
$bodyParts = collect([
$successEggs->isNotEmpty() ? trans('admin/egg.updated_eggs', ['eggs' => $successEggs->join(', ')]) : null,
$failedEggs->isNotEmpty() ? trans('admin/egg.failed_eggs', ['eggs' => $failedEggs->join(', ')]) : null,
$skippedEggs->isNotEmpty() ? trans('admin/egg.skipped_eggs', ['eggs' => $skippedEggs->join(', ')]) : null,
])->filter();
Notification::make()
->title(trans_choice('admin/egg.updated', 2, ['count' => $success, 'total' => $records->count()]))
->body(
collect([
$failed > 0 ? trans('admin/egg.updated_failed', ['count' => $failed]) : null,
$skipped > 0 ? trans('admin/egg.updated_skipped', ['count' => $skipped]) : null,
])->filter()->join(' ')
)
->status($failed > 0 ? 'warning' : 'success')
->title(trans_choice('admin/egg.updated', 2, ['count' => $successEggs->count(), 'total' => $records->count()]))
->body($bodyParts->join(' | '))
->status($failedEggs->isNotEmpty() ? 'warning' : 'success')
->persistent()
->send();
});

View File

@@ -83,6 +83,8 @@ class StartupVariable extends Field
$this->variableDefault(fn (Get $get) => $get('default_value'));
$this->variableRules(fn (Get $get) => $get('rules'));
$this->disabled(fn (Get $get) => !$get('user_editable'));
return $this;
}
@@ -94,6 +96,8 @@ class StartupVariable extends Field
$this->variableDefault(fn (?ServerVariable $record) => $record?->variable->default_value);
$this->variableRules(fn (?ServerVariable $record) => $record?->variable->rules);
$this->disabled(fn (?ServerVariable $record) => !$record?->variable->user_editable);
return $this;
}

View File

@@ -14,6 +14,7 @@ use App\Services\Ssh\KeyCreationService;
use App\Services\Users\UserUpdateService;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use App\Traits\Filament\CanCustomizeTabs;
use DateTimeZone;
use Exception;
use Filament\Actions\Action;
@@ -56,6 +57,7 @@ class EditProfile extends BaseEditProfile
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
use CanCustomizeTabs;
protected OAuthService $oauthService;
@@ -82,417 +84,432 @@ class EditProfile extends BaseEditProfile
*/
public function form(Schema $schema): Schema
{
$oauthSchemas = $this->oauthService->getEnabled();
return $schema
->components([
Tabs::make()->persistTabInQueryString()
->schema([
Tab::make('account')
->label(trans('profile.tabs.account'))
->icon('tabler-user-cog')
->schema([
TextInput::make('username')
->disabled(fn (User $user) => $user->is_managed_externally)
->prefixIcon('tabler-user')
->label(trans('profile.username'))
->required()
->maxLength(255)
->unique(),
TextInput::make('email')
->disabled(fn (User $user) => $user->is_managed_externally)
->prefixIcon('tabler-mail')
->label(trans('profile.email'))
->email()
->required()
->maxLength(255)
->unique(),
TextInput::make('password')
->hidden(fn (User $user) => $user->is_managed_externally)
->label(trans('profile.password'))
->password()
->prefixIcon('tabler-password')
->revealable(filament()->arePasswordsRevealable())
->rule(Password::default())
->autocomplete('new-password')
->dehydrated(fn ($state) => filled($state))
->dehydrateStateUsing(fn ($state) => Hash::make($state))
->live(debounce: 500)
->same('passwordConfirmation'),
TextInput::make('passwordConfirmation')
->label(trans('profile.password_confirmation'))
->password()
->prefixIcon('tabler-password-fingerprint')
->revealable(filament()->arePasswordsRevealable())
->required()
->visible(fn (Get $get) => filled($get('password')))
->dehydrated(false),
Select::make('timezone')
->label(trans('profile.timezone'))
->required()
->prefixIcon('tabler-clock-pin')
->default(config('app.timezone', 'UTC'))
->selectablePlaceholder(false)
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
->searchable(),
Select::make('language')
->label(trans('profile.language'))
->required()
->prefixIcon('tabler-flag')
->live()
->default('en')
->selectablePlaceholder(false)
->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? ''
: trans('profile.language_help', ['state' => $state]) . ' <u><a href="https://crowdin.com/project/pelican-dev/">Update On Crowdin</a></u>'))
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()),
FileUpload::make('avatar')
->visible(fn () => config('panel.filament.uploadable-avatars'))
->avatar()
->imageEditor()
->acceptedFileTypes(['image/png'])
->directory('avatars')
->disk('public')
->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png')
->formatStateUsing(function (FileUpload $fileUpload) {
$path = $fileUpload->getDirectory() . '/' . $this->getUser()->id . '.png';
if ($fileUpload->getDisk()->exists($path)) {
return $path;
}
})
->deleteUploadedFileUsing(function (FileUpload $fileUpload, $file) {
if ($file instanceof TemporaryUploadedFile) {
return $file->delete();
}
Tabs::make()
->persistTabInQueryString()
->tabs($this->getTabs()),
])
->operation('edit')
->model($this->getUser())
->statePath('data')
->inlineLabel(!static::isSimple());
}
if ($fileUpload->getDisk()->exists($file)) {
return $fileUpload->getDisk()->delete($file);
/** @return Tab[] */
protected function getDefaultTabs(): array
{
$oauthSchemas = $this->oauthService->getEnabled();
return [
Tab::make('account')
->label(trans('profile.tabs.account'))
->icon('tabler-user-cog')
->schema([
TextInput::make('username')
->disabled(fn (User $user) => $user->is_managed_externally)
->prefixIcon('tabler-user')
->label(trans('profile.username'))
->required()
->maxLength(255)
->unique(),
TextInput::make('email')
->disabled(fn (User $user) => $user->is_managed_externally)
->prefixIcon('tabler-mail')
->label(trans('profile.email'))
->email()
->required()
->maxLength(255)
->unique(),
TextInput::make('password')
->hidden(fn (User $user) => $user->is_managed_externally)
->label(trans('profile.password'))
->password()
->prefixIcon('tabler-password')
->revealable(filament()->arePasswordsRevealable())
->rule(Password::default())
->autocomplete('new-password')
->dehydrated(fn ($state) => filled($state))
->dehydrateStateUsing(fn ($state) => Hash::make($state))
->live(debounce: 500)
->same('passwordConfirmation'),
TextInput::make('passwordConfirmation')
->label(trans('profile.password_confirmation'))
->password()
->prefixIcon('tabler-password-fingerprint')
->revealable(filament()->arePasswordsRevealable())
->required()
->visible(fn (Get $get) => filled($get('password')))
->dehydrated(false),
Select::make('timezone')
->label(trans('profile.timezone'))
->required()
->prefixIcon('tabler-clock-pin')
->default(config('app.timezone', 'UTC'))
->selectablePlaceholder(false)
->options(fn () => collect(DateTimeZone::listIdentifiers())->mapWithKeys(fn ($tz) => [$tz => $tz]))
->searchable(),
Select::make('language')
->label(trans('profile.language'))
->required()
->prefixIcon('tabler-flag')
->live()
->default('en')
->selectablePlaceholder(false)
->helperText(fn ($state, LanguageService $languageService) => new HtmlString($languageService->isLanguageTranslated($state) ? ''
: trans('profile.language_help', ['state' => $state]) . ' <u><a href="https://crowdin.com/project/pelican-dev/">Update On Crowdin</a></u>'))
->options(fn (LanguageService $languageService) => $languageService->getAvailableLanguages()),
FileUpload::make('avatar')
->visible(fn () => config('panel.filament.uploadable-avatars'))
->avatar()
->imageEditor()
->acceptedFileTypes(['image/png'])
->directory('avatars')
->disk('public')
->getUploadedFileNameForStorageUsing(fn () => $this->getUser()->id . '.png')
->formatStateUsing(function (FileUpload $fileUpload) {
$path = $fileUpload->getDirectory() . '/' . $this->getUser()->id . '.png';
if ($fileUpload->getDisk()->exists($path)) {
return $path;
}
})
->deleteUploadedFileUsing(function (FileUpload $fileUpload, $file) {
if ($file instanceof TemporaryUploadedFile) {
return $file->delete();
}
if ($fileUpload->getDisk()->exists($file)) {
return $fileUpload->getDisk()->delete($file);
}
}),
]),
Tab::make('oauth')
->label(trans('profile.tabs.oauth'))
->icon('tabler-brand-oauth')
->visible(count($oauthSchemas) > 0)
->schema(function () use ($oauthSchemas) {
$actions = [];
foreach ($oauthSchemas as $schema) {
$id = $schema->getId();
$name = $schema->getName();
$color = $schema->getHexColor();
$color = is_string($color) ? Color::hex($color) : null;
$unlink = array_key_exists($id, $this->getUser()->oauth ?? []);
$actions[] = Action::make("oauth_$id")
->label(trans('profile.' . ($unlink ? 'unlink' : 'link'), ['name' => $name]))
->icon($unlink ? 'tabler-unlink' : 'tabler-link')
->color($color)
->action(function (UserUpdateService $updateService) use ($id, $name, $unlink) {
if ($unlink) {
$oauth = user()?->oauth;
unset($oauth[$id]);
$updateService->handle(user(), ['oauth' => $oauth]);
$this->fillForm();
Notification::make()
->title(trans('profile.unlinked', ['name' => $name]))
->success()
->send();
} else {
redirect(Socialite::with($id)->redirect()->getTargetUrl());
}
});
}
return [Actions::make($actions)];
}),
Tab::make('2fa')
->label(trans('profile.tabs.2fa'))
->icon('tabler-shield-lock')
->visible(fn () => Filament::hasMultiFactorAuthentication())
->schema(collect(Filament::getMultiFactorAuthenticationProviders())
->sort(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => $multiFactorAuthenticationProvider->isEnabled(Filament::auth()->user()) ? 0 : 1)
->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents())
->statePath($multiFactorAuthenticationProvider->getId()))
->all()),
Tab::make('api_keys')
->label(trans('profile.tabs.api_keys'))
->icon('tabler-key')
->schema([
Grid::make(5)
->schema([
Section::make(trans('profile.create_api_key'))->columnSpan(3)
->schema([
TextInput::make('description')
->label(trans('profile.description'))
->live(),
TagsInput::make('allowed_ips')
->label(trans('profile.allowed_ips'))
->live()
->splitKeys([',', ' ', 'Tab'])
->placeholder('127.0.0.1 or 192.168.1.1')
->helperText(trans('profile.allowed_ips_help'))
->columnSpanFull(),
])
->headerActions([
Action::make('create_api_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('description')))
->successRedirectUrl(self::getUrl(['tab' => 'api-keys::data::tab'], panel: 'app'))
->action(function (Get $get, Action $action, User $user) {
$token = $user->createToken(
$get('description'),
$get('allowed_ips'),
);
Activity::event('user:api-key.create')
->actor($user)
->subject($user)
->subject($token->accessToken)
->property('identifier', $token->accessToken->identifier)
->log();
Notification::make()
->title(trans('profile.api_key_created'))
->body($token->accessToken->identifier . $token->plainTextToken)
->persistent()
->success()
->send();
$action->success();
}),
]),
Section::make(trans('profile.api_keys'))->columnSpan(2)
->schema([
Repeater::make('api_keys')
->hiddenLabel()
->inlineLabel(false)
->relationship('apiKeys')
->addable(false)
->itemLabel(fn ($state) => $state['identifier'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) {
$items = $component->getState();
$key = $items[$arguments['item']];
$apiKey = ApiKey::find($key['id'] ?? null);
if ($apiKey->exists()) {
$apiKey->delete();
Activity::event('user:api-key.delete')
->actor($user)
->subject($user)
->subject($apiKey)
->property('identifier', $apiKey->identifier)
->log();
}
unset($items[$arguments['item']]);
$component->state($items);
$component->callAfterStateUpdated();
});
})
->schema(fn () => [
TextEntry::make('memo')
->hiddenLabel()
->state(fn (ApiKey $key) => $key->memo),
])
->visible(fn (User $user) => $user->apiKeys()->exists()),
TextEntry::make('no_api_keys')
->state(trans('profile.no_api_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->apiKeys()->exists()),
]),
]),
]),
Tab::make('ssh_keys')
->label(trans('profile.tabs.ssh_keys'))
->icon('tabler-lock-code')
->schema([
Grid::make(5)->schema([
Section::make(trans('profile.create_ssh_key'))->columnSpan(3)
->schema([
TextInput::make('name')
->label(trans('profile.name'))
->live(),
Textarea::make('public_key')
->label(trans('profile.public_key'))
->autosize()
->live(),
])
->headerActions([
Action::make('create_ssh_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('name')) || empty($get('public_key')))
->successRedirectUrl(self::getUrl(['tab' => 'ssh-keys::data::tab'], panel: 'app'))
->action(function (Get $get, Action $action, User $user, KeyCreationService $service) {
try {
$sshKey = $service->handle($user, $get('name'), $get('public_key'));
Activity::event('user:ssh-key.create')
->actor($user)
->subject($user)
->subject($sshKey)
->property('fingerprint', $sshKey->fingerprint)
->log();
Notification::make()
->title(trans('profile.ssh_key_created'))
->body("SHA256:{$sshKey->fingerprint}")
->success()
->send();
$action->success();
} catch (Exception $exception) {
Notification::make()
->title(trans('profile.could_not_create_ssh_key'))
->body($exception->getMessage())
->danger()
->send();
$action->failure();
}
}),
]),
Tab::make('oauth')
->label(trans('profile.tabs.oauth'))
->icon('tabler-brand-oauth')
->visible(count($oauthSchemas) > 0)
->schema(function () use ($oauthSchemas) {
$actions = [];
foreach ($oauthSchemas as $schema) {
$id = $schema->getId();
$name = $schema->getName();
$unlink = array_key_exists($id, $this->getUser()->oauth ?? []);
$actions[] = Action::make("oauth_$id")
->label(trans('profile.' . ($unlink ? 'unlink' : 'link'), ['name' => $name]))
->icon($unlink ? 'tabler-unlink' : 'tabler-link')
->color(Color::hex($schema->getHexColor()))
->action(function (UserUpdateService $updateService) use ($id, $name, $unlink) {
if ($unlink) {
$oauth = user()?->oauth;
unset($oauth[$id]);
$updateService->handle(user(), ['oauth' => $oauth]);
$this->fillForm();
Notification::make()
->title(trans('profile.unlinked', ['name' => $name]))
->success()
->send();
} else {
redirect(Socialite::with($id)->redirect()->getTargetUrl());
}
});
}
return [Actions::make($actions)];
}),
Tab::make('2fa')
->label(trans('profile.tabs.2fa'))
->icon('tabler-shield-lock')
->visible(fn () => Filament::hasMultiFactorAuthentication())
->schema(collect(Filament::getMultiFactorAuthenticationProviders())
->sort(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => $multiFactorAuthenticationProvider->isEnabled(Filament::auth()->user()) ? 0 : 1)
->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents())
->statePath($multiFactorAuthenticationProvider->getId()))
->all()),
Tab::make('api_keys')
->label(trans('profile.tabs.api_keys'))
->icon('tabler-key')
Section::make(trans('profile.ssh_keys'))->columnSpan(2)
->schema([
Grid::make(5)
->schema([
Section::make(trans('profile.create_api_key'))->columnSpan(3)
->schema([
TextInput::make('description')
->label(trans('profile.description'))
->live(),
TagsInput::make('allowed_ips')
->label(trans('profile.allowed_ips'))
->live()
->splitKeys([',', ' ', 'Tab'])
->placeholder('127.0.0.1 or 192.168.1.1')
->helperText(trans('profile.allowed_ips_help'))
->columnSpanFull(),
])
->headerActions([
Action::make('create_api_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('description')))
->successRedirectUrl(self::getUrl(['tab' => 'api-keys::data::tab'], panel: 'app'))
->action(function (Get $get, Action $action, User $user) {
$token = $user->createToken(
$get('description'),
$get('allowed_ips'),
);
Activity::event('user:api-key.create')
->actor($user)
->subject($user)
->subject($token->accessToken)
->property('identifier', $token->accessToken->identifier)
->log();
Notification::make()
->title(trans('profile.api_key_created'))
->body($token->accessToken->identifier . $token->plainTextToken)
->persistent()
->success()
->send();
$action->success();
}),
]),
Section::make(trans('profile.api_keys'))->columnSpan(2)
->schema([
Repeater::make('api_keys')
->hiddenLabel()
->inlineLabel(false)
->relationship('apiKeys')
->addable(false)
->itemLabel(fn ($state) => $state['identifier'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) {
$items = $component->getState();
$key = $items[$arguments['item']];
$apiKey = ApiKey::find($key['id'] ?? null);
if ($apiKey->exists()) {
$apiKey->delete();
Activity::event('user:api-key.delete')
->actor($user)
->subject($user)
->subject($apiKey)
->property('identifier', $apiKey->identifier)
->log();
}
unset($items[$arguments['item']]);
$component->state($items);
$component->callAfterStateUpdated();
});
})
->schema(fn () => [
TextEntry::make('memo')
->hiddenLabel()
->state(fn (ApiKey $key) => $key->memo),
])
->visible(fn (User $user) => $user->apiKeys()->exists()),
TextEntry::make('no_api_keys')
->state(trans('profile.no_api_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->apiKeys()->exists()),
]),
]),
]),
Tab::make('ssh_keys')
->label(trans('profile.tabs.ssh_keys'))
->icon('tabler-lock-code')
->schema([
Grid::make(5)->schema([
Section::make(trans('profile.create_ssh_key'))->columnSpan(3)
->schema([
TextInput::make('name')
->label(trans('profile.name'))
->live(),
Textarea::make('public_key')
->label(trans('profile.public_key'))
->autosize()
->live(),
])
->headerActions([
Action::make('create_ssh_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('name')) || empty($get('public_key')))
->successRedirectUrl(self::getUrl(['tab' => 'ssh-keys::data::tab'], panel: 'app'))
->action(function (Get $get, Action $action, User $user, KeyCreationService $service) {
try {
$sshKey = $service->handle($user, $get('name'), $get('public_key'));
Activity::event('user:ssh-key.create')
->actor($user)
->subject($user)
->subject($sshKey)
->property('fingerprint', $sshKey->fingerprint)
->log();
Notification::make()
->title(trans('profile.ssh_key_created'))
->body("SHA256:{$sshKey->fingerprint}")
->success()
->send();
$action->success();
} catch (Exception $exception) {
Notification::make()
->title(trans('profile.could_not_create_ssh_key'))
->body($exception->getMessage())
->danger()
->send();
$action->failure();
}
}),
]),
Section::make(trans('profile.ssh_keys'))->columnSpan(2)
->schema([
Repeater::make('ssh_keys')
->hiddenLabel()
->inlineLabel(false)
->relationship('sshKeys')
->addable(false)
->itemLabel(fn ($state) => $state['name'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) {
$items = $component->getState();
$key = $items[$arguments['item']];
$sshKey = UserSSHKey::find($key['id'] ?? null);
if ($sshKey->exists()) {
$sshKey->delete();
Activity::event('user:ssh-key.delete')
->actor($user)
->subject($user)
->subject($sshKey)
->property('fingerprint', $sshKey->fingerprint)
->log();
}
unset($items[$arguments['item']]);
$component->state($items);
$component->callAfterStateUpdated();
});
})
->schema(fn () => [
TextEntry::make('fingerprint')
->hiddenLabel()
->state(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"),
])
->visible(fn (User $user) => $user->sshKeys()->exists()),
TextEntry::make('no_ssh_keys')
->state(trans('profile.no_ssh_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->sshKeys()->exists()),
]),
]),
]),
Tab::make('activity')
->label(trans('profile.tabs.activity'))
->icon('tabler-history')
->schema([
Repeater::make('activity')
Repeater::make('ssh_keys')
->hiddenLabel()
->inlineLabel(false)
->deletable(false)
->relationship('sshKeys')
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderBy('timestamp', 'desc');
->itemLabel(fn ($state) => $state['name'])
->deleteAction(function (Action $action) {
$action->requiresConfirmation()->action(function (array $arguments, Repeater $component, User $user) {
$items = $component->getState();
$key = $items[$arguments['item']];
$sshKey = UserSSHKey::find($key['id'] ?? null);
if ($sshKey->exists()) {
$sshKey->delete();
Activity::event('user:ssh-key.delete')
->actor($user)
->subject($user)
->subject($sshKey)
->property('fingerprint', $sshKey->fingerprint)
->log();
}
unset($items[$arguments['item']]);
$component->state($items);
$component->callAfterStateUpdated();
});
})
->schema([
TextEntry::make('log')
->schema(fn () => [
TextEntry::make('fingerprint')
->hiddenLabel()
->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]),
->state(fn (UserSSHKey $key) => "SHA256:{$key->fingerprint}"),
])
->visible(fn (User $user) => $user->sshKeys()->exists()),
TextEntry::make('no_ssh_keys')
->state(trans('profile.no_ssh_keys'))
->hiddenLabel()
->visible(fn (User $user) => !$user->sshKeys()->exists()),
]),
Tab::make('customization')
->label(trans('profile.tabs.customization'))
->icon('tabler-adjustments')
->schema([
Section::make(trans('profile.dashboard'))
->collapsible()
->icon('tabler-dashboard')
->schema([
ToggleButtons::make('dashboard_layout')
->label(trans('profile.dashboard_layout'))
->inline()
->required()
->options([
'grid' => trans('profile.grid'),
'table' => trans('profile.table'),
]),
ToggleButtons::make('top_navigation')
->label(trans('profile.navigation'))
->inline()
->options([
'sidebar' => trans('profile.sidebar'),
'topbar' => trans('profile.topbar'),
'mixed' => trans('profile.mixed'),
]),
]),
Section::make(trans('profile.console'))
->collapsible()
->icon('tabler-brand-tabler')
->columns(4)
->schema([
TextInput::make('console_font_size')
->label(trans('profile.font_size'))
->columnSpan(1)
->minValue(1)
->numeric()
->required()
->live()
->default(14),
Select::make('console_font')
->label(trans('profile.font'))
->required()
->options(function () {
$fonts = [
'monospace' => 'monospace', //default
];
]),
]),
Tab::make('activity')
->label(trans('profile.tabs.activity'))
->icon('tabler-history')
->schema([
Repeater::make('activity')
->hiddenLabel()
->inlineLabel(false)
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderBy('timestamp', 'desc');
})
->schema([
TextEntry::make('log')
->hiddenLabel()
->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]),
]),
Tab::make('customization')
->label(trans('profile.tabs.customization'))
->icon('tabler-adjustments')
->schema([
Section::make(trans('profile.dashboard'))
->collapsible()
->icon('tabler-dashboard')
->schema([
ToggleButtons::make('dashboard_layout')
->label(trans('profile.dashboard_layout'))
->inline()
->required()
->options([
'grid' => trans('profile.grid'),
'table' => trans('profile.table'),
]),
ToggleButtons::make('top_navigation')
->label(trans('profile.navigation'))
->inline()
->options([
'sidebar' => trans('profile.sidebar'),
'topbar' => trans('profile.topbar'),
'mixed' => trans('profile.mixed'),
]),
]),
Section::make(trans('profile.console'))
->collapsible()
->icon('tabler-brand-tabler')
->columns(4)
->schema([
TextInput::make('console_font_size')
->label(trans('profile.font_size'))
->columnSpan(1)
->minValue(1)
->numeric()
->required()
->live()
->default(14),
Select::make('console_font')
->label(trans('profile.font'))
->required()
->options(function () {
$fonts = [
'monospace' => 'monospace', //default
];
if (!Storage::disk('public')->exists('fonts')) {
Storage::disk('public')->makeDirectory('fonts');
$this->fillForm();
}
if (!Storage::disk('public')->exists('fonts')) {
Storage::disk('public')->makeDirectory('fonts');
$this->fillForm();
}
foreach (Storage::disk('public')->allFiles('fonts') as $file) {
$fileInfo = pathinfo($file);
foreach (Storage::disk('public')->allFiles('fonts') as $file) {
$fileInfo = pathinfo($file);
if ($fileInfo['extension'] === 'ttf') {
$fonts[$fileInfo['filename']] = $fileInfo['filename'];
}
}
if ($fileInfo['extension'] === 'ttf') {
$fonts[$fileInfo['filename']] = $fileInfo['filename'];
}
}
return $fonts;
})
->live()
->default('monospace'),
TextEntry::make('font_preview')
->label(trans('profile.font_preview'))
->columnSpan(2)
->state(function (Get $get) {
$fontName = $get('console_font') ?? 'monospace';
$fontSize = $get('console_font_size') . 'px';
$style = <<<CSS
return $fonts;
})
->live()
->default('monospace'),
TextEntry::make('font_preview')
->label(trans('profile.font_preview'))
->columnSpan(2)
->state(function (Get $get) {
$fontName = $get('console_font') ?? 'monospace';
$fontSize = $get('console_font_size') . 'px';
$style = <<<CSS
.preview-text {
font-family: $fontName;
font-size: $fontSize;
@@ -500,49 +517,44 @@ class EditProfile extends BaseEditProfile
display: block;
}
CSS;
if ($fontName !== 'monospace') {
$fontUrl = asset("storage/fonts/$fontName.ttf");
$style = <<<CSS
if ($fontName !== 'monospace') {
$fontUrl = asset("storage/fonts/$fontName.ttf");
$style = <<<CSS
@font-face {
font-family: $fontName;
src: url("$fontUrl");
}
$style
CSS;
}
}
return new HtmlString(<<<HTML
return new HtmlString(<<<HTML
<style>
{$style}
</style>
<span class="preview-text">The quick blue pelican jumps over the lazy pterodactyl. :)</span>
HTML);
}),
TextInput::make('console_graph_period')
->label(trans('profile.graph_period'))
->suffix(trans('profile.seconds'))
->hintIcon('tabler-question-mark', trans('profile.graph_period_helper'))
->columnSpan(2)
->numeric()
->default(30)
->minValue(10)
->maxValue(120)
->required(),
TextInput::make('console_rows')
->label(trans('profile.rows'))
->minValue(1)
->numeric()
->required()
->columnSpan(2)
->default(30),
]),
]),
]),
])
->operation('edit')
->model($this->getUser())
->statePath('data')
->inlineLabel(!static::isSimple());
}),
TextInput::make('console_graph_period')
->label(trans('profile.graph_period'))
->suffix(trans('profile.seconds'))
->hintIcon('tabler-question-mark', trans('profile.graph_period_helper'))
->columnSpan(2)
->numeric()
->default(30)
->minValue(10)
->maxValue(120)
->required(),
TextInput::make('console_rows')
->label(trans('profile.rows'))
->minValue(1)
->numeric()
->required()
->columnSpan(2)
->default(30),
]),
]),
];
}
protected function getFormActions(): array

View File

@@ -76,10 +76,13 @@ class Login extends BaseLogin
$id = $schema->getId();
$color = $schema->getHexColor();
$color = is_string($color) ? Color::hex($color) : null;
$actions[] = Action::make("oauth_$id")
->label($schema->getName())
->icon($schema->getIcon())
->color(Color::hex($schema->getHexColor()))
->color($color)
->url(route('auth.oauth.redirect', ['driver' => $id], false));
}

View File

@@ -44,7 +44,7 @@ abstract class ServerFormPage extends Page
protected function authorizeAccess(): void {}
protected function fillform(): void
protected function fillForm(): void
{
$data = $this->getRecord()->attributesToArray();

View File

@@ -147,12 +147,15 @@ class Startup extends ServerFormPage
return parent::canAccess() && user()?->can(SubuserPermission::StartupRead, Filament::getTenant());
}
public function update(?string $state, ServerVariable $serverVariable): null
public function update(?string $state, ServerVariable $serverVariable): void
{
if (!$serverVariable->variable->user_editable) {
return;
}
$original = $serverVariable->variable_value;
try {
$validator = Validator::make(
['variable_value' => $state],
['variable_value' => $serverVariable->variable->rules]
@@ -165,7 +168,7 @@ class Startup extends ServerFormPage
->danger()
->send();
return null;
return;
}
ServerVariable::query()->updateOrCreate([
@@ -184,6 +187,7 @@ class Startup extends ServerFormPage
])
->log();
}
Notification::make()
->title(trans('server/startup.update', ['variable' => $serverVariable->variable->name]))
->body(fn () => $original . ' -> ' . $state)
@@ -196,8 +200,6 @@ class Startup extends ServerFormPage
->danger()
->send();
}
return null;
}
public function getTitle(): string

View File

@@ -98,7 +98,7 @@ class DatabaseResource extends Resource
->label(trans('server/database.remote')),
TextInput::make('max_connections')
->label(trans('server/database.max_connections'))
->formatStateUsing(fn (Database $database) => $database->max_connections === 0 ? $database->max_connections : 'Unlimited'),
->formatStateUsing(fn (Database $database) => $database->max_connections ?: trans('server/database.unlimited')),
TextInput::make('jdbc')
->label(trans('server/database.jdbc'))
->password()->revealable()

View File

@@ -83,15 +83,21 @@ class BackupController extends ClientApiController
// how best to allow a user to create a backup that is locked without also preventing
// them from just filling up a server with backups that can never be deleted?
if ($request->user()->can(SubuserPermission::BackupDelete, $server)) {
$action->setIsLocked((bool) $request->input('is_locked'));
$action->setIsLocked($request->boolean('is_locked'));
}
$backup = $action->handle($server, $request->input('name'));
$backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) {
$server->backups()->lockForUpdate();
Activity::event('server:backup.start')
->subject($backup)
->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')])
->log();
$backup = $action->handle($server, $request->input('name'));
$log->subject($backup)->property([
'name' => $backup->name,
'locked' => $request->boolean('is_locked'),
]);
return $backup;
});
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))

View File

@@ -59,12 +59,15 @@ class DatabaseController extends ClientApiController
*/
public function store(StoreDatabaseRequest $request, Server $server): array
{
$database = $this->deployDatabaseService->handle($server, $request->validated());
$database = Activity::event('server:database.create')->transaction(function ($log) use ($request, $server) {
$server->databases()->lockForUpdate();
Activity::event('server:database.create')
->subject($database)
->property('name', $database->database)
->log();
$database = $this->deployDatabaseService->handle($server, $request->validated());
$log->subject($database)->property('name', $database->database);
return $database;
});
return $this->fractal->item($database)
->parseIncludes(['password'])

View File

@@ -107,16 +107,19 @@ class NetworkAllocationController extends ClientApiController
*/
public function store(NewAllocationRequest $request, Server $server): array
{
if ($server->allocations()->count() >= $server->allocation_limit) {
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
}
$allocation = Activity::event('server:allocation.create')->transaction(function ($log) use ($server) {
$server->allocations()->lockForUpdate();
$allocation = $this->assignableAllocationService->handle($server);
if ($server->allocations->count() >= $server->allocation_limit) {
throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.');
}
Activity::event('server:allocation.create')
->subject($allocation)
->property('allocation', $allocation->address)
->log();
$allocation = $this->assignableAllocationService->handle($server);
$log->subject($allocation)->property('allocation', $allocation->address);
return $allocation;
});
return $this->fractal->item($allocation)
->transformWith($this->getTransformer(AllocationTransformer::class))

View File

@@ -131,7 +131,7 @@ class BackupRemoteUploadController extends Controller
*/
private function getConfiguredMaxPartSize(): int
{
$maxPartSize = (int) config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE);
$maxPartSize = config('backups.max_part_size', self::DEFAULT_MAX_PART_SIZE);
if ($maxPartSize <= 0) {
$maxPartSize = self::DEFAULT_MAX_PART_SIZE;
}

View File

@@ -79,13 +79,13 @@ class OAuthController extends Controller
$user = User::whereEmail($email)->first();
if ($user) {
if (!$driver->shouldLinkMissingUsers()) {
if (!$driver->shouldLinkMissingUser($user, $oauthUser)) {
return $this->errorRedirect();
}
$user = $this->oauthService->linkUser($user, $driver, $oauthUser);
} else {
if (!$driver->shouldCreateMissingUsers()) {
if (!$driver->shouldCreateMissingUser($oauthUser)) {
return $this->errorRedirect();
}

33
app/Jobs/InstallEgg.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
namespace App\Jobs;
use App\Services\Eggs\Sharing\EggImporterService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Throwable;
class InstallEgg implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 15;
public function __construct(public string $downloadUrl) {}
/**
* @throws Throwable
*/
public function handle(EggImporterService $eggImporterService): void
{
try {
$eggImporterService->fromUrl($this->downloadUrl);
} catch (Throwable $e) {
Log::error('Failed to install egg from URL: ' . $this->downloadUrl, ['exception' => $e]);
}
}
}

View File

@@ -2,8 +2,10 @@
namespace App\Livewire\Installer;
use App\Jobs\InstallEgg;
use App\Livewire\Installer\Steps\CacheStep;
use App\Livewire\Installer\Steps\DatabaseStep;
use App\Livewire\Installer\Steps\EggSelectionStep;
use App\Livewire\Installer\Steps\EnvironmentStep;
use App\Livewire\Installer\Steps\QueueStep;
use App\Livewire\Installer\Steps\RequirementsStep;
@@ -13,6 +15,7 @@ use App\Services\Helpers\LanguageService;
use App\Services\Users\UserCreationService;
use App\Traits\CheckMigrationsTrait;
use App\Traits\EnvironmentWriterTrait;
use App\Traits\Filament\CanCustomizeSteps;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
@@ -24,6 +27,7 @@ use Filament\Pages\SimplePage;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Filament\Support\Exceptions\Halt;
@@ -37,6 +41,7 @@ use Illuminate\Support\HtmlString;
*/
class PanelInstaller extends SimplePage implements HasForms
{
use CanCustomizeSteps;
use CheckMigrationsTrait;
use EnvironmentWriterTrait;
use InteractsWithForms;
@@ -53,7 +58,7 @@ class PanelInstaller extends SimplePage implements HasForms
public function getMaxContentWidth(): Width|string
{
return Width::SevenExtraLarge;
return Width::ScreenTwoExtraLarge;
}
public static function isInstalled(): bool
@@ -68,9 +73,7 @@ class PanelInstaller extends SimplePage implements HasForms
$this->form->fill();
}
/** @return Component[]
* @throws Exception
*/
/** @return Component[] */
protected function getFormSchema(): array
{
return [
@@ -78,20 +81,9 @@ class PanelInstaller extends SimplePage implements HasForms
->schema([
$this->getLanguageComponent(),
]),
Wizard::make([
RequirementsStep::make(),
EnvironmentStep::make($this),
DatabaseStep::make($this),
CacheStep::make($this),
QueueStep::make($this),
SessionStep::make(),
])
Wizard::make($this->getSteps())
->persistStepInQueryString()
->nextAction(function (Action $action) {
$action
->label(trans('installer.next_step'))
->keyBindings('enter');
})
->nextAction(fn (Action $action) => $action->keyBindings('enter'))
->submitAction(new HtmlString(Blade::render(<<<'BLADE'
<x-filament::button
type="submit"
@@ -99,12 +91,30 @@ class PanelInstaller extends SimplePage implements HasForms
wire:loading.attr="disabled"
>
{{ trans('installer.finish') }}
<span wire:loading><x-filament::loading-indicator class="h-4 w-4" /></span>
<x-filament::loading-indicator wire:loading class="h-4 w-4" />
</x-filament::button>
BLADE))),
];
}
/**
* @return Step[]
*
* @throws Exception
*/
protected function getDefaultSteps(): array
{
return [
RequirementsStep::make(),
EnvironmentStep::make($this),
DatabaseStep::make($this),
EggSelectionStep::make(),
CacheStep::make($this),
QueueStep::make($this),
SessionStep::make(),
];
}
protected function getLanguageComponent(): Component
{
return Select::make('language')
@@ -141,6 +151,9 @@ class PanelInstaller extends SimplePage implements HasForms
// Write session data at the very end to avoid "page expired" errors
$this->writeToEnv('env_session');
// Install selected eggs
$this->installEggs();
// Redirect to admin panel
$this->redirect(Filament::getPanel('admin')->getUrl());
} catch (Halt) {
@@ -165,8 +178,6 @@ class PanelInstaller extends SimplePage implements HasForms
throw new Halt(trans('installer.exceptions.write_env'));
}
Artisan::call('config:clear');
}
public function runMigrations(): void
@@ -220,4 +231,36 @@ class PanelInstaller extends SimplePage implements HasForms
throw new Halt(trans('installer.exceptions.create_user'));
}
}
public function installEggs(): void
{
try {
$selectedEggs = array_get($this->data, 'eggs', []);
if (!$selectedEggs) {
return;
}
foreach ($selectedEggs as $category => $eggs) {
foreach ($eggs as $downloadUrl) {
InstallEgg::dispatch($downloadUrl);
}
}
Notification::make()
->title(trans('installer.egg.background_install_started'))
->body(trans('installer.egg.background_install_description', ['count' => array_sum(array_map('count', $selectedEggs))]))
->success()
->persistent()
->send();
} catch (Exception $exception) {
report($exception);
Notification::make()
->title(trans('installer.egg.exceptions.installation_failed'))
->body($exception->getMessage())
->danger()
->persistent()
->send();
}
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Livewire\Installer\Steps;
use App\Console\Commands\Egg\UpdateEggIndexCommand;
use Exception;
use Filament\Forms\Components\CheckboxList;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Artisan;
class EggSelectionStep
{
public static function make(): Step
{
try {
Artisan::call(UpdateEggIndexCommand::class);
} catch (Exception $exception) {
Notification::make()
->title(trans('installer.egg.exceptions.failed_to_update'))
->icon('tabler-egg')
->body($exception->getMessage())
->danger()
->persistent()
->send();
}
$eggs = cache()->get('eggs.index', []);
$categories = array_keys($eggs);
$tabs = array_map(function (string $label) use ($eggs) {
$id = str_slug($label, '_');
$eggCount = count($eggs[$label]);
return Tab::make($id)
->label($label)
->badge($eggCount)
->schema([
CheckboxList::make("eggs.$id")
->hiddenLabel()
->options(fn () => array_sort($eggs[$label]))
->searchable($eggCount > 0)
->bulkToggleable($eggCount > 0)
->columns(4),
]);
}, $categories);
if (empty($tabs)) {
$tabs[] = Tab::make('no_eggs')
->label(trans('installer.egg.no_eggs'))
->schema([
TextEntry::make('no_eggs')
->hiddenLabel()
->state(trans('installer.egg.exceptions.no_eggs')),
]);
}
return Step::make('egg')
->label(trans('installer.egg.title'))
->columnSpanFull()
->schema([
Tabs::make('egg_tabs')
->tabs($tabs),
]);
}
}

View File

@@ -19,7 +19,7 @@ use PDOException;
* @property string $username
* @property string $remote
* @property string $password
* @property int $max_connections
* @property ?int $max_connections
* @property string $jdbc
* @property Carbon $created_at
* @property Carbon $updated_at

View File

@@ -110,7 +110,7 @@ class Node extends Model implements Validatable
'daemon_listen' => ['required', 'numeric', 'between:1,65535'],
'daemon_connect' => ['required', 'numeric', 'between:1,65535'],
'maintenance_mode' => ['boolean'],
'upload_size' => ['int', 'between:1,1024'],
'upload_size' => ['int', 'min:1'],
'tags' => ['array'],
];

View File

@@ -205,7 +205,7 @@ class Plugin extends Model implements HasPluginSettings
public function canDisable(): bool
{
return $this->status !== PluginStatus::Disabled && $this->status !== PluginStatus::NotInstalled && $this->isCompatible();
return $this->status === PluginStatus::Enabled || $this->status === PluginStatus::Incompatible;
}
public function isCompatible(): bool
@@ -306,11 +306,10 @@ class Plugin extends Model implements HasPluginSettings
public function hasSettings(): bool
{
try {
$pluginObject = filament($this->id);
$pluginObject = new ($this->fullClass());
return $pluginObject instanceof HasPluginSettings;
} catch (Exception) {
// Plugin is not loaded on the current panel, so no settings
}
return false;
@@ -320,13 +319,12 @@ class Plugin extends Model implements HasPluginSettings
public function getSettingsForm(): array
{
try {
$pluginObject = filament($this->id);
$pluginObject = new ($this->fullClass());
if ($pluginObject instanceof HasPluginSettings) {
return $pluginObject->getSettingsForm();
}
} catch (Exception) {
// Plugin is not loaded on the current panel, so no settings
}
return [];
@@ -336,13 +334,12 @@ class Plugin extends Model implements HasPluginSettings
public function saveSettings(array $data): void
{
try {
$pluginObject = filament($this->id);
$pluginObject = new ($this->fullClass());
if ($pluginObject instanceof HasPluginSettings) {
$pluginObject->saveSettings($data);
}
} catch (Exception) {
// Plugin is not loaded on the current panel, so no settings
}
}

View File

@@ -39,6 +39,7 @@ use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules\In;
use ResourceBundle;
@@ -215,7 +216,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
{
$rules = self::getValidationRules();
$rules['language'][] = new In(array_values(array_filter(ResourceBundle::getLocales(''), fn ($lang) => preg_match('/^[a-z]{2}$/', $lang))));
$rules['language'][] = new In(ResourceBundle::getLocales(''));
$rules['timezone'][] = new In(DateTimeZone::listIdentifiers());
return $rules;
@@ -333,12 +334,8 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return !$key ? $customization : $customization[$key->value];
}
protected function checkPermission(Server $server, string|SubuserPermission $permission = ''): bool
protected function hasPermission(Server $server, string $permission = ''): bool
{
if ($permission instanceof SubuserPermission) {
$permission = $permission->value;
}
if ($this->canned('update', $server) || $server->owner_id === $this->id) {
return true;
}
@@ -356,6 +353,17 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
return in_array($permission, $subuser->permissions);
}
protected function checkPermission(Server $server, string|SubuserPermission $permission = ''): bool
{
if ($permission instanceof SubuserPermission) {
$permission = $permission->value;
}
$contextKey = "users.$this->id.servers.$server->id.$permission";
return Context::remember($contextKey, fn () => $this->hasPermission($server, $permission));
}
/**
* Laravel's policies strictly check for the existence of a real method,
* this checks if the ability is one of our permissions and then checks if the user can do it or not

View File

@@ -3,34 +3,69 @@
namespace App\Policies;
use App\Enums\SubuserPermission;
use App\Models\Server;
use App\Models\User;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model;
class AllocationPolicy
{
public function viewAny(User $user): bool
{
return $user->can(SubuserPermission::AllocationRead, Filament::getTenant());
use DefaultAdminPolicies {
viewAny as adminViewAny;
view as adminView;
create as adminCreate;
update as adminUpdate;
delete as adminDelete;
deleteAny as adminDeleteAny;
}
public function view(User $user, Model $record): bool
protected string $modelName = 'allocation';
public function viewAny(User $user): bool
{
return $user->can(SubuserPermission::AllocationRead, Filament::getTenant());
/** @var ?Server $server */
$server = Filament::getTenant();
return $server ? $user->can(SubuserPermission::AllocationRead, $server) : $this->adminViewAny($user);
}
public function view(User $user, Model $model): bool
{
/** @var ?Server $server */
$server = Filament::getTenant();
return $server ? $user->can(SubuserPermission::AllocationRead, $server) : $this->adminView($user, $model);
}
public function create(User $user): bool
{
return $user->can(SubuserPermission::AllocationCreate, Filament::getTenant());
/** @var ?Server $server */
$server = Filament::getTenant();
return $server ? $user->can(SubuserPermission::AllocationCreate, $server) : $this->adminCreate($user);
}
public function edit(User $user, Model $record): bool
public function update(User $user, Model $model): bool
{
return $user->can(SubuserPermission::AllocationUpdate, Filament::getTenant());
/** @var ?Server $server */
$server = Filament::getTenant();
return $server ? $user->can(SubuserPermission::AllocationUpdate, $server) : $this->adminUpdate($user, $model);
}
public function delete(User $user, Model $record): bool
public function delete(User $user, Model $model): bool
{
return $user->can(SubuserPermission::AllocationDelete, Filament::getTenant());
/** @var ?Server $server */
$server = Filament::getTenant();
return $server ? $user->can(SubuserPermission::AllocationDelete, $server) : $this->adminDelete($user, $model);
}
public function deleteAny(User $user): bool
{
/** @var ?Server $server */
$server = Filament::getTenant();
return $server ? $user->can(SubuserPermission::AllocationDelete, $server) : $this->adminDeleteAny($user);
}
}

View File

@@ -14,7 +14,7 @@ class BackupPolicy
return $user->can(SubuserPermission::BackupRead, Filament::getTenant());
}
public function view(User $user, Model $record): bool
public function view(User $user, Model $model): bool
{
return $user->can(SubuserPermission::BackupRead, Filament::getTenant());
}
@@ -24,7 +24,7 @@ class BackupPolicy
return $user->can(SubuserPermission::BackupCreate, Filament::getTenant());
}
public function delete(User $user, Model $record): bool
public function delete(User $user, Model $model): bool
{
return $user->can(SubuserPermission::BackupDelete, Filament::getTenant());
}

View File

@@ -14,7 +14,7 @@ class DatabasePolicy
return $user->can(SubuserPermission::DatabaseRead, Filament::getTenant());
}
public function view(User $user, Model $record): bool
public function view(User $user, Model $model): bool
{
return $user->can(SubuserPermission::DatabaseRead, Filament::getTenant());
}
@@ -24,12 +24,12 @@ class DatabasePolicy
return $user->can(SubuserPermission::DatabaseCreate, Filament::getTenant());
}
public function edit(User $user, Model $record): bool
public function update(User $user, Model $model): bool
{
return $user->can(SubuserPermission::DatabaseUpdate, Filament::getTenant());
}
public function delete(User $user, Model $record): bool
public function delete(User $user, Model $model): bool
{
return $user->can(SubuserPermission::DatabaseDelete, Filament::getTenant());
}

View File

@@ -14,7 +14,7 @@ class FilePolicy
return $user->can(SubuserPermission::FileRead, Filament::getTenant());
}
public function view(User $user, Model $record): bool
public function view(User $user, Model $model): bool
{
return $user->can(SubuserPermission::FileReadContent, Filament::getTenant());
}
@@ -24,12 +24,12 @@ class FilePolicy
return $user->can(SubuserPermission::FileCreate, Filament::getTenant());
}
public function edit(User $user, Model $record): bool
public function update(User $user, Model $model): bool
{
return $user->can(SubuserPermission::FileUpdate, Filament::getTenant());
}
public function delete(User $user, Model $record): bool
public function delete(User $user, Model $model): bool
{
return $user->can(SubuserPermission::FileDelete, Filament::getTenant());
}

View File

@@ -14,7 +14,7 @@ class SchedulePolicy
return $user->can(SubuserPermission::ScheduleRead, Filament::getTenant());
}
public function view(User $user, Model $record): bool
public function view(User $user, Model $model): bool
{
return $user->can(SubuserPermission::ScheduleRead, Filament::getTenant());
}
@@ -24,12 +24,12 @@ class SchedulePolicy
return $user->can(SubuserPermission::ScheduleCreate, Filament::getTenant());
}
public function edit(User $user, Model $record): bool
public function update(User $user, Model $model): bool
{
return $user->can(SubuserPermission::ScheduleUpdate, Filament::getTenant());
}
public function delete(User $user, Model $record): bool
public function delete(User $user, Model $model): bool
{
return $user->can(SubuserPermission::ScheduleDelete, Filament::getTenant());
}

View File

@@ -14,7 +14,7 @@ class SubuserPolicy
return $user->can(SubuserPermission::UserRead, Filament::getTenant());
}
public function view(User $user, Model $record): bool
public function view(User $user, Model $model): bool
{
return $user->can(SubuserPermission::UserRead, Filament::getTenant());
}
@@ -24,12 +24,12 @@ class SubuserPolicy
return $user->can(SubuserPermission::UserCreate, Filament::getTenant());
}
public function edit(User $user, Model $record): bool
public function update(User $user, Model $model): bool
{
return $user->can(SubuserPermission::UserUpdate, Filament::getTenant());
}
public function delete(User $user, Model $record): bool
public function delete(User $user, Model $model): bool
{
return $user->can(SubuserPermission::UserDelete, Filament::getTenant());
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Enums\ResourceLimit;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Http\Middleware\TrimStrings;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
@@ -98,5 +99,7 @@ class RouteServiceProvider extends ServiceProvider
config('http.rate_limit.application')
)->by($key);
});
ResourceLimit::boot();
}
}

View File

@@ -31,11 +31,11 @@ class DaemonFileRepository extends DaemonRepository
throw new FileSizeTooLargeException();
}
if ($response->getStatusCode() === 400) {
if ($response->status() === 400) {
throw new FileNotEditableException();
}
if ($response->getStatusCode() === 404) {
if ($response->status() === 404) {
throw new FileNotFoundException();
}
@@ -56,7 +56,7 @@ class DaemonFileRepository extends DaemonRepository
->withBody($content)
->post("/api/servers/{$this->server->uuid}/files/write");
if ($response->getStatusCode() === 400) {
if ($response->status() === 400) {
throw new FileExistsException();
}
@@ -92,7 +92,7 @@ class DaemonFileRepository extends DaemonRepository
]
);
if ($response->getStatusCode() === 400) {
if ($response->status() === 400) {
throw new FileExistsException();
}

View File

@@ -133,6 +133,9 @@ class DaemonServerRepository extends DaemonRepository
* make it easier to revoke tokens on the fly. This ensures that the JTI key is formatted
* correctly and avoids any costly mistakes in the codebase.
*
* @deprecated
* @see self::deauthorize()
*
* @throws ConnectionException
*/
public function revokeUserJTI(int $id): void
@@ -143,6 +146,21 @@ class DaemonServerRepository extends DaemonRepository
]);
}
/**
* Deauthorizes a user (disconnects websockets and SFTP) on the Wings instance for the server.
*
* @throws ConnectionException
*/
public function deauthorize(string $user): void
{
$this->getHttpClient()->post('/api/deauthorize-user', [
'json' => [
'user' => $user,
'servers' => [$this->server->uuid],
],
]);
}
public function getInstallLogs(): string
{
return $this->getHttpClient()

View File

@@ -7,17 +7,21 @@ use App\Exceptions\Service\InvalidFileUploadException;
use App\Models\Plugin;
use Composer\Autoload\ClassLoader;
use Exception;
use Filament\Facades\Filament;
use Filament\Panel;
use Illuminate\Console\Application as ConsoleApplication;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Migrations\Migrator;
use Illuminate\Foundation\Application;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use JsonException;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
@@ -157,6 +161,8 @@ class PluginService
/**
* @param null|array<string, string> $newPackages
* @param null|array<string, string> $oldPackages
*
* @throws Exception
*/
public function manageComposerPackages(?array $newPackages = [], ?array $oldPackages = null): void
{
@@ -208,48 +214,55 @@ class PluginService
}
}
/** @throws Exception */
public function runPluginMigrations(Plugin $plugin): void
{
$migrations = plugin_path($plugin->id, 'database', 'migrations');
if (file_exists($migrations)) {
$success = Artisan::call('migrate', ['--realpath' => true, '--path' => $migrations, '--force' => true]) === 0;
if (!$success) {
throw new Exception("Could not run migrations for plugin '{$plugin->id}'");
try {
$migrator = $this->app->make(Migrator::class);
$migrator->run($migrations);
} catch (Exception $exception) {
throw new Exception("Could not run migrations': " . $exception->getMessage());
}
}
}
/** @throws Exception */
public function rollbackPluginMigrations(Plugin $plugin): void
{
$migrations = plugin_path($plugin->id, 'database', 'migrations');
if (file_exists($migrations)) {
$success = Artisan::call('migrate:rollback', ['--realpath' => true, '--path' => $migrations, '--force' => true]) === 0;
if (!$success) {
throw new Exception("Could not rollback migrations for plugin '{$plugin->id}'");
try {
$migrator = $this->app->make(Migrator::class);
$migrator->reset($migrations);
} catch (Exception $exception) {
throw new Exception("Could not rollback migrations': " . $exception->getMessage());
}
}
}
/** @throws Exception */
public function runPluginSeeder(Plugin $plugin): void
{
$seeder = $plugin->getSeeder();
if ($seeder) {
$success = Artisan::call('db:seed', ['--class' => $seeder, '--force' => true]) === 0;
try {
$seederObject = $this->app->make($seeder)->setContainer($this->app);
if (!$success) {
throw new Exception("Could not run seeder for plugin '{$plugin->id}'");
Model::unguarded(fn () => $seederObject->__invoke());
} catch (Exception $exception) {
throw new Exception('Could not run seeder: ' . $exception->getMessage());
}
}
}
public function buildAssets(): bool
public function buildAssets(bool $throw = false): bool
{
try {
$result = Process::path(base_path())->timeout(300)->run('yarn install');
if ($result->failed()) {
throw new Exception('Could not install dependencies: ' . $result->errorOutput());
throw new Exception('Could not install yarn dependencies: ' . $result->errorOutput());
}
$result = Process::path(base_path())->timeout(600)->run('yarn build');
@@ -259,16 +272,17 @@ class PluginService
return true;
} catch (Exception $exception) {
if ($this->isDevModeActive()) {
if ($throw || $this->isDevModeActive()) {
throw ($exception);
}
report($exception);
Log::warning($exception->getMessage(), ['exception' => $exception]);
}
return false;
}
/** @throws Exception */
public function installPlugin(Plugin $plugin, bool $enable = true): void
{
try {
@@ -282,32 +296,40 @@ class PluginService
}
}
$this->buildAssets();
$this->buildAssets($plugin->isTheme());
$this->runPluginMigrations($plugin);
$this->runPluginSeeder($plugin);
foreach (Filament::getPanels() as $panel) {
$panel->clearCachedComponents();
}
} catch (Exception $exception) {
$this->handlePluginException($plugin, $exception);
$this->handlePluginException($plugin, $exception, true);
}
}
/** @throws Exception */
public function updatePlugin(Plugin $plugin): void
{
try {
$downloadUrl = $plugin->getDownloadUrlForUpdate();
if ($downloadUrl) {
$this->downloadPluginFromUrl($downloadUrl, true);
$this->installPlugin($plugin, false);
cache()->forget("plugins.$plugin->id.update");
if (!$downloadUrl) {
throw new Exception('No download url found.');
}
$this->downloadPluginFromUrl($downloadUrl, true);
$this->installPlugin($plugin, false);
cache()->forget("plugins.$plugin->id.update");
} catch (Exception $exception) {
$this->handlePluginException($plugin, $exception);
$this->handlePluginException($plugin, $exception, true);
}
}
/** @throws Exception */
public function uninstallPlugin(Plugin $plugin, bool $deleteFiles = false): void
{
try {
@@ -324,11 +346,17 @@ class PluginService
$this->buildAssets();
$this->manageComposerPackages(oldPackages: $pluginPackages);
// This throws an error when not called with qualifier
foreach (\Filament\Facades\Filament::getPanels() as $panel) {
$panel->clearCachedComponents();
}
} catch (Exception $exception) {
$this->handlePluginException($plugin, $exception);
$this->handlePluginException($plugin, $exception, true);
}
}
/** @throws Exception */
public function downloadPluginFromFile(UploadedFile $file, bool $cleanDownload = false): void
{
// Validate file size to prevent zip bombs
@@ -368,6 +396,7 @@ class PluginService
$zip->close();
}
/** @throws Exception */
public function downloadPluginFromUrl(string $url, bool $cleanDownload = false): void
{
$info = pathinfo($url);
@@ -429,7 +458,11 @@ class PluginService
]);
}
/** @param array<int, string> $order */
/**
* @param array<int, string> $order
*
* @throws JsonException
*/
public function updateLoadOrder(array $order): void
{
foreach ($order as $i => $plugin) {
@@ -473,14 +506,14 @@ class PluginService
return config('panel.plugin.dev_mode', false);
}
private function handlePluginException(string|Plugin $plugin, Exception $exception): void
private function handlePluginException(string|Plugin $plugin, Exception $exception, bool $throw = false): void
{
if ($this->isDevModeActive()) {
$this->setStatus($plugin, PluginStatus::Errored, $exception->getMessage());
if ($throw || $this->isDevModeActive()) {
throw ($exception);
}
report($exception);
$this->setStatus($plugin, PluginStatus::Errored, $exception->getMessage());
}
}

View File

@@ -48,7 +48,7 @@ class DetailsModificationService
// websockets.
if ($server->owner_id !== $owner) {
try {
$this->serverRepository->setServer($server)->revokeUserJTI($owner);
$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

View File

@@ -78,7 +78,10 @@ class ServerDeletionService
}
}
$server->allocations()->update(['server_id' => null]);
$server->allocations()->update([
'server_id' => null,
'notes' => null,
]);
$server->delete();
});

View File

@@ -3,6 +3,7 @@
namespace App\Services\Servers;
use App\Models\Allocation;
use App\Models\Backup;
use App\Models\Node;
use App\Models\Server;
use App\Models\ServerTransfer;
@@ -23,11 +24,19 @@ class TransferServerService
private NodeJWTService $nodeJWTService,
) {}
private function notify(ServerTransfer $transfer, UnencryptedToken $token): void
/**
* @param string[] $backup_uuids
*/
private function notify(ServerTransfer $transfer, UnencryptedToken $token, array $backup_uuids = []): void
{
$backups = [];
if (config('backups.default') === Backup::ADAPTER_DAEMON) {
$backups = $backup_uuids;
}
Http::daemon($transfer->oldNode)->post("/api/servers/{$transfer->server->uuid}/transfer", [
'url' => $transfer->newNode->getConnectionAddress() . '/api/transfers',
'token' => 'Bearer ' . $token->toString(),
'backups' => $backups,
'server' => [
'uuid' => $transfer->server->uuid,
'start_on_completion' => false,
@@ -39,10 +48,11 @@ class TransferServerService
* Starts a transfer of a server to a new node.
*
* @param int[] $additional_allocations
* @param string[] $backup_uuid
*
* @throws Throwable
*/
public function handle(Server $server, int $node_id, ?int $allocation_id = null, ?array $additional_allocations = []): bool
public function handle(Server $server, int $node_id, ?int $allocation_id = null, ?array $additional_allocations = [], ?array $backup_uuid = []): bool
{
$additional_allocations = array_map(intval(...), $additional_allocations);
@@ -93,7 +103,7 @@ class TransferServerService
->handle($transfer->newNode, $server->uuid, 'sha256');
// Notify the source node of the pending outgoing transfer.
$this->notify($transfer, $token);
$this->notify($transfer, $token, $backup_uuid);
return true;
}

View File

@@ -28,7 +28,7 @@ class SubuserDeletionService
event(new SubUserRemoved($subuser->server, $subuser->user));
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
$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]);

View File

@@ -46,7 +46,7 @@ class SubuserUpdateService
$subuser->update(['permissions' => $cleanedPermissions]);
try {
$this->serverRepository->setServer($server)->revokeUserJTI($subuser->user_id);
$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.

View File

@@ -3,6 +3,7 @@
namespace App\Traits;
use Illuminate\Support\Env;
use Illuminate\Support\Facades\Artisan;
use RuntimeException;
trait EnvironmentWriterTrait
@@ -17,5 +18,6 @@ trait EnvironmentWriterTrait
public function writeToEnvironment(array $values = []): void
{
Env::writeVariables($values, base_path('.env'), true);
Artisan::call('config:clear');
}
}

View File

@@ -24,6 +24,6 @@ trait CanCustomizePages
/** @return array<string, PageRegistration> */
public static function getPages(): array
{
return array_unique(array_merge(static::getDefaultPages(), static::$customPages), SORT_REGULAR);
return array_unique(array_merge(static::$customPages, static::getDefaultPages()), SORT_REGULAR);
}
}

View File

@@ -23,6 +23,6 @@ trait CanCustomizeRelations
/** @return class-string<RelationManager>[] */
public static function getRelations(): array
{
return array_unique(array_merge(static::getDefaultRelations(), static::$customRelations));
return array_unique(array_merge(static::$customRelations, static::getDefaultRelations()));
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Traits\Filament;
use App\Enums\TabPosition;
use Filament\Schemas\Components\Tabs\Tab;
trait CanCustomizeStaticTabs
{
/** @var array<string, Tab[]> */
protected static array $customTabs = [];
public static function registerCustomTabs(TabPosition $position, Tab ...$customTabs): void
{
static::$customTabs[$position->value] = array_merge(static::$customTabs[$position->value] ?? [], $customTabs);
}
/** @return Tab[] */
protected static function getDefaultTabs(): array
{
return [];
}
/** @return Tab[] */
protected static function getTabs(): array
{
return array_merge(
static::$customTabs[TabPosition::Before->value] ?? [],
static::getDefaultTabs(),
static::$customTabs[TabPosition::After->value] ?? []
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Traits\Filament;
use App\Enums\StepPosition;
use Filament\Schemas\Components\Wizard\Step;
trait CanCustomizeSteps
{
/** @var Step[] */
protected static array $customSteps = [];
public static function registerCustomSteps(StepPosition $position, Step ...$customSteps): void
{
static::$customSteps[$position->value] = array_merge(static::$customSteps[$position->value] ?? [], $customSteps);
}
/** @return Step[] */
protected function getDefaultSteps(): array
{
return [];
}
/** @return Step[] */
protected function getSteps(): array
{
return array_merge(
static::$customSteps[StepPosition::Before->value] ?? [],
$this->getDefaultSteps(),
static::$customSteps[StepPosition::After->value] ?? []
);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Traits\Filament;
use App\Enums\TabPosition;
use Filament\Schemas\Components\Tabs\Tab;
trait CanCustomizeTabs
{
/** @var array<string, Tab[]> */
protected static array $customTabs = [];
public static function registerCustomTabs(TabPosition $position, Tab ...$customTabs): void
{
static::$customTabs[$position->value] = array_merge(static::$customTabs[$position->value] ?? [], $customTabs);
}
/** @return Tab[] */
protected function getDefaultTabs(): array
{
return [];
}
/** @return Tab[] */
protected function getTabs(): array
{
return array_merge(
static::$customTabs[TabPosition::Before->value] ?? [],
$this->getDefaultTabs(),
static::$customTabs[TabPosition::After->value] ?? []
);
}
}

View File

@@ -44,6 +44,10 @@ return Application::configure(basePath: dirname(__DIR__))
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'node.maintenance' => \App\Http\Middleware\MaintenanceMiddleware::class,
]);
$middleware->priority([
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
})
->withSingletons([
\Illuminate\Contracts\Console\Kernel::class => \App\Console\Kernel::class,

View File

@@ -1,4 +1,4 @@
# [Bounties](https://github.com/pelican-dev/panel/issues?q=is%3Aopen+is%3Aissue+label%3A%22%F0%9F%92%B5+bounty%22)
# [Bounties](https://github.com/pelican-dev/panel/issues?q=state%3Aopen%20is%3Aissue%20label%3A%22%F0%9F%92%B0%20fund%22)
Get paid to improve Pelican!
@@ -15,6 +15,6 @@ This is still valuable work, so we'll pay out $50 for getting any bounty closed
## Issue bounties
We've tagged bounty-eligible issues across openpilot and the rest of our repos; check out all the open ones [here](https://github.com/pelican-dev/panel/issues?q=is%3Aopen+is%3Aissue+label%3A%22%F0%9F%92%B5+bounty%22).
We've tagged bounty-eligible issues across openpilot and the rest of our repos; check out all the open ones [here](https://github.com/pelican-dev/panel/issues?q=state%3Aopen%20is%3Aissue%20label%3A%22%F0%9F%92%B0%20fund%22).
New bounties can be proposed in the [**#feedback**](https://discord.com/channels/1218730176297439332/1218732581797892220) channel in Discord.

View File

@@ -3,26 +3,26 @@
"description": "The free, open-source game management panel. Supporting Minecraft, Spigot, BungeeCord, and SRCDS servers.",
"license": "AGPL-3.0-only",
"require": {
"php": "^8.2 || ^8.3 || ^8.4",
"php": "^8.2 || ^8.3 || ^8.4 || ^8.5",
"ext-intl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"ext-pdo": "*",
"ext-zip": "*",
"aws/aws-sdk-php": "^3.356",
"aws/aws-sdk-php": "^3.369",
"calebporzio/sushi": "^2.5",
"dedoc/scramble": "^0.12.10",
"filament/filament": "~4.0",
"dedoc/scramble": "^0.13",
"filament/filament": "^4.5",
"gboquizosanchez/filament-log-viewer": "^2.1",
"guzzlehttp/guzzle": "^7.10",
"laravel/framework": "^12.37",
"laravel/helpers": "^1.7",
"laravel/framework": "^12.47",
"laravel/helpers": "^1.8",
"laravel/sanctum": "^4.2",
"laravel/socialite": "^5.23",
"laravel/socialite": "^5.24",
"laravel/tinker": "^2.10.1",
"laravel/ui": "^4.6",
"lcobucci/jwt": "^5.5",
"league/flysystem-aws-s3-v3": "^3.29",
"lcobucci/jwt": "^5.6",
"league/flysystem-aws-s3-v3": "^3.30",
"league/flysystem-memory": "^3.29",
"phiki/phiki": "^2.0",
"phpseclib/phpseclib": "~3.0.18",
@@ -32,11 +32,11 @@
"socialiteproviders/authentik": "^5.2",
"socialiteproviders/discord": "^4.2",
"socialiteproviders/steam": "^4.3",
"spatie/laravel-data": "^4.17",
"spatie/laravel-data": "^4.18",
"spatie/laravel-fractal": "^6.3",
"spatie/laravel-health": "^1.34",
"spatie/laravel-permission": "^6.21",
"spatie/laravel-query-builder": "^6.3",
"spatie/laravel-permission": "^6.24",
"spatie/laravel-query-builder": "^6.4",
"spatie/temporary-directory": "^2.3",
"symfony/http-client": "^7.2",
"symfony/mailgun-mailer": "^7.2",
@@ -98,4 +98,4 @@
},
"minimum-stability": "stable",
"prefer-stable": true
}
}

755
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Api\Remote\Backups\BackupRemoteUploadController;
use App\Models\Backup;
return [
@@ -10,16 +11,16 @@ return [
// This value is used to determine the lifespan of UploadPart presigned urls that daemon
// uses to upload backups to S3 storage. Value is in minutes, so this would default to an hour.
'presigned_url_lifespan' => env('BACKUP_PRESIGNED_URL_LIFESPAN', 60),
'presigned_url_lifespan' => (int) env('BACKUP_PRESIGNED_URL_LIFESPAN', 60),
// This value defines the maximal size of a single part for the S3 multipart upload during backups
// The maximal part size must be given in bytes. The default value is 5GB.
// Note that 5GB is the maximum for a single part when using AWS S3.
'max_part_size' => env('BACKUP_MAX_PART_SIZE', 5 * 1024 * 1024 * 1024),
'max_part_size' => (int) env('BACKUP_MAX_PART_SIZE', BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE),
// The time to wait before automatically failing a backup, time is in minutes and defaults
// to 6 hours. To disable this feature, set the value to `0`.
'prune_age' => env('BACKUP_PRUNE_AGE', 360),
'prune_age' => (int) env('BACKUP_PRUNE_AGE', 360),
// Defines the backup creation throttle limits for users. In this default example, we allow
// a user to create two (successful or pending) backups per 10 minutes. Even if they delete
@@ -27,8 +28,8 @@ return [
//
// Set the period to "0" to disable this throttle. The period is defined in seconds.
'throttles' => [
'limit' => env('BACKUP_THROTTLE_LIMIT', 2),
'period' => env('BACKUP_THROTTLE_PERIOD', 600),
'limit' => (int) env('BACKUP_THROTTLE_LIMIT', 2),
'period' => (int) env('BACKUP_THROTTLE_PERIOD', 600),
],
'disks' => [

View File

@@ -3,7 +3,7 @@
use Illuminate\Support\Str;
$database = env('DB_DATABASE', 'database.sqlite');
$datapasePath = database_path($database);
$databasePath = database_path($database);
if (str_starts_with($database, '/') || $database === ':memory:') {
$databasePath = $database;
@@ -41,7 +41,7 @@ return [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => $datapasePath,
'database' => $databasePath,
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
@@ -65,7 +65,7 @@ return [
'strict' => env('DB_STRICT_MODE', false),
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@@ -85,7 +85,7 @@ return [
'strict' => env('DB_STRICT_MODE', false),
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],

View File

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

View File

@@ -8,7 +8,7 @@ To start contributing to Pelican Panel, you need to have a basic understanding o
* [PHP](https://php.net) & [Laravel](https://laravel.com)
* [Livewire](https://laravel-livewire.com) & [Filament](https://filamentphp.com)
* [Git](https://git-scm.com) & [Github](https://github.com)
* [Git](https://git-scm.com) & [GitHub](https://github.com)
## Dev Environment Setup

View File

@@ -13,8 +13,6 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
$this->call(EggSeeder::class);
Role::firstOrCreate(['name' => Role::ROOT_ADMIN]);
$plugins = Plugin::query()->orderBy('load_order')->get();

View File

@@ -1,101 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggImporterService;
use DirectoryIterator;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use Symfony\Component\Yaml\Yaml;
use Throwable;
class EggSeeder extends Seeder
{
protected EggImporterService $importerService;
/**
* @var string[]
*/
public static array $imports = [
'Minecraft',
'Source Engine',
'Voice Servers',
'Rust',
];
/**
* EggSeeder constructor.
*/
public function __construct(
EggImporterService $importerService
) {
$this->importerService = $importerService;
}
/**
* Run the egg seeder.
*/
public function run(): void
{
foreach (static::$imports as $import) {
$this->parseEggFiles($import);
}
}
/**
* Loop through the list of egg files and import them.
*/
protected function parseEggFiles($name): void
{
$path = database_path('Seeders/eggs/' . kebab_case($name));
$files = new DirectoryIterator($path);
$this->command->alert('Updating Eggs for: ' . $name);
/** @var DirectoryIterator $file */
foreach ($files as $file) {
if (!$file->isFile() || !$file->isReadable()) {
continue;
}
$extension = strtolower($file->getExtension());
$filePath = $file->getRealPath();
try {
$decoded = match ($extension) {
'json' => json_decode(file_get_contents($filePath), true, 512, JSON_THROW_ON_ERROR),
'yaml', 'yml' => Yaml::parseFile($filePath),
default => null,
};
} catch (Throwable) {
$this->command->warn("Failed to parse {$file->getFilename()}, skipping.");
continue;
}
if (!is_array($decoded) || !isset($decoded['name'], $decoded['author'])) {
$this->command->warn("Invalid structure in {$file->getFilename()}, skipping.");
continue;
}
$uploaded = new UploadedFile($filePath, $file->getFilename());
$egg = Egg::query()
->where('author', $decoded['author'])
->where('name', $decoded['name'])
->first();
if ($egg instanceof Egg) {
$this->importerService->fromFile($uploaded, $egg);
$this->command->info('Updated ' . $decoded['name']);
} else {
$this->importerService->fromFile($uploaded);
$this->command->comment('Created ' . $decoded['name']);
}
}
$this->command->line('');
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,159 +0,0 @@
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
meta:
version: PLCN_v3
update_url: 'https://github.com/pelican-dev/panel/raw/main/database/Seeders/eggs/minecraft/egg-sponge.yaml'
exported_at: '2025-10-31T12:41:03+00:00'
name: Sponge
author: panel@example.com
uuid: f0d2f88f-1ff3-42a0-b03f-ac44c5571e6d
description: 'A community-driven open source Minecraft: Java Edition modding platform.'
image: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyMDAgMjAwIiBmaWxsPSIjRjdDRjBEIj48cGF0aCBkPSJNMTkwIDBIMTBDNC41IDAgMCA0LjUgMCAxMHYxODBjMCA1LjUgNC41IDEwIDEwIDEwaDE2LjFjLTEuNy00NS43LS4xLTUyLjUgMy4xLTU3IDMuOS01LjYgNS41LTYuMyAxMS40LTExIDUtNCAzLjItMTAuNS0uNC0xNS4yLTIuMi0yLjktNS4zLTYuMy03LjctOS42LTEuNS0yLjIgMi4yLTE1LjEgMy42LTE5LjggMS40LTQuNyAzLjgtMjAgMjQuOC0yNC4xIDcuOS0xLjYgMjkuNi0yLjcgNDQuNS0xLjgtLjEtLjYtLjMtMS4zLS40LTItLjMtMS4yLS41LTIuNS0uOC0zLjktLjMtMS4zLS42LTIuNy0uOS00LjEtLjMtMS40LS43LTIuOC0xLTQuMy0uNC0xLjUtLjctMi45LTEuMi00LjQtLjgtMy0xLjgtNS45LTMtOC43LS42LTEuNC0xLjItMi43LTEuOS0zLjktLjctMS4xLTEuNC0yLjEtMi0yLjUtLjEtLjEtLjItLjItLjMtLjJoLS4xLjJzLjEgMCAwIDBsLS4zLS4xaC0uMmwtLjQtLjFoLS41Yy0xLjMtLjEtMi43LS4xLTQuMiAwLTIuOS4yLTYgLjgtOSAxLjVzLTUuOSAxLjYtOC43IDIuNGMtMS4yLjQtMi4zLjgtMy40IDEuMS4xLjkuMiAxLjcuMiAyLjYgMCAxMy0xMC41IDIzLjUtMjMuNSAyMy41UzIwLjYgNDcuOSAyMC42IDM0LjlzMTAuNS0yMy41IDIzLjUtMjMuNWM4LjcgMCAxNi4zIDQuNyAyMC40IDExLjggMS0uNCAyLjEtLjggMy4yLTEuMiAyLjgtMS4xIDUuOS0yLjIgOS4xLTMuMiAzLjMtMSA2LjctMiAxMC41LTIuNSAxLjktLjMgMy45LS40IDYuMS0uM2guOGMuMyAwIC42LjEuOC4xSDk1LjdsLjMuMWguMWwuMy4xcy4yIDAgLjMuMWwuNC4xYy42LjIuOS4zIDEuMy41cy43LjMgMS4xLjVjLjcuNCAxLjMuOCAxLjkgMS4yIDEuMS45IDIgMS44IDIuNyAyLjcuOC45IDEuNCAxLjggMiAyLjcgMS4yIDEuOCAyLjEgMy41IDIuOSA1LjIgMS42IDMuNCAyLjkgNi44IDMuOSAxMGwxLjUgNC44Yy41IDEuNi44IDMuMSAxLjIgNC42LjIuNy40IDEuNS41IDIuMi4yLjcuMyAxLjQuNSAyLjEuMyAxLjQuNiAyLjguOSA0LjEuNCAyIC43IDMuOSAxIDUuNiAyMi40IDIuMiAzOS41IDUuMSA0Ny4yIDEyLjggMTEuMyAxMSAyMCA2MSAxNC4zIDEyNC41aDEwYzUuNSAwIDEwLTQuNSAxMC0xMFYxMGMwLTUuNS00LjUtMTAtMTAtMTB6Ii8+PHBhdGggZD0iTTkxLjQgMTQwLjhjLTEuMyAzLjYtMi40IDQ1LjcgMTAgNDUuN3MxMi41LTQzLjIgMTIuMS00NS43Yy0uNC0yLjQtMjAuOC0zLjUtMjIuMSAwek03NSAxMDBjLTguNS0xLjItMTMuNiA0MC4yLTEuNyA0Mi42IDExLjIgMi4yIDEwLjEtNDEuNCAxLjctNDIuNnpNMTMwLjggMTAwYy04LjUtMS4yLTEzLjYgNDAuMi0xLjcgNDIuNiAxMS4yIDIuMiAxMC4yLTQxLjQgMS43LTQyLjZ6Ii8+PC9zdmc+'
tags:
- minecraft
features:
- eula
- java_version
- pid_limit
docker_images:
'Java 21': 'ghcr.io/pelican-eggs/yolks:java_21'
'Java 17': 'ghcr.io/pelican-eggs/yolks:java_17'
'Java 16': 'ghcr.io/pelican-eggs/yolks:java_16'
'Java 11': 'ghcr.io/pelican-eggs/yolks:java_11'
'Java 8': 'ghcr.io/pelican-eggs/yolks:java_8'
file_denylist: { }
startup_commands:
Default: 'java -Xms128M -XX:MaxRAMPercentage=95.0 -jar {{SERVER_JARFILE}}'
config:
files:
server.properties:
parser: properties
find:
server-ip: ''
server-port: '{{server.allocations.default.port}}'
query.port: '{{server.allocations.default.port}}'
startup:
done: ')! For help, type '
logs: { }
stop: stop
scripts:
installation:
script: |-
#!/bin/ash
# Sponge Installation Script
#
# Server Files: /mnt/server
cd /mnt/server
if [ $MINECRAFT_VERSION = 'latest' ] || [ -z $MINECRAFT_VERSION ]; then
TARGET_VERSION_JSON=$(curl -sSL https://dl-api.spongepowered.org/v2/groups/org.spongepowered/artifacts/${SPONGE_TYPE}/latest?recommended=true)
if [ -z "${TARGET_VERSION_JSON}" ]; then
echo -e "Failed to find latest recommended version!"
exit 1
fi
echo -e "Found latest version for ${SPONGE_TYPE}"
else
if [ $SPONGE_TYPE = 'spongevanilla' ]; then
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)
else
FORGETAG='forge'
if [ $SPONGE_TYPE = 'spongeneo' ]; then
FORGETAG='neoforge'
fi
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)
fi
if [ -z "${VERSIONS_JSON}" ]; then
echo -e "Failed to find recommended ${MINECRAFT_VERSION} version for ${SPONGE_TYPE} ${FORGE_VERSION}!"
exit 1
fi
VERSION_KEY=$(echo $VERSIONS_JSON | jq -r '.artifacts | to_entries[0].key')
TARGET_VERSION_JSON=$(curl -sSL https://dl-api.spongepowered.org/v2/groups/org.spongepowered/artifacts/${SPONGE_TYPE}/versions/${VERSION_KEY})
if [ -z "${TARGET_VERSION_JSON}" ]; then
echo -e "Failed to find ${VERSION_KEY} for ${SPONGE_TYPE} ${FORGE_VERSION}!"
exit 1
fi
echo -e "Found ${MINECRAFT_VERSION} for ${SPONGE_TYPE}"
fi
TARGET_VERSION=`echo $TARGET_VERSION_JSON | jq '.assets[] | select(.classifier == "universal")'`
if [ -z "${TARGET_VERSION}" ]; then
TARGET_VERSION=`echo $TARGET_VERSION_JSON | jq '.assets[] | select(.classifier == "" and .extension == "jar")'`
fi
if [ -z "${TARGET_VERSION}" ]; then
echo -e "Failed to get download url data from the selected version"
exit 1
fi
SPONGE_URL=$(echo $TARGET_VERSION | jq -r '.downloadUrl')
CHECKSUM=$(echo $TARGET_VERSION | jq -r '.md5')
echo -e "Found file at ${SPONGE_URL} with checksum ${CHECKSUM}"
echo -e "running: curl -o ${SERVER_JARFILE} ${SPONGE_URL}"
curl -o ${SERVER_JARFILE} ${SPONGE_URL}
if [ $(basename $(md5sum ${SERVER_JARFILE})) = ${CHECKSUM} ] ; then
echo "Checksum passed"
else
echo "Checksum failed"
fi
echo -e "Install Complete"
container: 'ghcr.io/pelican-eggs/installers:alpine'
entrypoint: ash
variables:
-
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: 3
-
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
- string
- 'between:3,15'
sort: 1
-
name: 'Server Jar File'
description: 'The name of the Jarfile to use when running Sponge.'
env_variable: SERVER_JARFILE
default_value: server.jar
user_viewable: true
user_editable: true
rules:
- required
- 'regex:/^([\w\d._-]+)(\.jar)$/'
sort: 4
-
name: 'Sponge Type'
description: |-
SpongeVanilla if you are only using Sponge plugins.
SpongeForge when using Forge mods and Sponge plugins.
SpongeNeo 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'
sort: 2

File diff suppressed because one or more lines are too long

View File

@@ -1,155 +0,0 @@
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
meta:
version: PLCN_v3
update_url: 'https://github.com/pelican-dev/panel/raw/main/database/Seeders/eggs/source-engine/egg-custom-source-engine-game.yaml'
exported_at: '2025-10-31T12:43:00+00:00'
name: 'Custom Source Engine Game'
author: panel@example.com
uuid: 2a42d0c2-c0ba-4067-9a0a-9b95d77a3490
description: |-
This option allows modifying the startup arguments and other details to run a custom SRCDS based
game on the panel.
image: 'data:image/webp;base64,UklGRoAMAABXRUJQVlA4THMMAAAv+QATECUwbtvIkdx/2ZNnLnwjYgLYqi8KnDuYekQOBtxU1VNGcZdt8CWWtnXLbdOCTsNlE6Ix5eSRaVdUVr1HA0Dlx+NLkmTVtm3binROhla09D55Lsi/tN5reIlWJ3xHSKBt7dii9/uN8Cv8yLaxjGXbrrFt20YY2whj2zZy/bbVK8FtI0mS6JnZe9d1ZWVW7QskSZIcN1L9/zwbHDaPlYyAqtAy1NVBG0mOnCyW92E8+9FhG0mKVBkf/fjgKXVZkCSbttXXtm3btm3bfrZt27b5dW3btm3uuyBIkuSoyWLwSHjPBwr8a2Y+MUMVHtiDh/iHYtShBa1oQCUqUYv6V2h4HQO0ogXt6EbXK/Rj4BUGMfYOk5h5hwUsYRGLWMb6O2yBgGATs2jDT6RbYlG4eWMvZksTu/EbXVgF+bEwjZsQvbV53+lhjBtoBwXyw2EUAVfG8CEddaBAviBs48h9QRSXMAbyLeHAZc3HiwfHMAHyPWHLcrsqGKIT5KtCh8V1UWZ4XvNl4dBFIfbjOYYVdOE/bmEPwuEBB9jBGQFIxll8QC1mzkPXhP2h2DWzJtas8qjUohQoemVdDC7uoR8vEQrFf+dh2h+rP78/jSUCe5xHJTYOL+D8UDZVX+9qfHHqdc0oUMvrzcVgzwbm8I5xAlvumP153XRQx5mzt0Rcex7bwtelohao2/WqmPWhbl8Mo4D5JwzjBCNROXJjsSIQpQch86kIdaVohdSJ2lLPanOdv9kOiMfmGyhcsXirx25eQw8fNByDeoPhqa491cUKrpMVXp1RuFljT2M8RQ7ejD3tThy9cU4W7MPcIWj3xPpQuFpdlgVqVTEKFLvWR6GF5owics9A4zgnUxQ6aj4PRpwHdQJyotBUCMHCAbjXlSjACpN1hk9bogBDjFVhZM7w9iUKMKv+R9yPQmfBq/YsGlswaE0UsK8EuVFoLnyuQHh3IIK+PPRxcRe9ozYeiQUyAvSLNgYBGQn+R+Rv4gc0EtIb+mTjEJGRFvZFG7XxSEhwvyIv1EJgHi4XeAFH5bbr9E4vPjG+Pu912qoYa4itQlv1+GyQEE+vl85bIGYRs03i2IIDlbFrbTihPKFzaiUW4YKlUsd8wMX3XmD3f4S7Jh0e+G2E0Kgf7juobrkMvWGFmeuc1/rwcf310gkt3H5GCgyDn1lYg9rYG0aRW5RgDpmrUr9HjhYixhmi2MyYP1Yvh0QRLdkl5AZYZ27RXOvQW/ukle7agULC50llit5rfelXD5xi92jBFCp3lW7cGYrpPjKlZjRuSRuCFpaT8D89Aj0CBzGmk8sBT5eYz9VIrdjkTkU4VCiZZ72ObU9O9A475Ylz2FCpF2K8As/xTvLB94hqfecd4/J2KjAKniTBN2vlkYKzGBUbqSWZVfOGX6M8HIUDTztbnxzvdnIBAfrB7BeUhfTgJIzWbUyyKU6NmhmROgJVrObe8J3zYk1aMRTgNAQCA4vH1fDF/RV2wmb4C2ZfIPaB0zj2wHSYXg2LUSssMAm+pOBUct7w3oHzeAHXmPNCNUJhfwFJzoAHtoPz2N5sqtfjYhRiJ+CUgWVLMUk12JE7Y4ZShbvGcnfpQhPNV7KYImYaOjDGj824HaPNDAcawIiBlinXUHQB5XK++GjyJen3YiSMczKhJQE/opCks/OVZ2siytfOy4vF8B/smNzWLJIXPylW+A2GaL0dMzkYScbEC+idUyKIl3A1rtIHviF+Mi3da5xSLpQnP2k2Gg5N7C0wCC4nwC3pQTHUKQhdeJM0dHw+WZ8GW7mtGbJ1zOH0EH3bD463BAKfPPTYJ3wx6Tf5L761XQkGQhfVH7DxwlW//FqzZeNK+HcFDcDyMzSNgzlp+dH2I70CUhlteYu3PUEYTFZwqmYu2OPSX4LRRambdpl5CaLqDPMKdYVTdHo+2NJLePZmFZohuN/LODIADvSf+vxWFNijJlFmVN8jXDM/mAF2GnPhcNHT8PoBeDJ7M2vBTlnsa324rbEpgGGkD398YzOhtA3J5hQ4B78+wRyk6gwKDR5oli2L8CHMdrxZqExoGAbMvh+5yDAR9r3zoEDEO0wkxRRQ0ys4WCDH1V5H0wAOfYI3UcjilgY6Cq07fLRM4UTRmXoq5H7nzv1MLtAJYQrXc/ZAeUyEdwm53AIudFIO1HYuD8D5E9ikfYt4my97P7Sq57vIJ3J6KVo+n1I7G0S5my/v+MD43oWY/WGbWGKgX4Kx8Ij+92YoB8uJtvFvk8/mjBrW9lDx/7bSRSGdKeApTHp1WSIZNbGw829jTySF2wOfu1mYimnvPC8wDm+G/k846ZjjKY45Td3PEh5n4MPEHtKikPeO83C0TCJwRR36eae4W+BkO4RBQNrNjAmNi8jOoTwp4OuRdvbr/6dD9xbGx7mEopCo/GQczwa2F651zhyhEoW70c2MpGowPnRW5Km9gcZBV1Rt4VEUSrG0iw6cxbDcw+mg/VzXYGfCzXxMvd4IfnVm5Zl0AzMGkL8DyjKKQjH+9uLOtoVR/Qe3O21HLAx0Js55NuNugux/Z1qeyTewYADZOyiIQj00+dp8mPxxahFPrndOnX9A2Kkbe0e8mdGVIPjZWTSn4QbmDiBvB7EFbskbZrkqs9ULYvAQ3U8Od24dbTZNJyshR41gfJ1xPYH2rrNzTnaYsitXoHAWJVwHULH7KMviK3Bftr0Mu/2LaHkUsDSsRH9An4gHO34J1bKPEAy+rICusDA4gUNhfQkKrMAw02Bd4Lb8fyy06NjArSjcm01U7H36UF5AQbjkeEZ5HWEEvYDSjp53hEPxOts7A5t0YDElwSUU5m8ybh/shNzH4MLYCpvQKdk0tGvykYR3KjC7ALewijP+xM3OwwIF5IWiMiPp4OuUhGYSD9xJ6ydPegUtN/7XDSzvvoxU4j+u0c8QeIkQyyw2UWd+gQJehNXpn8jhPkQT54ISAosiO2/AgbRzNEpHmXp7pqvczRMxlpj/GYtVDsB2sxnBNXr7wMAlgvYtTREKWJAuuSycj/QCnvilx8A/otPUzOjU8EkUrbDZxed1gQI7iKFP5yVkb4Q+FFg1gJ0r9Mw5OGp0zW5K7Ak7GR/nA0kYSHjNZja6PwfkUBTU0H2DOA7eQX6o5jXrQCZ9ONXzFM7XbZc80H0yT8l9AJ9WOBeFGtM6IqFBOrPJsQ8Xi14JfT1vEUPHfHkV4WNa9Ktof8YZ8BYwnoVuHnLsz2NIwr7A+SAtNPM5nI+89VReaDCs6yGxMvZULuIQhhJcVQt3tUiWMB5ucptlKD2TdUk5L0zsHW522sGgk5eu9saHwp5ZqVmzRS6SgvcoIIU5vVewSRwOJm+1bqUm1l+qOR960Lighj8WJm/lzlCG48tzAPrYbvArCtVsTvvFN9tUSZOqyq44kZDTR/64Q6locw29i3UhDRzkLgVsyf3oKJnzk/RNvZ4UBvPylYbK2GHKPIAT62Z4lKH6DhZyoXWz6oVmZO3lzsJ4P8LowS02TieYjkpcPx+p5tVc/xE94P2iXV8honm8TuYolBOCP+fLsuvdbBwjlRGGyBFtim5RYFHcnKJLtzAFlFNebFqBBJiuqzgchQMJ9XfKS47xiRHVjG/CUzNbB1x7IEruUWCTtA5fFcrOM3T/ZtxELzCBZwvMW9JROBGGg7QTlDYsi9xNsDuTq8C2vCzaiC3MB8z2IBZQNnt7rXOy4Yr9PmN29bQAYquVfvAyz9IHvsAEZDC/wMcoHEqAfQZODNjK7VgNWprrVJkhu8/DuUyv4wDohfTLkMz4CLT5ydHewZXRdrMy2UfmfCxdk7yRczXBDhMeHlXZs9wKzIJr6+axp0MUjvWwlGKZSz7qxycmxtPnvfMWiWcxqvYVwlG9Vs/9xyUiNOyHm9bKYDkptrFirXXHb6NERAa9sk3845PnZbE9tvuj7fHxupNd74A53O6NFW6BK74aJiDG0+uVdo2chtUWQ7EdHvprlIgY118P7VCwaQWGgTxmFqiezxd9FI72J7LJ23axsbYN0ovdHoTNwGK/KRv9Sq93zKZjYrIs8FcFijX7xd42i/hiNTh0TCx0uFd64dW6ioQodBucQC3QaU+ubs0ZHjSvq0hvdxVP1kGLGfZuIQFkhcBuBzZYXuFvtwOtzdt+mIVKszjV0AfS9WFguBt8aXagiLHNoHmcU6BZeLAZTEE7Cs1C9ebn8XCNQrdQvMAGQqPQLpxaBhFR6Ne/c/Gg9gEZUeg4QwRvsQiC9rM/jCoAAA=='
tags:
- source
- steamcmd
features:
- steam_disk_space
docker_images:
Source: 'ghcr.io/pelican-eggs/games:source'
file_denylist: { }
startup_commands:
Default: './srcds_run -game {{SRCDS_GAME}} -console -port {{SERVER_PORT}} +map {{SRCDS_MAP}} +ip 0.0.0.0 -strictportbind -norestart'
config:
files: { }
startup:
done: 'gameserver Steam ID'
logs: { }
stop: quit
scripts:
installation:
script: |-
#!/bin/bash
# steamcmd Base Installation Script
#
# Server Files: /mnt/server
##
#
# Variables
# STEAM_USER, STEAM_PASS, STEAM_AUTH - Steam user setup. If a user has 2fa enabled it will most likely fail due to timeout. Leave blank for anon install.
# WINDOWS_INSTALL - if it's a windows server you want to install set to 1
# SRCDS_APPID - steam app id ffound here - https://developer.valvesoftware.com/wiki/Dedicated_Servers_List
# EXTRA_FLAGS - when a server has extra glas for things like beta installs or updates.
#
##
## just in case someone removed the defaults.
if [ "${STEAM_USER}" == "" ]; then
echo -e "steam user is not set.
"
echo -e "Using anonymous user.
"
STEAM_USER=anonymous
STEAM_PASS=""
STEAM_AUTH=""
else
echo -e "user set to ${STEAM_USER}"
fi
## download and install steamcmd
cd /tmp
mkdir -p /mnt/server/steamcmd
curl -sSL -o steamcmd.tar.gz https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz
tar -xzvf steamcmd.tar.gz -C /mnt/server/steamcmd
mkdir -p /mnt/server/steamapps # Fix steamcmd disk write error when this folder is missing
cd /mnt/server/steamcmd
# SteamCMD fails otherwise for some reason, even running as root.
# This is changed at the end of the install process anyways.
chown -R root:root /mnt
export HOME=/mnt/server
## install game using steamcmd
./steamcmd.sh +force_install_dir /mnt/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ "${WINDOWS_INSTALL}" == "1" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6
## set up 32 bit libraries
mkdir -p /mnt/server/.steam/sdk32
cp -v linux32/steamclient.so ../.steam/sdk32/steamclient.so
## set up 64 bit libraries
mkdir -p /mnt/server/.steam/sdk64
cp -v linux64/steamclient.so ../.steam/sdk64/steamclient.so
container: 'ghcr.io/pelican-eggs/installers:debian'
entrypoint: bash
variables:
-
name: 'Game ID'
description: 'The ID corresponding to the game to download and run using SRCDS.'
env_variable: SRCDS_APPID
default_value: ''
user_viewable: true
user_editable: false
rules:
- required
- numeric
- 'digits_between:1,6'
sort: 1
-
name: 'Game Name'
description: 'The name corresponding to the game to download and run using SRCDS.'
env_variable: SRCDS_GAME
default_value: ''
user_viewable: true
user_editable: false
rules:
- required
- alpha_dash
- 'between:1,100'
sort: 2
-
name: Map
description: 'The default map for the server.'
env_variable: SRCDS_MAP
default_value: ''
user_viewable: true
user_editable: true
rules:
- required
- string
- alpha_dash
sort: 3
-
name: 'Steam Auth'
description: ''
env_variable: STEAM_AUTH
default_value: ''
user_viewable: true
user_editable: true
rules:
- nullable
- string
sort: 6
-
name: 'Steam Password'
description: ''
env_variable: STEAM_PASS
default_value: ''
user_viewable: true
user_editable: true
rules:
- nullable
- string
sort: 5
-
name: 'Steam Username'
description: ''
env_variable: STEAM_USER
default_value: ''
user_viewable: true
user_editable: true
rules:
- nullable
- string
sort: 4

View File

@@ -1,229 +0,0 @@
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
meta:
version: PLCN_v3
update_url: 'https://github.com/pelican-dev/panel/raw/main/database/Seeders/eggs/source-engine/egg-garrys-mod.yaml'
exported_at: '2025-10-31T12:37:53+00:00'
name: 'Garrys Mod'
author: panel@example.com
uuid: 60ef81d4-30a2-4d98-ab64-f59c69e2f915
description: |-
Garrys Mod, is a sandbox physics game created by Garry Newman, and developed by his company,
Facepunch Studios.
image: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAyNC4zLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjxzdmcgdmVyc2lvbj0iMS4xIiBpZD0iTGF5ZXJfMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeD0iMHB4IiB5PSIwcHgiDQoJIHZpZXdCb3g9IjAgMCAzODQgMzg0IiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCAzODQgMzg0OyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+DQo8c3R5bGUgdHlwZT0idGV4dC9jc3MiPg0KCS5zdDB7ZmlsbDojMDA4MUZGO30NCgkuc3Qxe2ZpbGw6I0ZGRkZGRjt9DQo8L3N0eWxlPg0KPHBhdGggY2xhc3M9InN0MCIgZD0iTTM0My45MiwzODRjLTEwMS42LDAtMjAzLjIsMC0zMDQuOCwwYy0wLjg5LTAuNDQtMS44NC0wLjMxLTIuNzktMC4zOGMtNC41MS0wLjM2LTguNjEtMi4wNy0xMi41Ni00LjEzDQoJYy01LjktMy4wNy0xMC44NC03LjMxLTE0LjkyLTEyLjU0Yy0zLjkzLTUuMDQtNi43My0xMC42NS04LjE3LTE2LjljLTAuMzktMS42OC0wLjMxLTMuNDEtMC40MS01LjEyYy0wLjAyLTAuMzQsMC4wOS0wLjc0LTAuMjctMQ0KCUMwLDI0Mi42NCwwLDE0MS4zNiwwLDQwLjA4YzAuNDMtMC42NywwLjI1LTEuNDQsMC4yNy0yLjE1YzAuMTEtNC42NCwxLjU3LTguOTIsMy42My0xMi45N0M5LjIyLDE0LjQ4LDE3LjI1LDYuODgsMjguMjUsMi40Ng0KCWMzLjM2LTEuMzUsNi44MS0yLjA5LDEwLjQtMi4yN0MzOS4xMywwLjE2LDM5LjY3LDAuNiw0MC4wOCwwQzE0MS45MiwwLDI0My43NiwwLDM0NS42LDBjMC43MSwwLjc3LDEuNjgsMC4zNiwyLjQ5LDAuNDINCgljMS45MiwwLjE1LDMuNzQsMC42NCw1LjUzLDEuMjVjNy4zMSwyLjQ2LDEzLjU4LDYuNTYsMTguODIsMTIuMmM1LjM5LDUuOCw5LjEyLDEyLjUsMTAuOSwyMC4yNWMwLjM4LDEuNjYsMC4yOSwzLjM3LDAuMzksNS4wNg0KCWMwLjAyLDAuMzItMC4wNywwLjY3LDAuMjYsMC45YzAsMTAxLjI4LDAsMjAyLjU2LDAsMzAzLjg0Yy0wLjU4LDAuOC0wLjM1LDEuNzYtMC4zNCwyLjU5YzAuMDMsMi43LTAuNjMsNS4yNC0xLjUzLDcuNzENCgljLTQuMjMsMTEuNTUtMTEuODgsMjAuMTItMjIuOCwyNS43M2MtMy4xMywxLjYxLTYuNDIsMi45MS05Ljk2LDMuNDNjLTEuNTIsMC4yMi0zLjA0LDAuMjYtNC41NiwwLjMzDQoJQzM0NC40OCwzODMuNzMsMzQ0LjE1LDM4My42OCwzNDMuOTIsMzg0eiIvPg0KPHBhdGggY2xhc3M9InN0MSIgZD0iTTIzNS41NywyMjEuOTFjLTIuODQsMy40LTUuNjgsNy41MS05LjIyLDEwLjg4Yy03LjIsNi44NC0xNi4yLDEwLjQxLTI1Ljc3LDEyLjQ5DQoJYy0xMS41MywyLjUxLTIzLjEzLDIuMTgtMzQuNzYsMC42MWMtMTQuNTItMS45NS0yNy4yNy03LjgzLTM4LjM4LTE3LjIyYy05LjM1LTcuOTEtMTYuMDYtMTcuOC0yMC44NC0yOS4wNA0KCWMtNC44OC0xMS40Ni03LjQtMjMuNTEtOC42OC0zNS44MWMtMS44NS0xNy43OC0wLjk5LTM1LjQyLDQuMzMtNTIuNmM0LjM3LTE0LjExLDExLjAxLTI3LDIxLjA4LTM4LjAzDQoJQzEzNSw2MC40LDE0OS4xOCw1Mi4wOCwxNjYuMSw0OC41OWM5LjQ2LTEuOTUsMTkuMDEtMS45NSwyOC41Ny0wLjRjMTcsMi43NywzMC4xMywxMS40OSwzOS43NSwyNS42OGMwLjI1LDAuMzIsMS4xOSwxLjcyLDEuMTksMS43Mg0KCXMwLTQuNzgsMC02LjgzYy0wLjAxLTUuMTYsMC0xNi41MSwwLTE2LjUxczEuMDksMCwxLjM1LDBjMTUuMjgtMC4wNSw0OS45Ny0wLjAxLDQ5Ljk3LTAuMDFzLTAuMDEsMS45Ni0wLjAxLDQuMjgNCgljMCw2NC40LDAuMDksMTI4LjgtMC4wNSwxOTMuMTljLTAuMDQsMjAuMzYtNS43OSwzOS0xOC44NCw1NC45NWMtMTAuMTUsMTIuNC0yMy4yNSwyMC41Ny0zOC4yNSwyNS44NQ0KCWMtMTQuNiw1LjE0LTI5LjcsNi45MS00NS4wOCw2LjI2Yy0xNi44MS0wLjcxLTMyLjk5LTQuMzUtNDcuNzUtMTIuNjRjLTIyLjIyLTEyLjQ5LTM1LjcyLTMxLjIyLTM5LjA3LTU2Ljc4DQoJYy0wLjQ1LTMuMzktMC41Ni02Ljg0LTAuNjktMTAuMjZjLTAuMDEtMC4yMS0wLjA3LTEuMzEtMC4wNy0xLjMxczEuMDQsMCwxLjI2LDBjMTUuMjQtMC4wNywzMC40OC0wLjA4LDQ1LjcyLTAuMDMNCgljMC4xOCwwLDAuOSwwLjAxLDAuOSwwLjAxcy0wLjAxLDAuOTYtMC4wMSwxLjEyYzAuMTYsOC44NiwyLjYyLDE2Ljg2LDguNzcsMjMuNDRjNC41OSw0LjkxLDEwLjI2LDguMTYsMTYuNyw5Ljg5DQoJYzE0Ljk2LDQuMDMsMjkuNTksMy4xOSw0My40OS0zLjk4YzEyLjUtNi40NSwyMC40LTE2LjU0LDIxLjMtMzAuODVjMC42Ny0xMC43OSwwLjI3LTIxLjY2LDAuMzQtMzIuNDkNCglDMjM1LjU3LDIyMi4zNSwyMzUuNTcsMjIxLjc5LDIzNS41NywyMjEuOTF6Ii8+DQo8cGF0aCBjbGFzcz0ic3QwIiBkPSJNMTUwLjc3LDE0OS44MmMtMC4zLTExLjc5LDAuOTMtMjMuMzcsNS41NS0zNC4zNWM1LjQzLTEyLjg5LDE0LjkxLTIwLjc0LDI4LjktMjIuODgNCgljOS42Ny0xLjQ4LDE5LjA4LTAuODUsMjcuOTksMy40N2M4LjksNC4zMiwxNC4zOSwxMS43LDE3Ljk5LDIwLjY0YzMuNTMsOC43NSw1LjMzLDE3Ljk0LDUuNDYsMjcuMzFjMC4xMiw4LjM4LDAuMDksMTYuODktMS4yLDI1LjE0DQoJYy0yLjU5LDE2LjU3LTE0LjA5LDMyLjA4LTMxLjczLDM2LjRjLTE2LjA5LDMuOTQtMzIuNjktMi4yMy00Mi4yOC0xNS44MWMtNi43NC05LjUzLTkuODUtMjAuMjEtMTAuNjYtMzEuNjgNCglDMTUwLjYxLDE1NS4zMywxNTAuNzcsMTUyLjU3LDE1MC43NywxNDkuODJ6Ii8+DQo8L3N2Zz4NCg=='
tags:
- source
- steamcmd
features:
- gsl_token
- steam_disk_space
docker_images:
Source: 'ghcr.io/pelican-eggs/games:source'
file_denylist: { }
startup_commands:
Default: './srcds_run -game garrysmod -console -port {{SERVER_PORT}} +ip 0.0.0.0 +host_workshop_collection {{WORKSHOP_ID}} +map {{SRCDS_MAP}} +gamemode {{GAMEMODE}} -strictportbind -norestart +sv_setsteamaccount {{STEAM_ACC}} +maxplayers {{MAX_PLAYERS}} -tickrate {{TICKRATE}} $( [ "$LUA_REFRESH" == "1" ] || printf %s ''-disableluarefresh'' )'
config:
files: { }
startup:
done: 'gameserver Steam ID'
logs: { }
stop: quit
scripts:
installation:
script: |-
#!/bin/bash
# steamcmd Base Installation Script
#
# Server Files: /mnt/server
## just in case someone removed the defaults.
if [ "${STEAM_USER}" == "" ]; then
echo -e "steam user is not set.
"
echo -e "Using anonymous user.
"
STEAM_USER=anonymous
STEAM_PASS=""
STEAM_AUTH=""
else
echo -e "user set to ${STEAM_USER}"
fi
## download and install steamcmd
cd /tmp
mkdir -p /mnt/server/steamcmd
curl -sSL -o steamcmd.tar.gz https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz
tar -xzvf steamcmd.tar.gz -C /mnt/server/steamcmd
mkdir -p /mnt/server/steamapps # Fix steamcmd disk write error when this folder is missing
cd /mnt/server/steamcmd
# SteamCMD fails otherwise for some reason, even running as root.
# This is changed at the end of the install process anyways.
chown -R root:root /mnt
export HOME=/mnt/server
## install game using steamcmd
./steamcmd.sh +force_install_dir /mnt/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ "${WINDOWS_INSTALL}" == "1" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6
## set up 32 bit libraries
mkdir -p /mnt/server/.steam/sdk32
cp -v linux32/steamclient.so ../.steam/sdk32/steamclient.so
## set up 64 bit libraries
mkdir -p /mnt/server/.steam/sdk64
cp -v linux64/steamclient.so ../.steam/sdk64/steamclient.so
# Creating needed default files for the game
cd /mnt/server/garrysmod/lua/autorun/server
echo '
-- Docs: https://wiki.garrysmod.com/page/resource/AddWorkshop
-- Place the ID of the workshop addon you want to be downloaded to people who join your server, not the collection ID
-- Use https://beta.configcreator.com/create/gmod/resources.lua to easily create a list based on your collection ID
resource.AddWorkshop( "" )
' > workshop.lua
cd /mnt/server/garrysmod/cfg
echo '
// Please do not set RCon in here, use the startup parameters.
hostname "New Gmod Server"
sv_password ""
sv_loadingurl ""
sv_downloadurl ""
// Steam Server List Settings
// sv_location "eu"
sv_region "255"
sv_lan "0"
sv_max_queries_sec_global "30000"
sv_max_queries_window "45"
sv_max_queries_sec "5"
// Server Limits
sbox_maxprops 100
sbox_maxragdolls 5
sbox_maxnpcs 10
sbox_maxballoons 10
sbox_maxeffects 10
sbox_maxdynamite 10
sbox_maxlamps 10
sbox_maxthrusters 10
sbox_maxwheels 10
sbox_maxhoverballs 10
sbox_maxvehicles 20
sbox_maxbuttons 10
sbox_maxsents 20
sbox_maxemitters 5
sbox_godmode 0
sbox_noclip 0
// Network Settings - Please keep these set to default.
sv_minrate 75000
sv_maxrate 0
gmod_physiterations 2
net_splitpacket_maxrate 45000
decalfrequency 12
// Execute Ban Files - Please do not edit
exec banned_ip.cfg
exec banned_user.cfg
// Add custom lines under here
' > server.cfg
container: 'ghcr.io/pelican-eggs/installers:debian'
entrypoint: bash
variables:
-
name: Gamemode
description: 'The gamemode of your server.'
env_variable: GAMEMODE
default_value: sandbox
user_viewable: true
user_editable: true
rules:
- required
- string
sort: 5
-
name: 'Lua Refresh'
description: "0 = disable Lua refresh,\r\n1 = enable Lua refresh"
env_variable: LUA_REFRESH
default_value: 0
user_viewable: true
user_editable: true
rules:
- boolean
sort: 8
-
name: Map
description: 'The default map for the server.'
env_variable: SRCDS_MAP
default_value: gm_flatgrass
user_viewable: true
user_editable: true
rules:
- required
- string
- alpha_dash
sort: 1
-
name: 'Max Players'
description: 'The maximum amount of players allowed on your game server.'
env_variable: MAX_PLAYERS
default_value: 32
user_viewable: true
user_editable: true
rules:
- required
- integer
- 'max:128'
sort: 6
-
name: 'Source AppID'
description: 'Required for game to update on server restart. Do not modify this.'
env_variable: SRCDS_APPID
default_value: 4020
user_viewable: false
user_editable: false
rules:
- required
- string
- 'max:20'
sort: 3
-
name: 'Steam Account Token'
description: 'The Steam Account Token required for the server to be displayed publicly.'
env_variable: STEAM_ACC
default_value: ''
user_viewable: true
user_editable: true
rules:
- nullable
- string
- alpha_num
- 'size:32'
sort: 2
-
name: Tickrate
description: "The tickrate defines how fast the server will update each entity's location."
env_variable: TICKRATE
default_value: 22
user_viewable: true
user_editable: true
rules:
- required
- integer
- 'max:100'
sort: 7
-
name: 'Workshop ID'
description: 'The ID of your workshop collection (the numbers at the end of the URL)'
env_variable: WORKSHOP_ID
default_value: ''
user_viewable: true
user_editable: true
rules:
- nullable
- integer
sort: 4

File diff suppressed because one or more lines are too long

View File

@@ -1,124 +0,0 @@
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
meta:
version: PLCN_v3
update_url: 'https://github.com/pelican-dev/panel/raw/main/database/Seeders/eggs/source-engine/egg-team-fortress2.yaml'
exported_at: '2025-10-31T12:31:09+00:00'
name: 'Team Fortress 2'
author: panel@example.com
uuid: 7f8eb681-b2c8-4bf8-b9f4-d79ff70b6e5d
description: |-
Team Fortress 2 is a team-based first-person shooter multiplayer video game developed and published
by Valve Corporation. It is the sequel to the 1996 mod Team Fortress for Quake and its 1999 remake.
image: 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNi4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iNTAwcHgiIGhlaWdodD0iNTAwLjAwOXB4IiB2aWV3Qm94PSItNTAgLTUwLjAwNSA1MDAgNTAwLjAwOSIgZW5hYmxlLWJhY2tncm91bmQ9Im5ldyAtNTAgLTUwLjAwNSA1MDAgNTAwLjAwOSINCgkgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+DQo8bGluZWFyR3JhZGllbnQgaWQ9IlNWR0lEXzFfIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjI1NS4zOTk0IiB5MT0iLTQzMy44NDM4IiB4Mj0iMTQ0LjU5ODkiIHkyPSI1My44NDUxIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEgMCAwIC0xIDAgMTApIj4NCgk8c3RvcCAgb2Zmc2V0PSIwIiBzdHlsZT0ic3RvcC1jb2xvcjojNUQxRjBFIi8+DQoJPHN0b3AgIG9mZnNldD0iMSIgc3R5bGU9InN0b3AtY29sb3I6I0QwOTczNyIvPg0KPC9saW5lYXJHcmFkaWVudD4NCjxwYXRoIGZpbGw9InVybCgjU1ZHSURfMV8pIiBkPSJNMjYyLjA0NC00Mi4yMzlDMzcwLjA5Ny0xNC42NDQsNDUwLDgzLjM0NCw0NTAsMTk5Ljk5OGMwLDIuNjYxLTAuMDU3LDUuMzExLTAuMTQzLDcuOTUxDQoJbC0xNjguMTUxLTIzLjkxNmMtNC45MjItMjUuMzM4LTIxLjMzMy00Ni41NTYtNDMuNTk0LTU4LjA0NUwyNjIuMDQ0LTQyLjIzOXogTTEyNS45OSwxNjEuODgNCgljMTEuNDg4LTIyLjI2MSwzMi43MDctMzguNjcsNTguMDQzLTQzLjU5M2wyMy45Mi0xNjguMTUzYy0yLjY0My0wLjA4My01LjI5LTAuMTM5LTcuOTUzLTAuMTM5DQoJYy0xMTYuNjUyLDAtMjE0LjYzOSw3OS44OTgtMjQyLjIzNSwxODcuOTUzTDEyNS45OSwxNjEuODh6IE0xNjEuODgzLDI3NC4wMDhjLTIyLjI1OS0xMS40ODktMzguNjctMzIuNzEtNDMuNTkyLTU4LjA0NQ0KCWwtMTY4LjE1Mi0yMy45MmMtMC4wODMsMi42NDMtMC4xMzksNS4yOTMtMC4xMzksNy45NTVjMCwxMTYuNjQ4LDc5Ljg5OCwyMTQuNjM3LDE4Ny45NTIsMjQyLjIzM0wxNjEuODgzLDI3NC4wMDh6IE0yNzQuMDEsMjM4LjExMw0KCWMtMTEuNDksMjIuMjYxLTMyLjcwNywzOC42NjktNTguMDQ2LDQzLjU5M2wtMjMuOTE5LDE2OC4xNThjMi42NDMsMC4wODMsNS4yOTIsMC4xNCw3Ljk1NCwwLjE0DQoJYzExNi42NTMsMCwyMTQuNjQtNzkuOTAxLDI0Mi4yMzItMTg3Ljk1NUwyNzQuMDEsMjM4LjExM3oiLz4NCjxyYWRpYWxHcmFkaWVudCBpZD0iU1ZHSURfMl8iIGN4PSI5OC4xOTE5IiBjeT0iLTE5OC4zNTYiIHI9IjQyNS45ODcxIiBmeD0iOTIuNzU2NSIgZnk9Ii0xOTkuNDgzNCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgwLjk3NzQgMC4yMTE0IDAuMTI2NiAtMC41ODUxIC03Ny4wMzAxIC0xNjcuMzcwNykiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj4NCgk8c3RvcCAgb2Zmc2V0PSIwIiBzdHlsZT0ic3RvcC1jb2xvcjojRkZGRkZGO3N0b3Atb3BhY2l0eTowIi8+DQoJPHN0b3AgIG9mZnNldD0iMC40NjU3IiBzdHlsZT0ic3RvcC1jb2xvcjojRkZGRkZGO3N0b3Atb3BhY2l0eTowLjA4MDQiLz4NCgk8c3RvcCAgb2Zmc2V0PSIwLjk4NjEiIHN0eWxlPSJzdG9wLWNvbG9yOiNGRkZGRkY7c3RvcC1vcGFjaXR5OjAuMjE0MyIvPg0KCTxzdG9wICBvZmZzZXQ9IjEiIHN0eWxlPSJzdG9wLWNvbG9yOiNGRkZGRkY7c3RvcC1vcGFjaXR5OjAiLz4NCjwvcmFkaWFsR3JhZGllbnQ+DQo8cGF0aCBmaWxsPSJ1cmwoI1NWR0lEXzJfKSIgZD0iTTI2Mi4wNDQtNDIuMjM5QzM3MC4wOTctMTQuNjQ0LDQ1MCw4My4zNDQsNDUwLDE5OS45OThjMCwyLjY2MS0wLjA1Nyw1LjMxMS0wLjE0Myw3Ljk1MQ0KCWwtMTY4LjE1MS0yMy45MTZjLTQuOTIyLTI1LjMzOC0yMS4zMzMtNDYuNTU2LTQzLjU5NC01OC4wNDVMMjYyLjA0NC00Mi4yMzl6IE0xMjUuOTksMTYxLjg4DQoJYzExLjQ4OC0yMi4yNjEsMzIuNzA3LTM4LjY3LDU4LjA0My00My41OTNsMjMuOTItMTY4LjE1M2MtMi42NDMtMC4wODMtNS4yOS0wLjEzOS03Ljk1My0wLjEzOQ0KCWMtMTE2LjY1MiwwLTIxNC42MzksNzkuODk4LTI0Mi4yMzUsMTg3Ljk1M0wxMjUuOTksMTYxLjg4eiBNMTYxLjg4MywyNzQuMDA4Yy0yMi4yNTktMTEuNDg5LTM4LjY3LTMyLjcxLTQzLjU5Mi01OC4wNDUNCglsLTE2OC4xNTItMjMuOTJjLTAuMDgzLDIuNjQzLTAuMTM5LDUuMjkzLTAuMTM5LDcuOTU1YzAsMTE2LjY0OCw3OS44OTgsMjE0LjYzNywxODcuOTUyLDI0Mi4yMzNMMTYxLjg4MywyNzQuMDA4eiBNMjc0LjAxLDIzOC4xMTMNCgljLTExLjQ5LDIyLjI2MS0zMi43MDcsMzguNjY5LTU4LjA0Niw0My41OTNsLTIzLjkxOSwxNjguMTU4YzIuNjQzLDAuMDgzLDUuMjkyLDAuMTQsNy45NTQsMC4xNA0KCWMxMTYuNjUzLDAsMjE0LjY0LTc5LjkwMSwyNDIuMjMyLTE4Ny45NTVMMjc0LjAxLDIzOC4xMTN6Ii8+DQo8L3N2Zz4NCg=='
tags:
- source
- steamcmd
features:
- gsl_token
- steam_disk_space
docker_images:
Source: 'ghcr.io/pelican-eggs/games:source'
file_denylist: { }
startup_commands:
Default: './srcds_run -game tf -console -port {{SERVER_PORT}} +map {{SRCDS_MAP}} +ip 0.0.0.0 -strictportbind -norestart +sv_setsteamaccount {{STEAM_ACC}}'
config:
files: { }
startup:
done: 'gameserver Steam ID'
logs: { }
stop: quit
scripts:
installation:
script: |-
#!/bin/bash
# steamcmd Base Installation Script
#
# Server Files: /mnt/server
# Image to install with is 'debian:buster-slim'
##
#
# Variables
# STEAM_USER, STEAM_PASS, STEAM_AUTH - Steam user setup. If a user has 2fa enabled it will most likely fail due to timeout. Leave blank for anon install.
# WINDOWS_INSTALL - if it's a windows server you want to install set to 1
# SRCDS_APPID - steam app id ffound here - https://developer.valvesoftware.com/wiki/Dedicated_Servers_List
# EXTRA_FLAGS - when a server has extra glas for things like beta installs or updates.
#
##
## just in case someone removed the defaults.
if [ "${STEAM_USER}" == "" ]; then
echo -e "steam user is not set.
"
echo -e "Using anonymous user.
"
STEAM_USER=anonymous
STEAM_PASS=""
STEAM_AUTH=""
else
echo -e "user set to ${STEAM_USER}"
fi
## download and install steamcmd
cd /tmp
mkdir -p /mnt/server/steamcmd
curl -sSL -o steamcmd.tar.gz https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz
tar -xzvf steamcmd.tar.gz -C /mnt/server/steamcmd
mkdir -p /mnt/server/steamapps # Fix steamcmd disk write error when this folder is missing
cd /mnt/server/steamcmd
# SteamCMD fails otherwise for some reason, even running as root.
# This is changed at the end of the install process anyways.
chown -R root:root /mnt
export HOME=/mnt/server
## install game using steamcmd
./steamcmd.sh +force_install_dir /mnt/server +login ${STEAM_USER} ${STEAM_PASS} ${STEAM_AUTH} $( [[ "${WINDOWS_INSTALL}" == "1" ]] && printf %s '+@sSteamCmdForcePlatformType windows' ) +app_update ${SRCDS_APPID} ${EXTRA_FLAGS} validate +quit ## other flags may be needed depending on install. looking at you cs 1.6
## set up 32 bit libraries
mkdir -p /mnt/server/.steam/sdk32
cp -v linux32/steamclient.so ../.steam/sdk32/steamclient.so
## set up 64 bit libraries
mkdir -p /mnt/server/.steam/sdk64
cp -v linux64/steamclient.so ../.steam/sdk64/steamclient.so
container: 'ghcr.io/pelican-eggs/installers:debian'
entrypoint: bash
variables:
-
name: 'Default Map'
description: 'The default map to use when starting the server.'
env_variable: SRCDS_MAP
default_value: cp_dustbowl
user_viewable: true
user_editable: true
rules:
- required
- 'regex:/^(\w{1,20})$/'
sort: 2
-
name: 'Game ID'
description: 'The ID corresponding to the game to download and run using SRCDS.'
env_variable: SRCDS_APPID
default_value: 232250
user_viewable: true
user_editable: false
rules:
- required
- 'in:232250'
sort: 1
-
name: Steam
description: |-
The Steam Game Server Login Token to display servers publicly. Generate one at
https://steamcommunity.com/dev/managegameservers
env_variable: STEAM_ACC
default_value: ''
user_viewable: true
user_editable: true
rules:
- required
- string
- alpha_num
- 'size:32'
sort: 3

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('allocations')
->whereNull('server_id')
->update(['notes' => null]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Not needed
}
};

View File

@@ -2,8 +2,8 @@
# check for .env file or symlink and generate app keys if missing
if [ -f /var/www/html/.env ]; then
echo "external vars exist."
# load env vars from .env
export $(grep -v '^#' .env | xargs)
# load specific env vars from .env used in the entrypoint and they are not already set
for VAR in "APP_KEY" "APP_INSTALLED" "DB_CONNECTION" "DB_HOST" "DB_PORT"; do if ! (printenv | grep -q ${VAR}); then export $(grep ${VAR} .env | grep -ve "^#"); fi; done
else
echo "external vars don't exist."
# webroot .env is symlinked to this path
@@ -25,7 +25,7 @@ else
fi
# create directories for volumes
mkdir -p /pelican-data/database /pelican-data/storage/avatars /pelican-data/storage/fonts /var/www/html/storage/logs/supervisord 2>/dev/null
mkdir -p /pelican-data/database /pelican-data/storage/avatars /pelican-data/storage/fonts /pelican-data/storage/icons /var/www/html/storage/logs/supervisord 2>/dev/null
# if the app is installed then we need to run migrations on start. New installs will run migrations when you run the installer.
if [ "${APP_INSTALLED}" == "true" ]; then

View File

@@ -41,7 +41,7 @@ stdout_logfile_maxbytes=0
redirect_stderr=true
[program:supercronic]
command=supercronic -overlapping /etc/supercronic/crontab
command=supercronic -overlapping /etc/crontabs/crontab
autostart=true
autorestart=true
redirect_stderr=true

View File

@@ -1,94 +0,0 @@
<?php
return [
'nav_title' => 'البيوض',
'model_label' => 'البيضة',
'model_label_plural' => 'البيوض',
'tabs' => [
'configuration' => 'الإعدادات',
'process_management' => 'إدارة العمليات',
'egg_variables' => 'متغيرات البيضة',
'install_script' => 'برنامج التثبيت',
],
'import' => [
'file' => 'ملف',
'url' => 'رابط',
'egg_help' => 'يجب أن يكون هذا الملف الخام بصيغة .json (مثل egg-minecraft.json)',
'url_help' => 'يجب أن تشير الروابط مباشرة إلى ملف .json الخام',
'add_url' => 'رابط جديد',
'import_failed' => 'فشل الاستيراد',
'import_success' => 'تم الاستيراد بنجاح',
'github' => 'أضف من جيت هاب',
'refresh' => 'تحديث',
],
'export' => [
'modal' => 'كيف ترغب في تصدير :egg ؟',
'as' => 'ك.:format',
],
'in_use' => 'قيد الاستخدام',
'servers' => 'الخوادم',
'name' => 'الاسم',
'egg_uuid' => 'UUID البيضة',
'egg_id' => 'معرف البيضة',
'name_help' => 'اسم بسيط وسهل القراءة يُستخدم كمعرف لهذه البيضة.',
'author' => 'المؤلف',
'uuid_help' => 'المعرف الفريد عالميًا لهذه البيضة الذي يستخدمه Wings كمعرف.',
'author_help' => 'مؤلف هذا الإصدار من البيضة.',
'author_help_edit' => 'مؤلف هذا الإصدار من البيضة. تحميل إعدادات جديدة من مؤلف مختلف سيؤدي إلى تغييره.',
'description' => 'الوصف',
'description_help' => 'وصف لهذه البيضة سيظهر عبر اللوحة عند الحاجة.',
'startup' => 'أمر بدء التشغيل',
'startup_help' => 'أمر بدء التشغيل الافتراضي الذي سيتم استخدامه للخوادم الجديدة التي تستخدم هذه البيضة.',
'file_denylist' => 'قائمة حظر الملفات',
'file_denylist_help' => 'قائمة بالملفات التي لا يُسمح للمستخدم النهائي بتحريرها.',
'features' => 'الميزات',
'force_ip' => 'فرض عنوان IP الصادر',
'force_ip_help' => 'يجبر كل حركة المرور الصادرة على أن يكون عنوان المصدر هو عنوان التخصيص الأساسي للخادم. مطلوب لبعض الألعاب للعمل بشكل صحيح عندما تحتوي العقدة على عناوين IP عامة متعددة. عند تفعيل هذا الخيار، سيتم تعطيل الشبكات الداخلية لأي خوادم تستخدم هذه البيضة، مما يؤدي إلى عدم قدرتها على الوصول داخليًا إلى الخوادم الأخرى على نفس العقدة.',
'tags' => 'الوسوم',
'update_url' => 'رابط التحديث',
'update_url_help' => 'يجب أن تشير الروابط مباشرة إلى ملف .json الخام',
'add_image' => 'إضافة صورة Docker',
'docker_images' => 'صور Docker',
'docker_name' => 'اسم الصورة',
'docker_uri' => 'رابط الصورة',
'docker_help' => 'صور Docker المتاحة للخوادم التي تستخدم هذه البيضة.',
'stop_command' => 'أمر الإيقاف',
'stop_command_help' => 'الأمر الذي يجب إرساله إلى عمليات الخادم لإيقافها بشكل آمن. إذا كنت بحاجة إلى إرسال SIGINT، أدخل ^C هنا.',
'copy_from' => 'نسخ الإعدادات من',
'copy_from_help' => 'إذا كنت ترغب في استخدام الإعدادات الافتراضية لبيضة أخرى، حددها من القائمة أعلاه.',
'none' => 'لا شيء',
'start_config' => 'إعدادات بدء التشغيل',
'start_config_help' => 'قائمة القيم التي يجب أن يبحث عنها Daemon عند تشغيل الخادم لتحديد اكتمال التشغيل.',
'config_files' => 'ملفات الإعدادات',
'config_files_help' => 'يجب أن يكون هذا تمثيل JSON لملفات الإعدادات التي سيتم تعديلها وأي أجزاء يجب تغييرها.',
'log_config' => 'إعدادات السجلات',
'log_config_help' => 'يجب أن يكون هذا تمثيل JSON لمواقع ملفات السجلات وما إذا كان يجب على Daemon إنشاء سجلات مخصصة أم لا.',
'environment_variable' => 'المتغير البيئي',
'default_value' => 'القيمة الافتراضية',
'user_permissions' => 'أذونات المستخدم',
'viewable' => 'مرئي',
'editable' => 'قابل للتعديل',
'rules' => 'القواعد',
'add_new_variable' => 'إضافة متغير جديد',
'error_unique' => 'يوجد متغير بهذا الاسم بالفعل.',
'error_required' => 'حقل المتغير البيئي مطلوب.',
'error_reserved' => 'هذا المتغير البيئي محجوز ولا يمكن استخدامه.',
'script_from' => 'المصدر البرمجي',
'script_container' => 'حاوية السكريبت',
'script_entry' => 'مدخل السكريبت',
'script_install' => 'برنامج التثبيت',
'no_eggs' => 'لا توجد بيوض',
'no_servers' => 'لا توجد خوادم',
'no_servers_help' => 'لم يتم تعيين أي خوادم لهذه البيضة.',
'update' => 'تحديث|تحديث المحدد',
'updated' => 'تم تحديث البيضة|تم تحديث :count/: بيضة',
'updated_failed' => ':count فشلت',
'update_question' => 'هل أنت متأكد من أنك تريد تحديث هذه البيضة؟|هل أنت متأكد من أنك تريد تحديث البيض المحدد؟',
'update_description' => 'إذا قمت بأي تغييرات على البيضة فسيتم استبدالها!|إذا قمت بأي تغييرات على البيوض فسيتم استبدالها!',
'no_updates' => 'لا توجد تحديثات متوفرة للبيوض المحددة',
];

View File

@@ -1,15 +0,0 @@
<?php
return [
'model_label' => 'الجدول الزمني',
'model_label_plural' => 'الجدول الزمني',
'import' => [
'file' => 'ملف',
'url' => 'عنوان URL',
'schedule_help' => 'يجب أن يكون هذا الملف الخام بصيغة .json (مثل schedule-daily-restart.json)',
'url_help' => 'يجب أن تشير الروابط مباشرة إلى ملف .json الخام',
'add_url' => 'عنوان URL جديد',
'import_failed' => 'فشل الاستيراد',
'import_success' => 'نجح الاستيراد',
],
];

View File

@@ -1,153 +0,0 @@
<?php
return [
'title' => 'الإعدادات',
'save_success' => 'تم حفظ الإعدادات',
'save_failed' => 'فشل في حفظ الإعدادات',
'navigation' => [
'general' => 'عام',
'captcha' => 'كابتشا',
'mail' => 'البريد',
'backup' => 'النسخ الاحتياطي',
'oauth' => 'OAuth',
'misc' => 'متفرقات',
],
'general' => [
'app_name' => 'اسم التطبيق',
'app_logo' => 'شعار التطبيق',
'app_logo_help' => 'يجب وضع الشعار في المجلد العام الموجود في المجلد الجذري للوحة التحكم، اتركه فارغاً لاستخدام اسم التطبيق بدلاً من ذلك.',
'app_favicon' => 'أيقونة التطبيق',
'app_favicon_help' => 'يجب وضع الفافيكون في المجلد العام الموجود في المجلد الجذري للوحة التحكم.',
'debug_mode' => 'وضع التصحيح',
'navigation' => 'التنقل',
'sidebar' => 'الشريط الجانبي',
'topbar' => 'الشريط العلوي',
'unit_prefix' => 'بادئة الوحدة',
'decimal_prefix' => 'البادئة العشرية (MB/GB)',
'binary_prefix' => 'البادئة الثنائية (MiB/GiB)',
'2fa_requirement' => 'متطلب المصادقة الثنائية',
'not_required' => 'غير مطلوب',
'admins_only' => 'مطلوب للمسؤولين فقط',
'all_users' => 'مطلوب لجميع المستخدمين',
'trusted_proxies' => 'الوكلاء الموثوق بهم',
'trusted_proxies_help' => 'عنوان IP جديد أو نطاق IP',
'clear' => 'مسح',
'set_to_cf' => 'تعيين إلى عناوين IP الخاصة بـ Cloudflare',
'display_width' => 'عرض الشاشة',
'avatar_provider' => 'مزود الصورة الرمزية',
'uploadable_avatars' => 'السماح للمستخدمين برفع صورهم الخاصة؟',
],
'captcha' => [
'enable' => 'تفعيل',
'disable' => 'تعطيل',
'info_label' => 'معلومات',
'info' => 'يمكنك إنشاء المفاتيح في <u><a href="https://developers.cloudflare.com/turnstile/get-started/#get-a-sitekey-and-secret-key" target="_blank">لوحة تحكم Cloudflare</a></u>، مما يتطلب حساب Cloudflare.',
'site_key' => 'مفتاح الموقع',
'secret_key' => 'المفتاح السري',
'verify' => 'التحقق من النطاق؟',
],
'mail' => [
'mail_driver' => 'مشغل البريد',
'test_mail' => 'إرسال بريد تجريبي',
'test_mail_sent' => 'تم إرسال البريد التجريبي',
'test_mail_failed' => 'فشل إرسال البريد التجريبي',
'from_settings' => 'إعدادات المرسل',
'from_settings_help' => 'حدد العنوان والاسم المستخدمين كمرسل في رسائل البريد.',
'from_address' => 'عنوان المرسل',
'from_name' => 'اسم المرسل',
'smtp' => [
'smtp_title' => 'إعدادات SMTP',
'host' => 'المضيف',
'port' => 'المنفذ',
'username' => 'اسم المستخدم',
'password' => 'كلمة المرور',
'scheme' => 'المخطط',
],
'mailgun' => [
'mailgun_title' => 'إعدادات Mailgun',
'domain' => 'النطاق',
'secret' => 'المفتاح السري',
'endpoint' => 'نقطة النهاية',
],
],
'backup' => [
'backup_driver' => 'مشغل النسخ الاحتياطي',
'throttle' => 'التقييد',
'throttle_help' => 'حدد عدد النسخ الاحتياطية التي يمكن إنشاؤها خلال فترة زمنية. قم بتعيين الفترة إلى 0 لتعطيل هذا التقييد.',
'limit' => 'الحد',
'period' => 'الفترة',
'seconds' => 'ثواني',
's3' => [
's3_title' => 'إعدادات S3',
'default_region' => 'المنطقة الافتراضية',
'access_key' => 'معرف مفتاح الوصول',
'secret_key' => 'المفتاح السري للوصول',
'bucket' => 'السعة التخزينية',
'endpoint' => 'نقطة النهاية',
'use_path_style_endpoint' => 'استخدام نقطة نهاية بأسلوب المسار',
],
],
'oauth' => [
'enable' => 'تمكين',
'enable_schema' => 'تفعيل :schema',
'disable' => 'تعطيل',
'client_id' => 'معرف العميل',
'client_secret' => 'المفتاح السري للعميل',
'redirect' => 'عنوان URL لإعادة التوجيه',
'web_api_key' => 'مفتاح API للويب',
'base_url' => 'عنوان URL الأساسي',
'display_name' => 'اسم العرض',
'auth_url' => 'عنوان URL لاستدعاء المصادقة',
'create_missing_users' => 'إنشاء تلقائي للمستخدمين المفقودين؟',
'link_missing_users' => 'ربط تلقائي للمستخدمين المفقودين؟',
],
'misc' => [
'auto_allocation' => [
'title' => 'إنشاء التخصيص التلقائي',
'helper' => 'تمكين أو تعطيل قدرة المستخدمين على إنشاء التخصيصات من خلال واجهة العميل.',
'question' => 'السماح للمستخدمين بإنشاء التخصيصات؟',
'start' => 'منفذ البداية',
'end' => 'منفذ النهاية',
],
'mail_notifications' => [
'title' => 'إشعارات البريد',
'helper' => 'تحديد الإشعارات البريدية التي يجب إرسالها إلى المستخدمين.',
'server_installed' => 'تم تثبيت الخادم',
'server_reinstalled' => 'تمت إعادة تثبيت الخادم',
],
'connections' => [
'title' => 'الاتصالات',
'helper' => 'المهلات الزمنية المستخدمة عند إجراء الطلبات.',
'request_timeout' => 'مهلة الطلب',
'connection_timeout' => 'مهلة الاتصال',
'seconds' => 'ثواني',
],
'activity_log' => [
'title' => 'سجلات الأنشطة',
'helper' => 'تحديد مدة الاحتفاظ بسجلات الأنشطة القديمة وما إذا كان يجب تسجيل أنشطة المسؤول.',
'prune_age' => 'مدة الاحتفاظ',
'days' => 'أيام',
'log_admin' => 'إخفاء أنشطة المسؤول؟',
],
'api' => [
'title' => 'واجهة API',
'helper' => 'تحديد الحد الأقصى لعدد الطلبات المسموح بها في الدقيقة.',
'client_rate' => 'حد معدل API للعميل',
'app_rate' => 'حد معدل API للتطبيق',
'rpm' => 'طلبات في الدقيقة',
],
'server' => [
'title' => 'الخوادم',
'helper' => 'إعدادات الخوادم',
'edit_server_desc' => 'السماح للمستخدمين بتعديل الأوصاف؟',
'console_font_upload' => 'رفع خط وحدة التحكم',
'console_font_hint' => 'يتم دعم خطوط *.ttf فقط. يُنصح بشدة باستخدام خطوط Mono!',
],
'webhook' => [
'title' => 'Webhook',
'helper' => 'تحديد مدة الاحتفاظ بسجلات Webhook القديمة.',
'prune_age' => 'مدة الاحتفاظ',
'days' => 'أيام',
],
],
];

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