Compare commits

...

66 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
Boy132
242a75bf3d Plugin system (#1866) 2025-12-20 00:32:13 +01:00
Charles
2ab4c81e2a Replace CodeEditor with MonacoEditor (#2013)
Co-authored-by: Boy132 <mail@boy132.de>
2025-12-19 18:31:55 -05:00
Boy132
5a47948a93 Use recipient language for database notifications (#2008) 2025-12-17 20:34:12 +01:00
Boy132
9d1e7f510f Add toggle for externally managed users (#1825) 2025-12-17 14:09:17 -05:00
hallo123wert
be55e75109 Fix: egg images are not loading (#2009) 2025-12-17 10:47:18 +01:00
Charles
8b5f33ee71 Change images from being stored in base64 to files (#1993)
Co-authored-by: Boy132 <mail@boy132.de>
2025-12-16 11:52:58 -05:00
DaNussi
014e866d0e Egg API Import/Delete (#1947)
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
Co-authored-by: Boy132 <mail@boy132.de>
2025-12-16 06:28:12 -05:00
gOOvER
4a1ecb1adc changed docker panel restart to unless-stopped (#1995) 2025-12-15 12:11:21 -05:00
Michael (Parker) Parker
e2529ab436 Fix migrations in docker container (#1999) 2025-12-14 15:02:06 -05:00
Charles M
cd3f3a97ac Fix Docker build command in comments (#2003) 2025-12-14 14:22:36 -05:00
Charles
2f5790b121 Fix Egg Importer Upload File Type Filter (#2000) 2025-12-13 22:46:03 -05:00
Charles
59f0fe1959 Fix console duplicating with spa (#1990) 2025-12-13 21:49:58 -05:00
Charles
fdd9faaaa3 Fix schedule actions (#1992) 2025-12-12 18:31:46 -05:00
Boy132
9449d78144 Don't convert Windows-1252 encoding (#1991) 2025-12-13 00:15:45 +01:00
Charles
a391d21043 Fix progress bar max value in table view (#1989) 2025-12-12 16:55:09 -05:00
Quinten
b13fcfd644 Update paper egg to use their new domain (#1986)
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-12-12 16:16:30 -05:00
Boy132
760aaf9bfb Refactor subuser permissions (#1961)
Co-authored-by: RMartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2025-12-11 14:34:27 +01:00
MartinOscar
1ab4ddb07c Fix File global search path & rename to nested search (#1985) 2025-12-11 13:48:34 +01:00
MartinOscar
f278041bc0 EditServer select_startup refactor (#1983) 2025-12-11 13:48:29 +01:00
Boy132
cdc928a15b Consolidate policies and use Subuser model for subuser resource (#1978) 2025-12-11 13:16:57 +01:00
MartinOscar
3939c409c1 Followup Stock Eggs #1973 (#1982) 2025-12-10 20:41:56 +01:00
MartinOscar
091ca5447a Fix CreateWebhookConfiguration HeaderActions (#1979) 2025-12-10 20:39:57 +01:00
JoanFo
57c4172c74 Fix settings Translation typo (#1981) 2025-12-10 19:56:17 +01:00
1012 changed files with 23486 additions and 12917 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

@@ -2,7 +2,7 @@
# Pelican Production Dockerfile
##
# If you want to build this locally you want to run `docker build -f Dockerfile.dev`
# If you want to build this locally you want to run `docker build -f Dockerfile.dev .`
##
# ================================
@@ -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,37 +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 /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
# 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,35 +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 /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
# 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

@@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Services\Helpers\PluginService;
use Exception;
use Illuminate\Console\Command;
class ComposerPluginsCommand extends Command
{
protected $signature = 'p:plugin:composer';
protected $description = 'Makes sure the needed composer packages for all installed plugins are available.';
public function handle(PluginService $pluginService): void
{
try {
$pluginService->manageComposerPackages();
} catch (Exception $exception) {
report($exception);
$this->error($exception->getMessage());
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use Illuminate\Console\Command;
class DisablePluginCommand extends Command
{
protected $signature = 'p:plugin:disable {id?}';
protected $description = 'Disables a plugin';
public function handle(PluginService $pluginService): void
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
if (!$plugin) {
$this->error('Plugin does not exist!');
return;
}
if (!$plugin->canDisable()) {
$this->error("Plugin can't be disabled!");
return;
}
$pluginService->disablePlugin($plugin);
$this->info('Plugin disabled.');
}
}

View File

@@ -0,0 +1,43 @@
<?php
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
{
protected $signature = 'p:plugin:install {id?}';
protected $description = 'Installs a plugin';
public function handle(PluginService $pluginService): void
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
if (!$plugin) {
$this->error('Plugin does not exist!');
return;
}
if ($plugin->status !== PluginStatus::NotInstalled) {
$this->error('Plugin is already installed!');
return;
}
try {
$pluginService->installPlugin($plugin);
$this->info('Plugin installed and enabled.');
} catch (Exception $exception) {
$this->error('Could not install plugin: ' . $exception->getMessage());
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Models\Plugin;
use Illuminate\Console\Command;
class ListPluginsCommand extends Command
{
protected $signature = 'p:plugin:list';
protected $description = 'List all installed plugins';
public function handle(): void
{
$plugins = Plugin::query()->get(['name', 'author', 'status', 'version', 'panels', 'category']);
if (count($plugins) < 1) {
$this->warn('No plugins installed');
return;
}
$this->table(['Name', 'Author', 'Status', 'Version', 'Panels', 'Category'], $plugins->toArray());
$this->output->newLine();
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Enums\PluginCategory;
use App\Enums\PluginStatus;
use Illuminate\Console\Command;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
class MakePluginCommand extends Command
{
protected $signature = 'p:plugin:make
{--name=}
{--author=}
{--description=}
{--category=}
{--url=}
{--updateUrl=}
{--panels=}
{--panelVersion=}';
protected $description = 'Create a new plugin';
public function __construct(private Filesystem $filesystem)
{
parent::__construct();
}
public function handle(): void
{
$name = $this->option('name') ?? $this->ask('Name');
$name = preg_replace('/[^A-Za-z0-9 ]/', '', Str::ascii($name));
$id = Str::slug($name);
if ($this->filesystem->exists(plugin_path($id))) {
$this->error('Plugin with that name already exists!');
return;
}
$author = $this->option('author') ?? $this->ask('Author', cache('plugin.author'));
$author = preg_replace('/[^A-Za-z0-9 ]/', '', Str::ascii($author));
cache()->forever('plugin.author', $author);
$namespace = Str::studly($author) . '\\' . Str::studly($name);
$class = Str::studly($name . 'Plugin');
if (class_exists('\\' . $namespace . '\\' . $class)) {
$this->error('Plugin class with that name already exists!');
return;
}
$this->info('Creating Plugin "' . $name . '" (' . $id . ') by ' . $author);
$description = $this->option('description') ?? $this->ask('Description (can be empty)');
$category = $this->option('category') ?? $this->choice('Category', collect(PluginCategory::cases())->mapWithKeys(fn (PluginCategory $category) => [$category->value => $category->getLabel()])->toArray(), PluginCategory::Plugin->value);
if (!PluginCategory::tryFrom($category)) {
$this->error('Unknown plugin category!');
return;
}
$url = $this->option('url') ?? $this->ask('URL (can be empty)');
$updateUrl = $this->option('updateUrl') ?? $this->ask('Update URL (can be empty)');
$panels = $this->option('panels');
if (!$panels) {
if ($this->confirm('Should the plugin be available on all panels?', true)) {
$panels = null;
} else {
$panels = $this->choice('Panels (comma separated list)', [
'admin' => 'Admin Area',
'server' => 'Client Area',
'app' => 'Server List',
], multiple: true);
}
}
$panels = is_string($panels) ? explode(',', $panels) : $panels;
$panelVersion = $this->option('panelVersion');
if (!$panelVersion) {
$panelVersion = $this->ask('Required panel version (leave empty for no constraint)', config('app.version') === 'canary' ? null : config('app.version'));
if ($panelVersion && $this->confirm("Should the version constraint be minimal instead of strict? ($panelVersion or higher instead of only $panelVersion)")) {
$panelVersion = "^$panelVersion";
}
}
$composerPackages = null;
// TODO: ask for composer packages?
// Create base directory
$this->filesystem->makeDirectory(plugin_path($id));
// Write plugin.json
$this->filesystem->put(plugin_path($id, 'plugin.json'), json_encode([
'id' => $id,
'name' => $name,
'author' => $author,
'version' => '1.0.0',
'description' => $description,
'category' => $category,
'url' => $url,
'update_url' => $updateUrl,
'namespace' => $namespace,
'class' => $class,
'panels' => $panels,
'panel_version' => $panelVersion,
'composer_packages' => $composerPackages,
'meta' => [
'status' => PluginStatus::Enabled,
'status_message' => null,
],
], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// Create src directory and create main class
$this->filesystem->makeDirectory(plugin_path($id, 'src'));
$this->filesystem->put(plugin_path($id, 'src', $class . '.php'), Str::replace(['$namespace$', '$class$', '$id$'], [$namespace, $class, $id], file_get_contents(__DIR__ . '/Plugin.stub')));
// Create Providers directory and create service provider
$this->filesystem->makeDirectory(plugin_path($id, 'src', 'Providers'));
$this->filesystem->put(plugin_path($id, 'src', 'Providers', $class . 'Provider.php'), Str::replace(['$namespace$', '$class$'], [$namespace, $class], file_get_contents(__DIR__ . '/PluginProvider.stub')));
// Create config directory and create config file
$this->filesystem->makeDirectory(plugin_path($id, 'config'));
$this->filesystem->put(plugin_path($id, 'config', $id . '.php'), Str::replace(['$name$'], [$name], file_get_contents(__DIR__ . '/PluginConfig.stub')));
$this->info('Plugin created.');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace $namespace$;
use Filament\Contracts\Plugin;
use Filament\Panel;
class $class$ implements Plugin
{
public function getId(): string
{
return '$id$';
}
public function register(Panel $panel): void
{
// Allows you to use any configuration option that is available to the panel.
// This includes registering resources, custom pages, themes, render hooks and more.
}
public function boot(Panel $panel): void
{
// Is run only when the panel that the plugin is being registered to is actually in-use. It is executed by a middleware class.
}
}

View File

@@ -0,0 +1,5 @@
<?php
return [
// Config values for $name$
];

View File

@@ -0,0 +1,18 @@
<?php
namespace $namespace$\Providers;
use Illuminate\Support\ServiceProvider;
class $class$Provider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
//
}
}

View File

@@ -0,0 +1,48 @@
<?php
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
{
protected $signature = 'p:plugin:uninstall {id?} {--delete : Delete the plugin files}';
protected $description = 'Uninstalls a plugin';
public function handle(PluginService $pluginService): void
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
if (!$plugin) {
$this->error('Plugin does not exist!');
return;
}
if ($plugin->status === PluginStatus::NotInstalled) {
$this->error('Plugin is not installed!');
return;
}
$deleteFiles = $this->option('delete');
if ($this->input->isInteractive() && !$deleteFiles) {
$deleteFiles = $this->confirm('Do you also want to delete the plugin files?');
}
try {
$pluginService->uninstallPlugin($plugin, $deleteFiles);
$this->info('Plugin uninstalled' . ($deleteFiles ? ' and files deleted' : '') . '.');
} catch (Exception $exception) {
$this->error('Could not uninstall plugin: ' . $exception->getMessage());
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use Exception;
use Illuminate\Console\Command;
class UpdatePluginCommand extends Command
{
protected $signature = 'p:plugin:update {id?}';
protected $description = 'Updates a plugin';
public function handle(PluginService $pluginService): void
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
if (!$plugin) {
$this->error('Plugin does not exist!');
return;
}
if (!$plugin->isUpdateAvailable()) {
$this->error("Plugin doesn't need updating!");
return;
}
try {
$pluginService->updatePlugin($plugin);
$this->info('Plugin updated.');
} catch (Exception $exception) {
$this->error('Could not update plugin: ' . $exception->getMessage());
}
}
}

View File

@@ -2,12 +2,13 @@
namespace App\Contracts\Http;
use App\Enums\SubuserPermission;
interface ClientPermissionsRequest
{
/**
* Returns the permissions string indicating which permission should be used to
* validate that the authenticated user has permission to perform this action against
* the given resource (server).
* Returns the permission used to validate that the authenticated user may perform
* this action against the given resource (server).
*/
public function permission(): string;
public function permission(): SubuserPermission|string;
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Contracts\Plugins;
use Filament\Schemas\Components\Component;
interface HasPluginSettings
{
/**
* @return Component[]
*/
public function getSettingsForm(): array;
/**
* @param array<mixed, mixed> $data
*/
public function saveSettings(array $data): void;
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasLabel;
enum EditorLanguages: string implements HasLabel
{
case plaintext = 'plaintext';
case abap = 'abap';
case apex = 'apex';
case azcali = 'azcali';
case bat = 'bat';
case bicep = 'bicep';
case cameligo = 'cameligo';
case clojure = 'clojure';
case coffeescript = 'coffeescript';
case c = 'c';
case cpp = 'cpp';
case csharp = 'csharp';
case csp = 'csp';
case css = 'css';
case cypher = 'cypher';
case dart = 'dart';
case dockerfile = 'dockerfile';
case ecl = 'ecl';
case elixir = 'elixir';
case flow9 = 'flow9';
case fsharp = 'fsharp';
case go = 'go';
case graphql = 'graphql';
case handlebars = 'handlebars';
case hcl = 'hcl';
case html = 'html';
case ini = 'ini';
case java = 'java';
case javascript = 'javascript';
case julia = 'julia';
case json = 'json';
case kotlin = 'kotlin';
case less = 'less';
case lexon = 'lexon';
case lua = 'lua';
case liquid = 'liquid';
case m3 = 'm3';
case markdown = 'markdown';
case mdx = 'mdx';
case mips = 'mips';
case msdax = 'msdax';
case mysql = 'mysql';
case objectivec = 'objective-c';
case pascal = 'pascal';
case pascaligo = 'pascaligo';
case perl = 'perl';
case pgsql = 'pgsql';
case php = 'php';
case pla = 'pla';
case postiats = 'postiats';
case powerquery = 'powerquery';
case powershell = 'powershell';
case proto = 'proto';
case pug = 'pug';
case python = 'python';
case qsharp = 'qsharp';
case r = 'r';
case razor = 'razor';
case redis = 'redis';
case redshift = 'redshift';
case restructuredtext = 'restructuredtext';
case ruby = 'ruby';
case rust = 'rust';
case sb = 'sb';
case scala = 'scala';
case scheme = 'scheme';
case scss = 'scss';
case shell = 'shell';
case sol = 'sol';
case aes = 'aes';
case sparql = 'sparql';
case sql = 'sql';
case st = 'st';
case swift = 'swift';
case systemverilog = 'systemverilog';
case verilog = 'verilog';
case tcl = 'tcl';
case twig = 'twig';
case typescript = 'typescript';
case typespec = 'typespec';
case vb = 'vb';
case wgsl = 'wgsl';
case xml = 'xml';
case yaml = 'yaml';
public static function fromWithAlias(string $match): self
{
return match ($match) {
'h' => self::c,
'cc', 'hpp' => self::cpp,
'cs' => self::csharp,
'class' => self::java,
'htm' => self::html,
'js', 'mjs', 'cjs' => self::javascript,
'kt', 'kts' => self::kotlin,
'md' => self::markdown,
'm' => self::objectivec,
'pl', 'pm' => self::perl,
'php3', 'php4', 'php5', 'phtml' => self::php,
'py', 'pyc', 'pyo', 'pyi' => self::python,
'rdata', 'rds' => self::r,
'rb', 'erb' => self::ruby,
'sc' => self::scala,
'sh', 'zsh' => self::shell,
'ts', 'tsx' => self::typescript,
'yml' => self::yaml,
default => self::tryFrom($match) ?? self::plaintext,
};
}
public function getLabel(): string
{
return $this->name;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum PluginCategory: string implements HasIcon, HasLabel
{
case Plugin = 'plugin';
case Theme = 'theme';
case Language = 'language';
public function getIcon(): string
{
return match ($this) {
self::Plugin => 'tabler-package',
self::Theme => 'tabler-palette',
self::Language => 'tabler-language',
};
}
public function getLabel(): string
{
return trans('admin/plugin.category_enum.' . $this->value);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum PluginStatus: string implements HasColor, HasIcon, HasLabel
{
case NotInstalled = 'not_installed';
case Disabled = 'disabled';
case Enabled = 'enabled';
case Errored = 'errored';
case Incompatible = 'incompatible';
public function getIcon(): string
{
return match ($this) {
self::NotInstalled => 'tabler-heart-off',
self::Disabled => 'tabler-heart-x',
self::Enabled => 'tabler-heart-check',
self::Errored => 'tabler-heart-broken',
self::Incompatible => 'tabler-heart-cancel',
};
}
public function getColor(): string
{
return match ($this) {
self::NotInstalled => 'gray',
self::Disabled => 'warning',
self::Enabled => 'success',
self::Errored => 'danger',
self::Incompatible => 'danger',
};
}
public function getLabel(): string
{
return trans('admin/plugin.status_enum.' . $this->value);
}
}

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,88 @@
<?php
namespace App\Enums;
enum SubuserPermission: string
{
case WebsocketConnect = 'websocket.connect';
case ControlConsole = 'control.console';
case ControlStart = 'control.start';
case ControlStop = 'control.stop';
case ControlRestart = 'control.restart';
case FileRead = 'file.read';
case FileReadContent = 'file.read-content';
case FileCreate = 'file.create';
case FileUpdate = 'file.update';
case FileDelete = 'file.delete';
case FileArchive = 'file.archive';
case FileSftp = 'file.sftp';
case BackupRead = 'backup.read';
case BackupCreate = 'backup.create';
case BackupDelete = 'backup.delete';
case BackupDownload = 'backup.download';
case BackupRestore = 'backup.restore';
case ScheduleRead = 'schedule.read';
case ScheduleCreate = 'schedule.create';
case ScheduleUpdate = 'schedule.update';
case ScheduleDelete = 'schedule.delete';
case UserRead = 'user.read';
case UserCreate = 'user.create';
case UserUpdate = 'user.update';
case UserDelete = 'user.delete';
case DatabaseRead = 'database.read';
case DatabaseCreate = 'database.create';
case DatabaseUpdate = 'database.update';
case DatabaseDelete = 'database.delete';
case DatabaseViewPassword = 'database.view-password';
case AllocationRead = 'allocation.read';
case AllocationCreate = 'allocation.create';
case AllocationUpdate = 'allocation.update';
case AllocationDelete = 'allocation.delete';
case ActivityRead = 'activity.read';
case StartupRead = 'startup.read';
case StartupUpdate = 'startup.update';
case StartupDockerImage = 'startup.docker-image';
case SettingsRename = 'settings.rename';
case SettingsDescription = 'settings.description';
case SettingsReinstall = 'settings.reinstall';
/** @return string[] */
public function split(): array
{
return explode('.', $this->value, 2);
}
public function isHidden(): bool
{
return $this === self::WebsocketConnect;
}
public function getIcon(): ?string
{
[$group, $permission] = $this->split();
return match ($group) {
'control' => 'tabler-terminal-2',
'user' => 'tabler-users',
'file' => 'tabler-files',
'backup' => 'tabler-file-zip',
'allocation' => 'tabler-network',
'startup' => 'tabler-player-play',
'database' => 'tabler-database',
'schedule' => 'tabler-clock',
'settings' => 'tabler-settings',
'activity' => 'tabler-stack',
default => null,
};
}
}

View File

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

View File

@@ -2,9 +2,9 @@
namespace App\Extensions\Features\Schemas;
use App\Enums\SubuserPermission;
use App\Extensions\Features\FeatureSchemaInterface;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Repositories\Daemon\DaemonServerRepository;
@@ -54,7 +54,7 @@ class GSLTokenSchema implements FeatureSchemaInterface
->modalHeading('Invalid GSL token')
->modalDescription('It seems like your Gameserver Login Token (GSL token) is invalid or has expired.')
->modalSubmitActionLabel('Update GSL Token')
->disabledSchema(fn () => !user()?->can(Permission::ACTION_STARTUP_UPDATE, $server))
->disabledSchema(fn () => !user()?->can(SubuserPermission::StartupUpdate, $server))
->schema([
TextEntry::make('info')
->label(new HtmlString(Blade::render('You can either <x-filament::link href="https://steamcommunity.com/dev/managegameservers" target="_blank">generate a new one</x-filament::link> and enter it below or leave the field blank to remove it completely.'))),

View File

@@ -2,9 +2,9 @@
namespace App\Extensions\Features\Schemas;
use App\Enums\SubuserPermission;
use App\Extensions\Features\FeatureSchemaInterface;
use App\Facades\Activity;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use Exception;
@@ -44,7 +44,7 @@ class JavaVersionSchema implements FeatureSchemaInterface
->modalHeading('Unsupported Java Version')
->modalDescription('This server is currently running an unsupported version of Java and cannot be started.')
->modalSubmitActionLabel('Update Docker Image')
->disabledSchema(fn () => !user()?->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->disabledSchema(fn () => !user()?->can(SubuserPermission::StartupDockerImage, $server))
->schema([
TextEntry::make('java')
->label('Please select a supported version from the list below to continue starting the server.'),

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()
@@ -123,7 +119,7 @@ class ListLogs extends BaseListLogs
}
}),
DeleteAction::make()
->icon('tabler-trash')->iconSize(IconSize::Medium)->iconButton(),
->iconSize(IconSize::Medium)->iconButton(),
]);
}
}

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

@@ -22,7 +22,7 @@ class ViewLogs extends BaseViewLog
BackAction::make()
->icon('tabler-arrow-left')->iconSize(IconSize::ExtraLarge)->iconButton(),
DeleteAction::make(withTooltip: true)
->icon('tabler-trash')->iconSize(IconSize::ExtraLarge)->iconButton(),
->iconSize(IconSize::ExtraLarge)->iconButton(),
DownloadAction::make(withTooltip: true)
->icon('tabler-file-download')->iconSize(IconSize::ExtraLarge)->iconButton(),
Action::make('uploadLogs')
@@ -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

@@ -2,16 +2,17 @@
namespace App\Filament\Admin\Resources\Eggs\Pages;
use App\Enums\EditorLanguages;
use App\Filament\Admin\Resources\Eggs\EggResource;
use App\Filament\Components\Forms\Fields\CopyFrom;
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;
use Filament\Forms\Components\CodeEditor;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
@@ -36,6 +37,7 @@ class CreateEgg extends CreateRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
use CanCustomizeTabs;
protected static string $resource = EggResource::class;
@@ -56,226 +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(),
CodeEditor::make('script_install')
->label(trans('admin/egg.script_install'))
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

@@ -2,20 +2,22 @@
namespace App\Filament\Admin\Resources\Eggs\Pages;
use App\Enums\EditorLanguages;
use App\Filament\Admin\Resources\Eggs\EggResource;
use App\Filament\Components\Actions\ExportEggAction;
use App\Filament\Components\Actions\ImportEggAction;
use App\Filament\Components\Forms\Fields\CopyFrom;
use App\Filament\Components\Forms\Fields\MonacoEditor;
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;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\CodeEditor;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
@@ -38,411 +40,406 @@ use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Support\Enums\IconSize;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\Unique;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class EditEgg extends EditRecord
{
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('base64Image'),
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);
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'));
}
$allowedExtensions = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
];
$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, $allowedExtensions)) {
throw new \Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', $allowedExtensions)]));
}
$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);
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$imageContent = @file_get_contents($state, false, $context, 0, 1048576); // 1024KB
if (!$imageContent) {
throw new \Exception(trans('admin/egg.import.image_error'));
}
if (strlen($imageContent) >= 1048576) {
throw new \Exception(trans('admin/egg.import.image_too_large'));
}
$mimeType = $allowedExtensions[$extension];
$base64 = 'data:' . $mimeType . ';base64,' . base64_encode($imageContent);
$set('base64Image', $base64);
$set('image_url_error', null);
} catch (\Exception $e) {
$set('image_url_error', $e->getMessage());
$set('base64Image', 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(1024)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->saveUploadedFileUsing(function ($file, Set $set) {
$base64 = "data:{$file->getMimeType()};base64,". base64_encode(file_get_contents($file->getRealPath()));
$set('base64Image', $base64);
return $base64;
}),
]),
} 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 {
$base64 = $data['base64Image'] ?? null;
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);
if (empty($base64) && !empty($data['image'])) {
$base64 = $data['image'];
}
Notification::make()
->title(trans('admin/egg.import.image_updated'))
->success()
->send();
if (!empty($base64)) {
$record->update([
'image' => $base64,
]);
return;
}
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();
$record->refresh();
} else {
Notification::make()
->title(trans('admin/egg.import.no_image'))
->warning()
->send();
}
}),
Action::make('deleteImage')
->visible(fn ($record) => $record->image)
->label('')
->icon('tabler-trash')
->iconButton()
->iconSize(IconSize::Large)
->color('danger')
->action(function ($record) {
return;
}
$record->update([
'image' => null,
]);
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(),
CodeEditor::make('script_install')
->hiddenLabel()
->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> */
@@ -452,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()
@@ -467,6 +474,37 @@ class EditEgg extends EditRecord
$this->fillForm();
}
/**
* Save an image from URL download to a file.
*
* @throws Exception
*/
private function saveImageFromUrl(string $imageUrl, string $extension, Egg $egg): void
{
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$data = @file_get_contents($imageUrl, false, $context, 0, 1048576); // 1024KB
if (empty($data)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$normalizedExtension = match ($extension) {
'svg+xml' => 'svg',
'jpeg' => 'jpg',
default => $extension,
};
Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data);
}
protected function getFormActions(): array
{
return [];

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

@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Admin\Resources\Plugins\Pages;
use App\Enums\PluginCategory;
use App\Filament\Admin\Resources\Plugins\PluginResource;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
class ListPlugins extends ListRecords
{
protected static string $resource = PluginResource::class;
public function reorderTable(array $order, int|string|null $draggedRecordKey = null): void
{
/** @var PluginService $pluginService */
$pluginService = app(PluginService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
$pluginService->updateLoadOrder($order);
}
public function getTabs(): array
{
$tabs = [
'all' => Tab::make('all')
->label(trans('admin/plugin.all'))
->badge(Plugin::count()),
];
foreach (PluginCategory::cases() as $category) {
$query = Plugin::whereCategory($category->value);
$tabs[$category->value] = Tab::make($category->value)
->label($category->getLabel())
->icon($category->getIcon())
->badge($query->count())
->modifyQueryUsing(fn () => $query);
}
return $tabs;
}
}

View File

@@ -0,0 +1,337 @@
<?php
namespace App\Filament\Admin\Resources\Plugins;
use App\Enums\PluginStatus;
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Http\UploadedFile;
class PluginResource extends Resource
{
protected static ?string $model = Plugin::class;
protected static string|\BackedEnum|null $navigationIcon = 'tabler-packages';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
return trans('admin/plugin.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/plugin.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/plugin.model_label_plural');
}
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->count() ?: null;
}
public static function table(Table $table): Table
{
return $table
->openRecordUrlInNewTab()
->reorderable('load_order')
->authorizeReorder(fn () => user()?->can('update plugin'))
->reorderRecordsTriggerAction(fn (Action $action, bool $isReordering) => $action->hiddenLabel()->tooltip($isReordering ? trans('admin/plugin.apply_load_order') : trans('admin/plugin.change_load_order')))
->defaultSort('load_order')
->columns([
TextColumn::make('name')
->label(trans('admin/plugin.name'))
->description(fn (Plugin $plugin) => (strlen($plugin->description) > 80) ? substr($plugin->description, 0, 80).'...' : $plugin->description)
->icon(fn (Plugin $plugin) => $plugin->isUpdateAvailable() ? 'tabler-versions-off' : 'tabler-versions')
->iconColor(fn (Plugin $plugin) => $plugin->isUpdateAvailable() ? 'danger' : 'success')
->tooltip(fn (Plugin $plugin) => $plugin->isUpdateAvailable() ? trans('admin/plugin.update_available') : null)
->sortable()
->searchable(),
TextColumn::make('author')
->label(trans('admin/plugin.author'))
->sortable(),
TextColumn::make('version')
->label(trans('admin/plugin.version'))
->sortable(),
TextColumn::make('category')
->label(trans('admin/plugin.category'))
->badge()
->sortable()
->visible(fn ($livewire) => $livewire->activeTab === 'all'),
TextColumn::make('status')
->label(trans('admin/plugin.status'))
->badge()
->tooltip(fn (Plugin $plugin) => $plugin->status_message)
->sortable(),
])
->recordActions([
Action::make('view')
->label(trans('filament-actions::view.single.label'))
->icon(fn (Plugin $plugin) => $plugin->getReadme() ? 'tabler-eye' : 'tabler-eye-share')
->color('gray')
->visible(fn (Plugin $plugin) => $plugin->getReadme() || $plugin->url)
->url(fn (Plugin $plugin) => !$plugin->getReadme() ? $plugin->url : null, true)
->slideOver(true)
->modalHeading('Readme')
->modalSubmitAction(fn (Plugin $plugin) => Action::make('visit_website')
->label(trans('admin/plugin.visit_website'))
->visible(!is_null($plugin->url))
->url($plugin->url, true)
)
->modalCancelActionLabel(trans('filament::components/modal.actions.close.label'))
->schema(fn (Plugin $plugin) => $plugin->getReadme() ? [
TextEntry::make('readme')
->hiddenLabel()
->markdown()
->state(fn (Plugin $plugin) => $plugin->getReadme()),
] : null),
Action::make('settings')
->label(trans('admin/plugin.settings'))
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
->icon('tabler-settings')
->color('primary')
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::Enabled && $plugin->hasSettings())
->schema(fn (Plugin $plugin) => $plugin->getSettingsForm())
->action(fn (array $data, Plugin $plugin) => $plugin->saveSettings($data))
->slideOver(),
ActionGroup::make([
Action::make('install')
->label(trans('admin/plugin.install'))
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
->icon('tabler-terminal')
->color('success')
->hidden(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
try {
$pluginService->installPlugin($plugin, !$plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
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'))
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
->icon('tabler-download')
->color('success')
->visible(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled && $plugin->isUpdateAvailable())
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
try {
$pluginService->updatePlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
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'))
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
->icon('tabler-check')
->color('success')
->visible(fn (Plugin $plugin) => $plugin->canEnable())
->requiresConfirmation(fn (Plugin $plugin, PluginService $pluginService) => $plugin->isTheme() && $pluginService->hasThemePluginEnabled())
->modalHeading(fn (Plugin $plugin, PluginService $pluginService) => $plugin->isTheme() && $pluginService->hasThemePluginEnabled() ? trans('admin/plugin.enable_theme_modal.heading') : null)
->modalDescription(fn (Plugin $plugin, PluginService $pluginService) => $plugin->isTheme() && $pluginService->hasThemePluginEnabled() ? trans('admin/plugin.enable_theme_modal.description') : null)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
$pluginService->enablePlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
Notification::make()
->success()
->title(trans('admin/plugin.notifications.enabled'))
->send();
}),
Action::make('disable')
->label(trans('admin/plugin.disable'))
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
->icon('tabler-x')
->color('warning')
->visible(fn (Plugin $plugin) => $plugin->canDisable())
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
$pluginService->disablePlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
Notification::make()
->success()
->title(trans('admin/plugin.notifications.disabled'))
->send();
}),
Action::make('delete')
->label(trans('filament-actions::delete.single.label'))
->authorize(fn (Plugin $plugin) => user()?->can('delete', $plugin))
->icon('tabler-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
$pluginService->deletePlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
Notification::make()
->success()
->title(trans('admin/plugin.notifications.deleted'))
->send();
}),
Action::make('uninstall')
->label(trans('admin/plugin.uninstall'))
->authorize(fn (Plugin $plugin) => user()?->can('update', $plugin))
->icon('tabler-terminal')
->color('danger')
->requiresConfirmation()
->hidden(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled || $plugin->status === PluginStatus::Errored)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
try {
$pluginService->uninstallPlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
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();
}
}),
]),
])
->headerActions([
Action::make('import_from_file')
->label(trans('admin/plugin.import_from_file'))
->authorize(fn () => user()?->can('create', Plugin::class))
->icon('tabler-file-download')
->iconButton()
->iconSize(IconSize::ExtraLarge)
->schema([
// TODO: switch to new file upload
FileUpload::make('file')
->required()
->acceptedFileTypes(['application/zip', 'application/zip-compressed', 'application/x-zip-compressed'])
->preserveFilenames()
->previewable(false)
->storeFiles(false),
])
->action(function ($data, $livewire, PluginService $pluginService) {
try {
/** @var UploadedFile $file */
$file = $data['file'];
$pluginName = str($file->getClientOriginalName())->before('.zip')->toString();
if (Plugin::where('id', $pluginName)->exists()) {
throw new Exception(trans('admin/plugin.notifications.import_exists'));
}
$pluginService->downloadPluginFromFile($file);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.imported'))
->send();
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
} catch (Exception $exception) {
report($exception);
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.import_failed'))
->body($exception->getMessage())
->send();
}
}),
Action::make('import_from_url')
->label(trans('admin/plugin.import_from_url'))
->authorize(fn () => user()?->can('create', Plugin::class))
->icon('tabler-world-download')
->iconButton()
->iconSize(IconSize::ExtraLarge)
->schema([
TextInput::make('url')
->required()
->url()
->endsWith('.zip'),
])
->action(function ($data, $livewire, PluginService $pluginService) {
try {
$pluginName = str($data['url'])->before('.zip')->explode('/')->last();
if (Plugin::where('id', $pluginName)->exists()) {
throw new Exception(trans('admin/plugin.notifications.import_exists'));
}
$pluginService->downloadPluginFromUrl($data['url']);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.imported'))
->send();
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
} catch (Exception $exception) {
report($exception);
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.import_failed'))
->body($exception->getMessage())
->send();
}
}),
])
->emptyStateIcon('tabler-packages')
->emptyStateDescription('')
->emptyStateHeading(trans('admin/plugin.no_plugins'));
}
public static function getPages(): array
{
return [
'index' => ListPlugins::route('/'),
];
}
}

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;
@@ -33,6 +34,7 @@ use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\PageRegistration;
@@ -58,6 +60,7 @@ class UserResource extends Resource
{
use CanCustomizePages;
use CanCustomizeRelations;
use CanCustomizeStaticTabs;
use CanModifyForm;
use CanModifyTable;
@@ -145,322 +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,
]),
Select::make('timezone')
->label(trans('profile.timezone'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->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'))
->columnSpan([
'default' => 1,
'md' => 1,
'lg' => 1,
])
->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)
->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 ($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();
}
if (!$user) {
return;
}
$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

@@ -25,9 +25,11 @@ class CreateWebhookConfiguration extends CreateRecord
{
return [
$this->getCancelFormAction()->formId('form')
->iconButton()->iconSize(IconSize::ExtraLarge),
->iconButton()->iconSize(IconSize::ExtraLarge)
->icon('tabler-arrow-left'),
$this->getCreateFormAction()->formId('form')
->iconButton()->iconSize(IconSize::ExtraLarge),
->iconButton()->iconSize(IconSize::ExtraLarge)
->icon('tabler-file-plus'),
];
}

View File

@@ -4,11 +4,11 @@ namespace App\Filament\App\Resources\Servers\Pages;
use App\Enums\CustomizationKey;
use App\Enums\ServerResourceType;
use App\Enums\SubuserPermission;
use App\Filament\App\Resources\Servers\ServerResource;
use App\Filament\Components\Tables\Columns\ProgressBarColumn;
use App\Filament\Components\Tables\Columns\ServerEntryColumn;
use App\Filament\Server\Pages\Console;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Traits\Filament\CanCustomizeHeaderActions;
@@ -91,7 +91,7 @@ class ListServers extends ListRecords
->label('')
->warningThresholdPercent(static::WARNING_THRESHOLD)
->dangerThresholdPercent(static::DANGER_THRESHOLD)
->maxValue(fn (Server $server) => ServerResourceType::CPULimit->getResourceAmount($server) === 0 ? ($server->node->systemInformation()['cpu_count'] ?? 0 * 100) : ServerResourceType::CPULimit->getResourceAmount($server))
->maxValue(fn (Server $server) => ServerResourceType::CPULimit->getResourceAmount($server) === 0 ? (($server->node->systemInformation()['cpu_count'] ?? 0) * 100) : ServerResourceType::CPULimit->getResourceAmount($server))
->state(fn (Server $server) => $server->retrieveResources()['cpu_absolute'] ?? 0)
->helperLabel(fn (Server $server) => $server->formatResource(ServerResourceType::CPU, 0) . ' / ' . $server->formatResource(ServerResourceType::CPULimit, 0)),
ProgressBarColumn::make('memoryUsage')
@@ -244,21 +244,21 @@ class ListServers extends ListRecords
->label(trans('server/console.power_actions.start'))
->color('primary')
->icon('tabler-player-play-filled')
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_START, $server))
->authorize(fn (Server $server) => user()?->can(SubuserPermission::ControlStart, $server))
->visible(fn (Server $server) => $server->retrieveStatus()->isStartable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'start']),
Action::make('restart')
->label(trans('server/console.power_actions.restart'))
->color('gray')
->icon('tabler-reload')
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_RESTART, $server))
->authorize(fn (Server $server) => user()?->can(SubuserPermission::ControlRestart, $server))
->visible(fn (Server $server) => $server->retrieveStatus()->isRestartable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'restart']),
Action::make('stop')
->label(trans('server/console.power_actions.stop'))
->color('danger')
->icon('tabler-player-stop-filled')
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_STOP, $server))
->authorize(fn (Server $server) => user()?->can(SubuserPermission::ControlStop, $server))
->visible(fn (Server $server) => $server->retrieveStatus()->isStoppable() && !$server->retrieveStatus()->isKillable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'stop']),
Action::make('kill')
@@ -266,7 +266,7 @@ class ListServers extends ListRecords
->color('danger')
->icon('tabler-alert-square')
->tooltip(trans('server/console.power_actions.kill_tooltip'))
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_STOP, $server))
->authorize(fn (Server $server) => user()?->can(SubuserPermission::ControlStop, $server))
->visible(fn (Server $server) => $server->retrieveStatus()->isKillable())
->dispatch('powerAction', fn (Server $server) => ['server' => $server, 'action' => 'kill']),
])

View File

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

View File

@@ -24,7 +24,7 @@ class ExportEggAction extends Action
$this->iconButton();
$this->icon('tabler-file-export');
$this->icon('tabler-download');
$this->tableIcon('tabler-download');

View File

@@ -2,7 +2,7 @@
namespace App\Filament\Components\Actions;
use App\Models\Permission;
use App\Enums\SubuserPermission;
use App\Models\Schedule;
use App\Models\Server;
use App\Services\Schedules\Sharing\ScheduleExporterService;
@@ -36,7 +36,7 @@ class ExportScheduleAction extends Action
$this->label(trans('filament-actions::export.modal.actions.export.label'));
$this->authorize(fn () => user()?->can(Permission::ACTION_SCHEDULE_READ, $server));
$this->authorize(fn () => user()?->can(SubuserPermission::ScheduleRead, $server));
$this->action(fn (ScheduleExporterService $service, Schedule $schedule) => response()->streamDownload(function () use ($service, $schedule) {
echo $service->handle($schedule);

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')
@@ -120,7 +144,7 @@ class ImportEggAction extends Action
FileUpload::make('files')
->label(trans('admin/egg.model_label'))
->hint(trans('admin/egg.import.egg_help'))
->acceptedFileTypes(['application/json', 'application/yaml', 'application/x-yaml', 'text/yaml'])
->acceptedFileTypes(['application/json', 'application/x-yaml', 'text/yaml', '.yaml', '.yml'])
->preserveFilenames()
->previewable(false)
->storeFiles(false)
@@ -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

@@ -2,7 +2,7 @@
namespace App\Filament\Components\Actions;
use App\Models\Permission;
use App\Enums\SubuserPermission;
use App\Models\Server;
use App\Services\Schedules\Sharing\ScheduleImporterService;
use Exception;
@@ -33,7 +33,7 @@ class ImportScheduleAction extends Action
$this->label(trans('filament-actions::import.modal.actions.import.label'));
$this->authorize(fn () => user()?->can(Permission::ACTION_SCHEDULE_CREATE, $server));
$this->authorize(fn () => user()?->can(SubuserPermission::ScheduleCreate, $server));
$this->schema([
Tabs::make('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

@@ -0,0 +1,124 @@
<?php
namespace App\Filament\Components\Forms\Fields;
use App\Enums\EditorLanguages;
use Closure;
use Exception;
use Filament\Forms\Components\Field;
class MonacoEditor extends Field
{
public bool|Closure $showLoader = true;
public bool|Closure $automaticLayout = true;
public int|Closure $lineNumbersMinChars = 3;
public string|Closure $fontSize = '15px';
public EditorLanguages|Closure $language = EditorLanguages::html;
public string|Closure $theme = 'blackboard';
protected string $view = 'filament.components.monaco-editor';
protected function setUp(): void
{
$this->showLoader = config('monaco-editor.general.show-loader');
$this->fontSize = config('monaco-editor.general.font-size');
$this->lineNumbersMinChars = config('monaco-editor.general.line-numbers-min-chars');
$this->automaticLayout = config('monaco-editor.general.automatic-layout');
$this->theme = config('monaco-editor.general.default-theme');
}
public function editorTheme(): string
{
$theme = $this->evaluate($this->theme);
if (!isset(config('monaco-editor.themes')[$theme])) {
throw new Exception("Theme {$theme} not found in config file.");
}
return json_encode([
'base' => config("monaco-editor.themes.{$theme}.base"),
'inherit' => config("monaco-editor.themes.{$theme}.inherit"),
'rules' => config("monaco-editor.themes.{$theme}.rules"),
'colors' => config("monaco-editor.themes.{$theme}.colors"),
], JSON_THROW_ON_ERROR);
}
public function language(EditorLanguages|Closure $lang = EditorLanguages::html): static
{
$this->language = $lang;
return $this;
}
public function showLoader(bool|Closure $condition = true): static
{
$this->showLoader = $condition;
return $this;
}
public function hideLoader(): static
{
$this->showLoader = false;
return $this;
}
public function fontSize(string|Closure $size = '15px'): static
{
$this->fontSize = $size;
return $this;
}
public function lineNumbersMinChars(int|Closure $value = 3): static
{
$this->lineNumbersMinChars = $value;
return $this;
}
public function automaticLayout(bool|Closure $condition = true): static
{
$this->automaticLayout = $condition;
return $this;
}
public function theme(string|Closure $name = 'blackboard'): static
{
$this->theme = $name;
return $this;
}
public function getLanguage(): EditorLanguages
{
return $this->evaluate($this->language);
}
public function getShowLoader(): bool
{
return (bool) $this->evaluate($this->showLoader);
}
public function getFontSize(): string
{
return $this->evaluate($this->fontSize);
}
public function getLineNumbersMinChars(): int
{
return (int) $this->evaluate($this->lineNumbersMinChars);
}
public function getAutomaticLayout(): bool
{
return (bool) $this->evaluate($this->automaticLayout);
}
}

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,413 +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')
->prefixIcon('tabler-user')
->label(trans('profile.username'))
->required()
->maxLength(255)
->unique(),
TextInput::make('email')
->prefixIcon('tabler-mail')
->label(trans('profile.email'))
->email()
->required()
->maxLength(255)
->unique(),
TextInput::make('password')
->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()
->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;
@@ -496,50 +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

@@ -4,6 +4,7 @@ namespace App\Filament\Server\Pages;
use App\Enums\ConsoleWidgetPosition;
use App\Enums\ContainerStatus;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Extensions\Features\FeatureService;
use App\Filament\Server\Widgets\ServerConsole;
@@ -12,7 +13,6 @@ use App\Filament\Server\Widgets\ServerMemoryChart;
use App\Filament\Server\Widgets\ServerNetworkChart;
use App\Filament\Server\Widgets\ServerOverview;
use App\Livewire\AlertBanner;
use App\Models\Permission;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use Filament\Actions\Action;
@@ -164,7 +164,7 @@ class Console extends Page
->label(trans('server/console.power_actions.start'))
->color('primary')
->icon('tabler-player-play-filled')
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_START, $server))
->authorize(fn (Server $server) => user()?->can(SubuserPermission::ControlStart, $server))
->disabled(fn (Server $server) => $server->isInConflictState() || !$this->status->isStartable())
->action(fn (Server $server) => $this->dispatch('setServerState', uuid: $server->uuid, state: 'start'))
->size(Size::ExtraLarge),
@@ -172,7 +172,7 @@ class Console extends Page
->label(trans('server/console.power_actions.restart'))
->color('gray')
->icon('tabler-reload')
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_RESTART, $server))
->authorize(fn (Server $server) => user()?->can(SubuserPermission::ControlRestart, $server))
->disabled(fn (Server $server) => $server->isInConflictState() || !$this->status->isRestartable())
->action(fn (Server $server) => $this->dispatch('setServerState', uuid: $server->uuid, state: 'restart'))
->size(Size::ExtraLarge),
@@ -180,7 +180,7 @@ class Console extends Page
->label(trans('server/console.power_actions.stop'))
->color('danger')
->icon('tabler-player-stop-filled')
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_STOP, $server))
->authorize(fn (Server $server) => user()?->can(SubuserPermission::ControlStop, $server))
->visible(fn () => !$this->status->isKillable())
->disabled(fn (Server $server) => $server->isInConflictState() || !$this->status->isStoppable())
->action(fn (Server $server) => $this->dispatch('setServerState', uuid: $server->uuid, state: 'stop'))
@@ -191,7 +191,7 @@ class Console extends Page
->icon('tabler-alert-square')
->tooltip(trans('server/console.power_actions.kill_tooltip'))
->requiresConfirmation()
->authorize(fn (Server $server) => user()?->can(Permission::ACTION_CONTROL_STOP, $server))
->authorize(fn (Server $server) => user()?->can(SubuserPermission::ControlStop, $server))
->visible(fn () => $this->status->isKillable())
->disabled(fn (Server $server) => $server->isInConflictState() || !$this->status->isKillable())
->action(fn (Server $server) => $this->dispatch('setServerState', uuid: $server->uuid, state: 'kill'))

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

@@ -2,8 +2,9 @@
namespace App\Filament\Server\Pages;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Models\Permission;
use App\Filament\Components\Actions\DeleteServerIcon;
use App\Models\Server;
use App\Services\Servers\ReinstallServerService;
use Exception;
@@ -25,6 +26,8 @@ use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Alignment;
use Filament\Support\Enums\IconSize;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class Settings extends ServerFormPage
{
@@ -60,7 +63,7 @@ class Settings extends ServerFormPage
->columnStart(1)
->columnSpanFull()
->label(trans('server/setting.server_info.name'))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_SETTINGS_RENAME, $server))
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::SettingsRename, $server))
->required()
->live(onBlur: true)
->afterStateUpdated(fn ($state, Server $server) => $this->updateName($state, $server)),
@@ -69,7 +72,7 @@ class Settings extends ServerFormPage
->columnSpanFull()
->label(trans('server/setting.server_info.description'))
->hidden(!config('panel.editable_server_descriptions'))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_SETTINGS_DESCRIPTION, $server))
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::SettingsDescription, $server))
->autosize()
->live(onBlur: true)
->afterStateUpdated(fn ($state, Server $server) => $this->updateDescription($state ?? '', $server)),
@@ -90,155 +93,124 @@ class Settings extends ServerFormPage
->modal()
->modalSubmitActionLabel(trans('server/setting.server_info.icon.upload'))
->schema([
Tabs::make()->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
Hidden::make('base64Image'),
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);
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
Hidden::make('imageUrl'),
Hidden::make('imageExtension'),
TextInput::make('image_url')
->label(trans('admin/egg.import.image_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('image_url_error', null);
$set('imageUrl', null);
$set('imageExtension', null);
return;
}
try {
if (!in_array(parse_url($state, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
return;
}
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
try {
if (!in_array(parse_url($state, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!array_key_exists($extension, Server::IMAGE_FORMATS)) {
throw new \Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Server::IMAGE_FORMATS))]));
}
$host = parse_url($state, PHP_URL_HOST);
$ip = gethostbyname($host);
if (
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
) {
throw new \Exception(trans('admin/egg.import.no_local_ip'));
}
$set('imageUrl', $state);
$set('imageExtension', $extension);
$set('image_url_error', null);
} catch (\Exception $e) {
$set('image_url_error', $e->getMessage());
$set('imageUrl', null);
$set('imageExtension', null);
}
$allowedExtensions = [
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
];
$extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!array_key_exists($extension, $allowedExtensions)) {
throw new \Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys($allowedExtensions))]));
}
$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'));
}
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$imageContent = @file_get_contents($state, false, $context, 0, 262144); //256KB
if (!$imageContent) {
throw new \Exception(trans('admin/egg.import.image_error'));
}
$mimeType = $allowedExtensions[$extension];
$base64 = 'data:' . $mimeType . ';base64,' . base64_encode($imageContent);
$set('base64Image', $base64);
$set('image_url_error', null);
} catch (\Exception $e) {
$set('image_url_error', $e->getMessage());
$set('base64Image', null);
}
}),
TextEntry::make('image_url_error')
->hiddenLabel()
->visible(fn (Get $get) => $get('image_url_error') !== null)
->afterStateHydrated(fn (Get $get) => $get('image_url_error')),
Image::make(fn (Get $get) => $get('image_url'), '')
->imageSize(150)
->visible(fn (Get $get) => $get('image_url') && !$get('image_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('image')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->saveUploadedFileUsing(function ($file, Set $set) {
$base64 = "data:{$file->getMimeType()};base64,". base64_encode(file_get_contents($file->getRealPath()));
$set('base64Image', $base64);
return $base64;
}),
]),
]),
}),
TextEntry::make('image_url_error')
->hiddenLabel()
->visible(fn (Get $get) => $get('image_url_error') !== null)
->afterStateHydrated(fn (Get $get) => $get('image_url_error')),
Image::make(fn (Get $get) => $get('image_url'), '')
->imageSize(150)
->visible(fn (Get $get) => $get('image_url') && !$get('image_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('image')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->disk('public')
->directory(Server::ICON_STORAGE_PATH)
->acceptedFileTypes([
'image/png',
'image/jpeg',
'image/webp',
'image/svg+xml',
])
->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) {
return $record->uuid . '.' . $file->getClientOriginalExtension();
}),
]),
]),
])
->action(function (array $data, $record): void {
$base64 = $data['base64Image'] ?? null;
if (empty($base64) && !empty($data['image'])) {
$base64 = $data['image'];
}
if (!empty($base64)) {
$record->update([
'icon' => $base64,
]);
if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) {
$this->saveIconFromUrl($data['imageUrl'], $data['imageExtension'], $record);
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
$record->refresh();
} else {
return;
}
if (!empty($data['image'])) {
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
}
if (empty($data['imageUrl']) && empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.no_image'))
->warning()
->send();
}
}),
Action::make('deleteIcon')
->visible(fn ($record) => $record->icon)
->label('')
->icon('tabler-trash')
->iconButton()->iconSize(IconSize::Large)
->color('danger')
->action(function ($record) {
$record->update([
'icon' => null,
]);
Notification::make()
->title(trans('server/setting.server_info.icon.deleted'))
->success()
->send();
$record->refresh();
}),
DeleteServerIcon::make(),
]),
TextInput::make('uuid')
->label(trans('server/setting.server_info.uuid'))
@@ -319,7 +291,7 @@ class Settings extends ServerFormPage
]),
Fieldset::make(trans('server/setting.server_info.sftp.title'))
->columnSpanFull()
->hidden(fn (Server $server) => !user()?->can(Permission::ACTION_FILE_SFTP, $server))
->hidden(fn (Server $server) => !user()?->can(SubuserPermission::FileSftp, $server))
->columns([
'default' => 1,
'sm' => 1,
@@ -361,19 +333,19 @@ class Settings extends ServerFormPage
]),
]),
Section::make(trans('server/setting.reinstall.title'))
->hidden(fn (Server $server) => !user()?->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->hidden(fn (Server $server) => !user()?->can(SubuserPermission::SettingsReinstall, $server))
->columnSpanFull()
->footerActions([
Action::make('reinstall')
->label(trans('server/setting.reinstall.action'))
->color('danger')
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_SETTINGS_REINSTALL, $server))
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::SettingsReinstall, $server))
->requiresConfirmation()
->modalHeading(trans('server/setting.reinstall.modal'))
->modalDescription(trans('server/setting.reinstall.modal_description'))
->modalSubmitActionLabel(trans('server/setting.reinstall.yes'))
->action(function (Server $server, ReinstallServerService $reinstallService) {
abort_unless(user()?->can(Permission::ACTION_SETTINGS_REINSTALL, $server), 403);
abort_unless(user()?->can(SubuserPermission::SettingsReinstall, $server), 403);
try {
$reinstallService->handle($server);
@@ -412,7 +384,7 @@ class Settings extends ServerFormPage
public function updateName(string $name, Server $server): void
{
abort_unless(user()?->can(Permission::ACTION_SETTINGS_RENAME, $server), 403);
abort_unless(user()?->can(SubuserPermission::SettingsRename, $server), 403);
$original = $server->name;
@@ -443,7 +415,7 @@ class Settings extends ServerFormPage
public function updateDescription(string $description, Server $server): void
{
abort_unless(user()?->can(Permission::ACTION_SETTINGS_DESCRIPTION, $server) && config('panel.editable_server_descriptions'), 403);
abort_unless(user()?->can(SubuserPermission::SettingsDescription, $server) && config('panel.editable_server_descriptions'), 403);
$original = $server->description;
@@ -472,6 +444,37 @@ class Settings extends ServerFormPage
}
}
/**
* Save an icon from URL download to a file.
*
* @throws Exception
*/
private function saveIconFromUrl(string $imageUrl, string $extension, Server $server): void
{
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB
if (empty($data)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
$normalizedExtension = match ($extension) {
'svg+xml' => 'svg',
'jpeg' => 'jpg',
default => $extension,
};
Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data);
}
public function getTitle(): string
{
return trans('server/setting.title');

View File

@@ -2,10 +2,10 @@
namespace App\Filament\Server\Pages;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Components\Actions\PreviewStartupAction;
use App\Filament\Components\Forms\Fields\StartupVariable;
use App\Models\Permission;
use App\Models\Server;
use App\Models\ServerVariable;
use Exception;
@@ -51,7 +51,7 @@ class Startup extends ServerFormPage
->label(trans('server/startup.command'))
->live()
->visible(fn (Server $server) => in_array($server->startup, $server->egg->startup_commands))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_STARTUP_UPDATE, $server))
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::StartupUpdate, $server))
->formatStateUsing(fn (Server $server) => $server->startup)
->afterStateUpdated(function ($state, Server $server, Set $set) {
$original = $server->startup;
@@ -85,7 +85,7 @@ class Startup extends ServerFormPage
->label(trans('server/startup.docker_image'))
->live()
->visible(fn (Server $server) => in_array($server->image, $server->egg->docker_images))
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_STARTUP_DOCKER_IMAGE, $server))
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::StartupDockerImage, $server))
->afterStateUpdated(function ($state, Server $server) {
$original = $server->image;
$server->forceFill(['image' => $state])->saveOrFail();
@@ -123,7 +123,7 @@ class Startup extends ServerFormPage
return $query->where('egg_variables.user_viewable', true)->orderByPowerJoins('variable.sort');
})
->grid()
->disabled(fn (Server $server) => !user()?->can(Permission::ACTION_STARTUP_UPDATE, $server))
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::StartupUpdate, $server))
->reorderable(false)->addable(false)->deletable(false)
->schema([
StartupVariable::make('variable_value')
@@ -139,20 +139,23 @@ class Startup extends ServerFormPage
protected function authorizeAccess(): void
{
abort_unless(user()?->can(Permission::ACTION_STARTUP_READ, Filament::getTenant()), 403);
abort_unless(user()?->can(SubuserPermission::StartupRead, Filament::getTenant()), 403);
}
public static function canAccess(): bool
{
return parent::canAccess() && user()?->can(Permission::ACTION_STARTUP_READ, Filament::getTenant());
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

@@ -2,10 +2,10 @@
namespace App\Filament\Server\Resources\Allocations;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Server\Resources\Allocations\Pages\ListAllocations;
use App\Models\Allocation;
use App\Models\Permission;
use App\Models\Server;
use App\Services\Allocations\FindAssignableAllocationService;
use App\Traits\Filament\BlockAccessInConflict;
@@ -57,7 +57,7 @@ class AllocationResource extends Resource
TextInputColumn::make('notes')
->label(trans('server/network.notes'))
->visibleFrom('sm')
->disabled(fn () => !user()?->can(Permission::ACTION_ALLOCATION_UPDATE, $server))
->disabled(fn () => !user()?->can(SubuserPermission::AllocationUpdate, $server))
->placeholder(trans('server/network.no_notes')),
IconColumn::make('primary')
->icon(fn ($state) => match ($state) {
@@ -69,7 +69,7 @@ class AllocationResource extends Resource
default => 'gray',
})
->tooltip(fn (Allocation $allocation) => $allocation->id === $server->allocation_id ? trans('server/network.primary') : trans('server/network.make_primary'))
->action(fn (Allocation $allocation) => user()?->can(PERMISSION::ACTION_ALLOCATION_UPDATE, $server) && $server->update(['allocation_id' => $allocation->id]))
->action(fn (Allocation $allocation) => user()?->can(SubuserPermission::AllocationUpdate, $server) && $server->update(['allocation_id' => $allocation->id]))
->default(fn (Allocation $allocation) => $allocation->id === $server->allocation_id)
->label(trans('server/network.primary')),
IconColumn::make('is_locked')
@@ -81,7 +81,7 @@ class AllocationResource extends Resource
->recordActions([
DetachAction::make()
->visible(fn (Allocation $allocation) => !$allocation->is_locked || user()?->can('update', $allocation->node))
->authorize(fn () => user()?->can(Permission::ACTION_ALLOCATION_DELETE, $server))
->authorize(fn () => user()?->can(SubuserPermission::AllocationDelete, $server))
->label(trans('server/network.delete'))
->action(function (Allocation $allocation) {
Allocation::where('id', $allocation->id)->update([
@@ -101,7 +101,7 @@ class AllocationResource extends Resource
Action::make('add_allocation')
->hiddenLabel()->iconButton()->iconSize(IconSize::ExtraLarge)
->icon(fn () => $server->allocations()->count() >= $server->allocation_limit ? 'tabler-network-off' : 'tabler-network')
->authorize(fn () => user()?->can(Permission::ACTION_ALLOCATION_CREATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::AllocationCreate, $server))
->tooltip(fn () => $server->allocations()->count() >= $server->allocation_limit ? trans('server/network.limit') : trans('server/network.add'))
->hidden(fn () => !config('panel.client_features.allocations.enabled') || $server->allocation === null)
->disabled(fn () => $server->allocations()->count() >= $server->allocation_limit)

View File

@@ -4,13 +4,13 @@ namespace App\Filament\Server\Resources\Backups;
use App\Enums\BackupStatus;
use App\Enums\ServerState;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\Backups\Pages\ListBackups;
use App\Http\Controllers\Api\Client\Servers\BackupController;
use App\Models\Backup;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonBackupRepository;
use App\Services\Backups\DeleteBackupService;
@@ -128,7 +128,7 @@ class BackupResource extends Resource
ActionGroup::make([
Action::make('rename')
->icon('tabler-pencil')
->authorize(fn () => user()?->can(Permission::ACTION_BACKUP_DELETE, $server))
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
->label(trans('server/backup.actions.rename.title'))
->schema([
TextInput::make('name')
@@ -159,7 +159,7 @@ class BackupResource extends Resource
Action::make('lock')
->iconSize(IconSize::Large)
->icon(fn (Backup $backup) => !$backup->is_locked ? 'tabler-lock' : 'tabler-lock-open')
->authorize(fn () => user()?->can(Permission::ACTION_BACKUP_DELETE, $server))
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
->label(fn (Backup $backup) => !$backup->is_locked ? trans('server/backup.actions.lock.lock') : trans('server/backup.actions.lock.unlock'))
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
@@ -168,7 +168,7 @@ class BackupResource extends Resource
->iconSize(IconSize::Large)
->color('primary')
->icon('tabler-download')
->authorize(fn () => user()?->can(Permission::ACTION_BACKUP_DOWNLOAD, $server))
->authorize(fn () => user()?->can(SubuserPermission::BackupDownload, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('restore')
@@ -176,7 +176,7 @@ class BackupResource extends Resource
->iconSize(IconSize::Large)
->color('success')
->icon('tabler-folder-up')
->authorize(fn () => user()?->can(Permission::ACTION_BACKUP_RESTORE, $server))
->authorize(fn () => user()?->can(SubuserPermission::BackupRestore, $server))
->schema([
TextEntry::make('stop_info')
->hiddenLabel()
@@ -258,7 +258,7 @@ class BackupResource extends Resource
])
->toolbarActions([
CreateAction::make()
->authorize(fn () => user()?->can(Permission::ACTION_BACKUP_CREATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::BackupCreate, $server))
->icon('tabler-file-zip')
->tooltip(fn () => $server->backups()->count() >= $server->backup_limit ? trans('server/backup.actions.create.limit') : trans('server/backup.actions.create.title'))
->disabled(fn () => $server->backups()->count() >= $server->backup_limit)
@@ -269,7 +269,7 @@ class BackupResource extends Resource
->action(function (InitiateBackupService $initiateBackupService, $data) use ($server) {
$action = $initiateBackupService->setIgnoredFiles(explode(PHP_EOL, $data['ignored'] ?? ''));
if (user()?->can(Permission::ACTION_BACKUP_DELETE, $server)) {
if (user()?->can(SubuserPermission::BackupDelete, $server)) {
$action->setIsLocked((bool) $data['is_locked']);
}

View File

@@ -2,12 +2,12 @@
namespace App\Filament\Server\Resources\Databases;
use App\Enums\SubuserPermission;
use App\Filament\Components\Actions\RotateDatabasePasswordAction;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\Databases\Pages\ListDatabases;
use App\Models\Database;
use App\Models\DatabaseHost;
use App\Models\Permission;
use App\Models\Server;
use App\Services\Databases\DatabaseManagementService;
use App\Traits\Filament\BlockAccessInConflict;
@@ -87,10 +87,10 @@ class DatabaseResource extends Resource
TextInput::make('password')
->label(trans('server/database.password'))
->password()->revealable()
->hidden(fn () => !user()?->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hidden(fn () => !user()?->can(SubuserPermission::DatabaseViewPassword, $server))
->hintAction(
RotateDatabasePasswordAction::make()
->authorize(fn () => user()?->can(Permission::ACTION_DATABASE_UPDATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::DatabaseUpdate, $server))
)
->copyable()
->formatStateUsing(fn (Database $database) => $database->password),
@@ -98,11 +98,11 @@ 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()
->hidden(!user()?->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $server))
->hidden(!user()?->can(SubuserPermission::DatabaseViewPassword, $server))
->copyable()
->columnSpanFull()
->formatStateUsing(fn (Database $database) => $database->jdbc),

View File

@@ -2,9 +2,9 @@
namespace App\Filament\Server\Resources\Files\Pages;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Server\Resources\Files\FileResource;
use App\Models\Permission;
use App\Models\Server;
use App\Services\Nodes\NodeJWTService;
use Carbon\CarbonImmutable;
@@ -55,7 +55,7 @@ class DownloadFiles extends Page
protected function authorizeAccess(): void
{
abort_unless(user()?->can(Permission::ACTION_FILE_READ_CONTENT, Filament::getTenant()), 403);
abort_unless(user()?->can(SubuserPermission::FileReadContent, Filament::getTenant()), 403);
}
public static function route(string $path): PageRegistration

View File

@@ -2,13 +2,15 @@
namespace App\Filament\Server\Resources\Files\Pages;
use App\Enums\EditorLanguages;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\Server\FileSizeTooLargeException;
use App\Exceptions\Repository\FileNotEditableException;
use App\Facades\Activity;
use App\Filament\Components\Forms\Fields\MonacoEditor;
use App\Filament\Server\Resources\Files\FileResource;
use App\Livewire\AlertBanner;
use App\Models\File;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Traits\Filament\CanCustomizeHeaderActions;
@@ -16,8 +18,6 @@ use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Closure;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\CodeEditor;
use Filament\Forms\Components\CodeEditor\Enums\Language;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Notifications\Notification;
@@ -83,7 +83,7 @@ class EditFiles extends Page
->footerActions([
Action::make('save_and_close')
->label(trans('server/file.actions.edit.save_close'))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileUpdate, $server))
->icon('tabler-device-floppy')
->keyBindings('mod+shift+s')
->action(function () {
@@ -103,7 +103,7 @@ class EditFiles extends Page
}),
Action::make('save')
->label(trans('server/file.actions.edit.save'))
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileUpdate, $server))
->icon('tabler-device-floppy')
->keyBindings('mod+s')
->action(function () {
@@ -137,43 +137,18 @@ class EditFiles extends Page
->label(trans('server/file.actions.new_file.syntax'))
->searchable()
->live()
->options(Language::class)
->options(EditorLanguages::class)
->selectablePlaceholder(false)
->default(fn () => match (pathinfo($this->path, PATHINFO_EXTENSION)) {
'cc', 'hpp' => Language::Cpp,
'css', 'scss' => Language::Css,
'go' => Language::Go,
'html' => Language::Html,
'class', 'kt', 'kts' => Language::Java,
'js', 'mjs', 'cjs', 'ts', 'tsx' => Language::JavaScript,
'json', 'json5' => Language::Json,
'md' => Language::Markdown,
'php3', 'php4', 'php5', 'phtml', 'php' => Language::Php,
'py', 'pyc', 'pyo', 'pyi' => Language::Python,
'xml' => Language::Xml,
'yml', 'yaml' => Language::Yaml,
default => null,
}),
CodeEditor::make('editor')
->afterStateUpdated(fn ($state) => $this->dispatch('setLanguage', lang: $state))
->default(fn () => EditorLanguages::fromWithAlias(pathinfo($this->path, PATHINFO_EXTENSION))),
MonacoEditor::make('editor')
->hiddenLabel()
->language(fn (Get $get) => $get('lang'))
->default(function () {
try {
$contents = $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size'));
return mb_convert_encoding($contents, 'UTF-8', ['UTF-8', 'UTF-16', 'ISO-8859-1', 'Windows-1252', 'ASCII']);
return mb_convert_encoding($contents, 'UTF-8', ['UTF-8', 'UTF-16', 'ISO-8859-1', 'ASCII']);
} catch (FileSizeTooLargeException) {
AlertBanner::make('file_too_large')
->title(trans('server/file.alerts.file_too_large.title', ['name' => basename($this->path)]))
@@ -196,6 +171,7 @@ class EditFiles extends Page
} catch (ConnectionException) {
// Alert banner for this one will be handled by ListFiles
}
$this->redirectToList();
}),
])
@@ -233,7 +209,7 @@ class EditFiles extends Page
protected function authorizeAccess(): void
{
abort_unless(user()?->can(Permission::ACTION_FILE_READ_CONTENT, Filament::getTenant()), 403);
abort_unless(user()?->can(SubuserPermission::FileReadContent, Filament::getTenant()), 403);
}
/**

View File

@@ -2,14 +2,16 @@
namespace App\Filament\Server\Resources\Files\Pages;
use App\Enums\EditorLanguages;
use App\Enums\SubuserPermission;
use App\Exceptions\Repository\FileExistsException;
use App\Facades\Activity;
use App\Filament\Components\Forms\Fields\MonacoEditor;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Filament\Server\Resources\Files\FileResource;
use App\Livewire\AlertBanner;
use App\Models\File;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Services\Nodes\NodeJWTService;
@@ -26,7 +28,6 @@ use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\CodeEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
@@ -122,7 +123,7 @@ class ListFiles extends ListRecords
return self::getUrl(['path' => encode_path(join_paths($this->path, $file->name))]);
}
if (!user()?->can(Permission::ACTION_FILE_READ_CONTENT, $server)) {
if (!user()?->can(SubuserPermission::FileReadContent, $server)) {
return null;
}
@@ -130,18 +131,18 @@ class ListFiles extends ListRecords
})
->recordActions([
Action::make('view')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_READ, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileRead, $server))
->label(trans('server/file.actions.open'))
->icon('tabler-eye')->iconSize(IconSize::Large)
->visible(fn (File $file) => $file->is_directory)
->url(fn (File $file) => self::getUrl(['path' => encode_path(join_paths($this->path, $file->name))])),
EditAction::make('edit')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileReadContent, $server))
->visible(fn (File $file) => $file->canEdit())
->url(fn (File $file) => EditFiles::getUrl(['path' => encode_path(join_paths($this->path, $file->name))])),
ActionGroup::make([
Action::make('rename')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileUpdate, $server))
->label(trans('server/file.actions.rename.title'))
->icon('tabler-forms')->iconSize(IconSize::Large)
->schema([
@@ -171,7 +172,7 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
Action::make('copy')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileCreate, $server))
->label(trans('server/file.actions.copy.title'))
->icon('tabler-copy')->iconSize(IconSize::Large)
->visible(fn (File $file) => $file->is_file)
@@ -190,13 +191,13 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
Action::make('download')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_READ_CONTENT, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileReadContent, $server))
->label(trans('server/file.actions.download'))
->icon('tabler-download')->iconSize(IconSize::Large)
->visible(fn (File $file) => $file->is_file)
->url(fn (File $file) => DownloadFiles::getUrl(['path' => encode_path(join_paths($this->path, $file->name))]), true),
Action::make('move')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileUpdate, $server))
->label(trans('server/file.actions.move.title'))
->icon('tabler-replace')->iconSize(IconSize::Large)
->schema([
@@ -233,7 +234,7 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
Action::make('permissions')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileUpdate, $server))
->label(trans('server/file.actions.permissions.title'))
->icon('tabler-license')->iconSize(IconSize::Large)
->schema([
@@ -295,7 +296,7 @@ class ListFiles extends ListRecords
->send();
}),
Action::make('archive')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_ARCHIVE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileArchive, $server))
->label(trans('server/file.actions.archive.title'))
->icon('tabler-archive')->iconSize(IconSize::Large)
->schema([
@@ -335,7 +336,7 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
Action::make('unarchive')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_ARCHIVE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileArchive, $server))
->label(trans('server/file.actions.unarchive.title'))
->icon('tabler-archive')->iconSize(IconSize::Large)
->visible(fn (File $file) => $file->isArchive())
@@ -356,7 +357,7 @@ class ListFiles extends ListRecords
}),
])->iconSize(IconSize::Large),
DeleteAction::make()
->authorize(fn () => user()?->can(Permission::ACTION_FILE_DELETE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileDelete, $server))
->hiddenLabel()
->iconSize(IconSize::Large)
->requiresConfirmation()
@@ -376,7 +377,7 @@ class ListFiles extends ListRecords
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('move')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_UPDATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileUpdate, $server))
->schema([
TextInput::make('location')
->label(trans('server/file.actions.move.directory'))
@@ -405,7 +406,7 @@ class ListFiles extends ListRecords
$this->refreshPage();
}),
BulkAction::make('archive')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_ARCHIVE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileArchive, $server))
->schema([
Grid::make(3)
->schema([
@@ -446,7 +447,7 @@ class ListFiles extends ListRecords
}),
DeleteBulkAction::make()
->successNotificationTitle(null)
->authorize(fn () => user()?->can(Permission::ACTION_FILE_DELETE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileDelete, $server))
->action(function (Collection $files) {
$files = $files->map(fn ($file) => $file['name'])->toArray();
$this->getDaemonFileRepository()->deleteFiles($this->path, $files);
@@ -466,7 +467,7 @@ class ListFiles extends ListRecords
]),
Action::make('new_file')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileCreate, $server))
->tooltip(trans('server/file.actions.new_file.title'))
->hiddenLabel()->icon('tabler-file-plus')->iconButton()->iconSize(IconSize::ExtraLarge)
->color('primary')
@@ -495,11 +496,20 @@ class ListFiles extends ListRecords
TextInput::make('name')
->label(trans('server/file.actions.new_file.file_name'))
->required(),
CodeEditor::make('editor')
->hiddenLabel(),
Select::make('lang')
->label(trans('server/file.actions.new_file.syntax'))
->searchable()
->live()
->options(EditorLanguages::class)
->selectablePlaceholder(false)
->afterStateUpdated(fn ($state) => $this->dispatch('setLanguage', lang: $state))
->default(EditorLanguages::plaintext->value),
MonacoEditor::make('editor')
->hiddenLabel()
->language(fn (Get $get) => $get('lang') ?? 'plaintext'),
]),
Action::make('new_folder')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileCreate, $server))
->hiddenLabel()->icon('tabler-folder-plus')->iconButton()->iconSize(IconSize::ExtraLarge)
->tooltip(trans('server/file.actions.new_folder.title'))
->color('primary')
@@ -530,10 +540,10 @@ class ListFiles extends ListRecords
->required(),
]),
Action::make('uploadFile')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileCreate, $server))
->view('filament.server.pages.file-upload'),
Action::make('uploadURL')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_CREATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileCreate, $server))
->hiddenLabel()->icon('tabler-download')->iconButton()->iconSize(IconSize::ExtraLarge)
->tooltip(trans('server/file.actions.upload.from_url'))
->modalHeading(trans('server/file.actions.upload.from_url'))
@@ -555,17 +565,17 @@ class ListFiles extends ListRecords
->url(),
]),
Action::make('search')
->authorize(fn () => user()?->can(Permission::ACTION_FILE_READ, $server))
->authorize(fn () => user()?->can(SubuserPermission::FileRead, $server))
->hiddenLabel()->iconButton()->iconSize(IconSize::ExtraLarge)
->tooltip(trans('server/file.actions.global_search.title'))
->tooltip(trans('server/file.actions.nested_search.title'))
->color('primary')
->icon('tabler-world-search')
->modalHeading(trans('server/file.actions.global_search.title'))
->modalSubmitActionLabel(trans('server/file.actions.global_search.search'))
->modalHeading(trans('server/file.actions.nested_search.title'))
->modalSubmitActionLabel(trans('server/file.actions.nested_search.search'))
->schema([
TextInput::make('searchTerm')
->label(trans('server/file.actions.global_search.search_term'))
->placeholder(trans('server/file.actions.global_search.search_term_placeholder'))
->label(trans('server/file.actions.nested_search.search_term'))
->placeholder(trans('server/file.actions.nested_search.search_term_placeholder'))
->required()
->regex('/^[^*]*\*?[^*]*$/')
->minValue(3),
@@ -605,7 +615,7 @@ class ListFiles extends ListRecords
/** @var Server $server */
$server = Filament::getTenant();
if (!user()?->can(Permission::ACTION_FILE_CREATE, $server)) {
if (!user()?->can(SubuserPermission::FileCreate, $server)) {
abort(403, 'You do not have permission to upload files.');
}
@@ -640,7 +650,7 @@ class ListFiles extends ListRecords
/** @var Server $server */
$server = Filament::getTenant();
if (!user()?->can(Permission::ACTION_FILE_CREATE, $server)) {
if (!user()?->can(SubuserPermission::FileCreate, $server)) {
abort(403, 'You do not have permission to create folders.');
}

View File

@@ -36,7 +36,7 @@ class SearchFiles extends ListRecords
return [
$resource::getUrl() => $resource::getBreadcrumb(),
self::getUrl(['searchTerm' => $this->searchTerm]) => trans('server/file.actions.global_search.search_for_term', ['term' => ' "' . $this->searchTerm . '"']),
self::getUrl(['searchTerm' => $this->searchTerm]) => trans('server/file.actions.nested_search.search_for_term', ['term' => ' "' . $this->searchTerm . '"']),
];
}
@@ -67,15 +67,15 @@ class SearchFiles extends ListRecords
])
->recordUrl(function (File $file) {
if ($file->is_directory) {
return ListFiles::getUrl(['path' => join_paths($this->path, $file->name)]);
return ListFiles::getUrl(['path' => $file->name]);
}
return $file->canEdit() ? EditFiles::getUrl(['path' => join_paths($this->path, $file->name)]) : null;
return $file->canEdit() ? EditFiles::getUrl(['path' => $file->name]) : null;
});
}
public function getTitle(): string|Htmlable
{
return trans('server/file.actions.global_search.title');
return trans('server/file.actions.nested_search.title');
}
}

View File

@@ -2,15 +2,19 @@
namespace App\Filament\Server\Resources\Schedules\Pages;
use App\Enums\ScheduleStatus;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Components\Actions\ExportScheduleAction;
use App\Filament\Server\Resources\Schedules\ScheduleResource;
use App\Models\Schedule;
use App\Services\Schedules\ProcessScheduleService;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Facades\Filament;
use Filament\Resources\Pages\EditRecord;
use Filament\Support\Enums\IconSize;
@@ -49,22 +53,36 @@ class EditSchedule extends EditRecord
{
return [
DeleteAction::make()
->hiddenLabel()->iconButton()->iconSize(IconSize::ExtraLarge)
->hiddenLabel()
->iconButton()->iconSize(IconSize::ExtraLarge)
->tooltip(trans('server/schedule.delete'))
->after(function ($record) {
Activity::event('server:schedule.delete')
->property('name', $record->name)
->log();
}),
Action::make('run_now')
->iconButton()->iconSize(IconSize::ExtraLarge)
->icon('tabler-run')
->authorize(fn () => user()?->can(SubuserPermission::ScheduleUpdate, Filament::getTenant()))
->tooltip(fn (Schedule $schedule) => $schedule->tasks->count() === 0 ? trans('server/schedule.no_tasks') : ($schedule->status === ScheduleStatus::Processing ? ScheduleStatus::Processing->getLabel() : trans('server/schedule.run_now')))
->color(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->status === ScheduleStatus::Processing ? 'warning' : 'primary')
->disabled(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->status === ScheduleStatus::Processing)
->action(function (ProcessScheduleService $service, Schedule $schedule) {
$service->handle($schedule, true);
Activity::event('server:schedule.execute')
->subject($schedule)
->property('name', $schedule->name)
->log();
$this->fillForm();
}),
ExportScheduleAction::make(),
$this->getSaveFormAction()->formId('form')
->hiddenLabel()->iconButton()->iconSize(IconSize::ExtraLarge)
->icon('tabler-device-floppy')
->tooltip(trans('server/schedule.save')),
$this->getCancelFormAction()->formId('form')
->hiddenLabel()->iconButton()->iconSize(IconSize::ExtraLarge)
->icon('tabler-arrow-left')
->tooltip(trans('server/schedule.cancel')),
];
}

View File

@@ -2,18 +2,12 @@
namespace App\Filament\Server\Resources\Schedules\Pages;
use App\Enums\ScheduleStatus;
use App\Facades\Activity;
use App\Filament\Server\Resources\Schedules\ScheduleResource;
use App\Models\Permission;
use App\Models\Schedule;
use App\Services\Schedules\ProcessScheduleService;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ViewRecord;
use Filament\Support\Enums\IconSize;
@@ -28,21 +22,6 @@ class ViewSchedule extends ViewRecord
protected function getDefaultHeaderActions(): array
{
return [
Action::make('run_now')
->authorize(fn () => user()?->can(Permission::ACTION_SCHEDULE_UPDATE, Filament::getTenant()))
->label(fn (Schedule $schedule) => $schedule->tasks->count() === 0 ? trans('server/schedule.no_tasks') : ($schedule->status === ScheduleStatus::Processing ? ScheduleStatus::Processing->getLabel() : trans('server/schedule.run_now')))
->color(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->status === ScheduleStatus::Processing ? 'warning' : 'primary')
->disabled(fn (Schedule $schedule) => $schedule->tasks->count() === 0 || $schedule->status === ScheduleStatus::Processing)
->action(function (ProcessScheduleService $service, Schedule $schedule) {
$service->handle($schedule, true);
Activity::event('server:schedule.execute')
->subject($schedule)
->property('name', $schedule->name)
->log();
$this->fillForm();
}),
EditAction::make()
->hiddenLabel()->iconButton()->iconSize(IconSize::ExtraLarge)
->icon('tabler-calendar-code')

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Filament\Server\Resources\Users\Pages;
namespace App\Filament\Server\Resources\Subusers\Pages;
use App\Filament\Server\Resources\Users\UserResource;
use App\Filament\Server\Resources\Subusers\SubuserResource;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
@@ -10,12 +10,12 @@ use Filament\Actions\ActionGroup;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Contracts\Support\Htmlable;
class ListUsers extends ListRecords
class ListSubusers extends ListRecords
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = UserResource::class;
protected static string $resource = SubuserResource::class;
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array

View File

@@ -1,12 +1,12 @@
<?php
namespace App\Filament\Server\Resources\Users;
namespace App\Filament\Server\Resources\Subusers;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Server\Resources\Users\Pages\ListUsers;
use App\Models\Permission;
use App\Filament\Server\Resources\Subusers\Pages\ListSubusers;
use App\Models\Server;
use App\Models\User;
use App\Models\Subuser;
use App\Services\Subusers\SubuserCreationService;
use App\Services\Subusers\SubuserDeletionService;
use App\Services\Subusers\SubuserUpdateService;
@@ -38,7 +38,7 @@ use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class UserResource extends Resource
class SubuserResource extends Resource
{
use BlockAccessInConflict;
use CanCustomizePages;
@@ -46,14 +46,12 @@ class UserResource extends Resource
use CanModifyTable;
use HasLimitBadge;
protected static ?string $model = User::class;
protected static ?string $model = Subuser::class;
protected static ?int $navigationSort = 5;
protected static string|\BackedEnum|null $navigationIcon = 'tabler-users';
protected static ?string $tenantOwnershipRelationshipName = 'subServers';
protected static function getBadgeCount(): int
{
/** @var Server $server */
@@ -70,7 +68,11 @@ class UserResource extends Resource
$tabs = [];
$permissionsArray = [];
foreach (Permission::permissionData() as $data) {
foreach (Subuser::allPermissionData() as $data) {
if ($data['hidden']) {
continue;
}
$options = [];
$descriptions = [];
@@ -86,6 +88,7 @@ class UserResource extends Resource
Section::make()
->description(trans('server/user.permissions.' . $data['name'] . '_desc'))
->icon($data['icon'])
->contained(false)
->schema([
CheckboxList::make($data['name'])
->hiddenLabel()
@@ -104,24 +107,26 @@ class UserResource extends Resource
->visibleFrom('lg')
->label('')
->alignCenter()->circular()
->defaultImageUrl(fn (User $user) => Filament::getUserAvatarUrl($user)),
TextColumn::make('username')
->defaultImageUrl(fn (Subuser $subuser) => Filament::getUserAvatarUrl($subuser->user)),
TextColumn::make('user.username')
->label(trans('server/user.username'))
->searchable(),
TextColumn::make('email')
TextColumn::make('user.email')
->label(trans('server/user.email'))
->searchable(),
TextColumn::make('permissions')
TextColumn::make('permissions_count')
->label(trans('server/user.permissions.title'))
->state(fn (User $user) => count($server->subusers->where('user_id', $user->id)->first()->permissions)),
->state(fn (Subuser $subuser) => collect($subuser->permissions)
->reject(fn (string $permission) => SubuserPermission::tryFrom($permission)?->isHidden() ?? false)
->count()
),
])
->recordActions([
DeleteAction::make()
->label(trans('server/user.delete'))
->hidden(fn (User $user) => user()?->id === $user->id)
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
->successNotificationTitle(null)
->action(function (User $user, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuser = $server->subusers->where('user_id', $user->id)->first();
->action(function (Subuser $subuser, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuserDeletionService->handle($subuser, $server);
Notification::make()
@@ -131,17 +136,15 @@ class UserResource extends Resource
}),
EditAction::make()
->label(trans('server/user.edit'))
->hidden(fn (User $user) => user()?->id === $user->id)
->authorize(fn () => user()?->can(Permission::ACTION_USER_UPDATE, $server))
->modalHeading(fn (User $user) => trans('server/user.editing', ['user' => $user->email]))
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
->authorize(fn () => user()?->can(SubuserPermission::UserUpdate, $server))
->modalHeading(fn (Subuser $subuser) => trans('server/user.editing', ['user' => $subuser->user->email]))
->successNotificationTitle(null)
->action(function (array $data, SubuserUpdateService $subuserUpdateService, User $user) use ($server) {
$subuser = $server->subusers->where('user_id', $user->id)->first();
->action(function (array $data, SubuserUpdateService $subuserUpdateService, Subuser $subuser) use ($server) {
$permissions = collect($data)
->forget('email')
->flatMap(fn ($permissions, $key) => collect($permissions)->map(fn ($permission) => "$key.$permission"))
->push(Permission::ACTION_WEBSOCKET_CONNECT)
->push(SubuserPermission::WebsocketConnect->value)
->unique()
->all();
@@ -172,7 +175,8 @@ class UserResource extends Resource
'sm' => 1,
'md' => 4,
'lg' => 5,
]),
])
->formatStateUsing(fn (Subuser $subuser) => $subuser->user->email),
Actions::make([
Action::make('assignAll')
->label(trans('server/user.assign_all'))
@@ -195,12 +199,10 @@ class UserResource extends Resource
->schema($tabs),
]),
])
->mutateRecordDataUsing(function ($data, User $user) use ($server) {
$permissionsArray = $server->subusers->where('user_id', $user->id)->first()->permissions;
->mutateRecordDataUsing(function ($data, Subuser $subuser) {
$transformedPermissions = [];
foreach ($permissionsArray as $permission) {
foreach ($subuser->permissions as $permission) {
[$group, $action] = explode('.', $permission, 2);
$transformedPermissions[$group][] = $action;
}
@@ -218,7 +220,7 @@ class UserResource extends Resource
->icon('tabler-user-plus')
->tooltip(trans('server/user.invite_user'))
->createAnother(false)
->authorize(fn () => user()?->can(Permission::ACTION_USER_CREATE, $server))
->authorize(fn () => user()?->can(SubuserPermission::UserCreate, $server))
->schema([
Grid::make()
->columnSpanFull()
@@ -272,7 +274,7 @@ class UserResource extends Resource
$permissions = collect($data)
->forget('email')
->flatMap(fn ($permissions, $key) => collect($permissions)->map(fn ($permission) => "$key.$permission"))
->push(Permission::ACTION_WEBSOCKET_CONNECT)
->push(SubuserPermission::WebsocketConnect->value)
->unique()
->all();
@@ -312,7 +314,7 @@ class UserResource extends Resource
public static function getDefaultPages(): array
{
return [
'index' => ListUsers::route('/'),
'index' => ListSubusers::route('/'),
];
}

View File

@@ -2,9 +2,9 @@
namespace App\Filament\Server\Widgets;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Livewire\AlertBanner;
use App\Models\Permission;
use App\Models\Server;
use App\Models\User;
use App\Services\Nodes\NodeJWTService;
@@ -46,7 +46,7 @@ class ServerConsole extends Widget
protected function getToken(): string
{
if (!$this->user || !$this->server || $this->user->cannot(Permission::ACTION_WEBSOCKET_CONNECT, $this->server)) {
if (!$this->user || !$this->server || $this->user->cannot(SubuserPermission::WebsocketConnect, $this->server)) {
throw new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.');
}
@@ -72,7 +72,7 @@ class ServerConsole extends Widget
protected function authorizeSendCommand(): bool
{
return $this->user->can(Permission::ACTION_CONTROL_CONSOLE, $this->server);
return $this->user->can(SubuserPermission::ControlConsole, $this->server);
}
protected function canSendCommand(): bool

View File

@@ -7,15 +7,22 @@ use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Eggs\ExportEggRequest;
use App\Http\Requests\Api\Application\Eggs\GetEggRequest;
use App\Http\Requests\Api\Application\Eggs\GetEggsRequest;
use App\Http\Requests\Api\Application\Eggs\ImportEggRequest;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use App\Transformers\Api\Application\EggTransformer;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
class EggController extends ApplicationApiController
{
public function __construct(
private EggExporterService $exporterService,
private EggImporterService $importService
) {
parent::__construct();
}
@@ -48,6 +55,20 @@ class EggController extends ApplicationApiController
->toArray();
}
/**
* Delete egg
*
* Delete an egg from the Panel.
*
* @throws Exception
*/
public function delete(GetEggRequest $request, Egg $egg): Response
{
$egg->delete();
return $this->returnNoContent();
}
/**
* Export egg
*
@@ -63,4 +84,22 @@ class EggController extends ApplicationApiController
'Content-Type' => 'application/' . $format->value,
]);
}
/**
* Import egg
*
* Create a new egg on the Panel. Returns the created egg and an HTTP/201 status response on success
* If no uuid is supplied a new one will be generated
* If an uuid is supplied, and it already exists the old configuration get overwritten
*
* @throws Exception|Throwable
*/
public function import(ImportEggRequest $request): JsonResponse
{
$egg = $this->importService->fromContent($request->getContent());
return $this->fractal->item($egg)
->transformWith($this->getTransformer(EggTransformer::class))
->respond(201);
}
}

View File

@@ -4,13 +4,12 @@ namespace App\Http\Controllers\Api\Client;
use App\Http\Requests\Api\Client\GetServersRequest;
use App\Models\Filters\MultiFieldServerFilter;
use App\Models\Permission;
use App\Models\Server;
use App\Models\Subuser;
use App\Transformers\Api\Client\ServerTransformer;
use Dedoc\Scramble\Attributes\Group;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\QueryBuilder;
@@ -81,14 +80,14 @@ class ClientController extends ClientApiController
*
* Returns all the subuser permissions available on the system.
*
* @return array{object: string, attributes: array{permissions: Collection}}
* @return array{object: string, attributes: array{permissions: string[]}}
*/
public function permissions(): array
{
return [
'object' => 'system_permissions',
'attributes' => [
'permissions' => Permission::permissions(),
'permissions' => Subuser::allPermissionKeys(),
],
];
}

View File

@@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\SubuserPermission;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\ActivityLog;
use App\Models\Permission;
use App\Models\Role;
use App\Models\Server;
use App\Models\User;
@@ -29,7 +29,7 @@ class ActivityLogController extends ClientApiController
*/
public function __invoke(ClientApiRequest $request, Server $server): array
{
Gate::authorize(Permission::ACTION_ACTIVITY_READ, $server);
Gate::authorize(SubuserPermission::ActivityRead, $server);
$activity = QueryBuilder::for($server->activity())
->allowedSorts(['timestamp'])

View File

@@ -3,13 +3,13 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\ServerState;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Backups\RenameBackupRequest;
use App\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest;
use App\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
use App\Models\Backup;
use App\Models\Permission;
use App\Models\Server;
use App\Repositories\Daemon\DaemonBackupRepository;
use App\Services\Backups\DeleteBackupService;
@@ -48,7 +48,7 @@ class BackupController extends ClientApiController
*/
public function index(Request $request, Server $server): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
if (!$request->user()->can(SubuserPermission::BackupRead, $server)) {
throw new AuthorizationException();
}
@@ -82,16 +82,22 @@ class BackupController extends ClientApiController
// otherwise ignore this status. This gets a little funky since it isn't clear
// 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(Permission::ACTION_BACKUP_DELETE, $server)) {
$action->setIsLocked((bool) $request->input('is_locked'));
if ($request->user()->can(SubuserPermission::BackupDelete, $server)) {
$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))
@@ -110,7 +116,7 @@ class BackupController extends ClientApiController
*/
public function toggleLock(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
if (!$request->user()->can(SubuserPermission::BackupDelete, $server)) {
throw new AuthorizationException();
}
@@ -136,7 +142,7 @@ class BackupController extends ClientApiController
*/
public function view(Request $request, Server $server, Backup $backup): array
{
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
if (!$request->user()->can(SubuserPermission::BackupRead, $server)) {
throw new AuthorizationException();
}
@@ -155,7 +161,7 @@ class BackupController extends ClientApiController
*/
public function delete(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
if (!$request->user()->can(SubuserPermission::BackupDelete, $server)) {
throw new AuthorizationException();
}
@@ -181,7 +187,7 @@ class BackupController extends ClientApiController
*/
public function download(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) {
if (!$request->user()->can(SubuserPermission::BackupDownload, $server)) {
throw new AuthorizationException();
}

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

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Exceptions\Model\DataValidationException;
use App\Exceptions\Service\ServiceLimitExceededException;
@@ -9,7 +10,6 @@ use App\Facades\Activity;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Http\Requests\Api\Client\Servers\Schedules\StoreTaskRequest;
use App\Models\Permission;
use App\Models\Schedule;
use App\Models\Server;
use App\Models\Task;
@@ -170,7 +170,7 @@ class ScheduleTaskController extends ClientApiController
throw new NotFoundHttpException();
}
if (!$request->user()->can(Permission::ACTION_SCHEDULE_DELETE, $server)) {
if (!$request->user()->can(SubuserPermission::ScheduleDelete, $server)) {
throw new HttpForbiddenException('You do not have permission to perform this action.');
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\SubuserPermission;
use App\Exceptions\Model\DataValidationException;
use App\Exceptions\Service\Subuser\ServerSubuserExistsException;
use App\Exceptions\Service\Subuser\UserIsServerOwnerException;
@@ -11,7 +12,6 @@ use App\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest;
use App\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest;
use App\Http\Requests\Api\Client\Servers\Subusers\StoreSubuserRequest;
use App\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest;
use App\Models\Permission;
use App\Models\Server;
use App\Models\Subuser;
use App\Models\User;
@@ -82,18 +82,17 @@ class SubuserController extends ClientApiController
*/
public function store(StoreSubuserRequest $request, Server $server): array
{
$response = $this->creationService->handle(
$server,
$request->input('email'),
$this->getDefaultPermissions($request)
);
$email = $request->input('email');
$permissions = $this->getCleanedPermissions($request);
$subuser = $this->creationService->handle($server, $email, $permissions);
Activity::event('server:subuser.create')
->subject($response->user)
->property(['email' => $request->input('email'), 'permissions' => $this->getDefaultPermissions($request)])
->subject($subuser->user)
->property(['email' => $email, 'permissions' => $subuser->permissions])
->log();
return $this->fractal->item($response)
return $this->fractal->item($subuser)
->transformWith($this->getTransformer(SubuserTransformer::class))
->toArray();
}
@@ -112,7 +111,7 @@ class SubuserController extends ClientApiController
/** @var Subuser $subuser */
$subuser = $request->attributes->get('subuser');
$this->updateService->handle($subuser, $server, $this->getDefaultPermissions($request));
$this->updateService->handle($subuser, $server, $this->getCleanedPermissions($request));
return $this->fractal->item($subuser->refresh())
->transformWith($this->getTransformer(SubuserTransformer::class))
@@ -135,17 +134,19 @@ class SubuserController extends ClientApiController
}
/**
* Returns the default permissions for subusers and parses out any permissions
* Returns the "cleaned" permissions for subusers and parses out any permissions
* that were passed that do not also exist in the internally tracked list of
* permissions.
*
* @return array<array-key, mixed>
* @return string[]
*/
protected function getDefaultPermissions(Request $request): array
protected function getCleanedPermissions(Request $request): array
{
$allowed = Permission::permissionKeys()->all();
$cleaned = array_intersect($request->input('permissions') ?? [], $allowed);
return array_unique(array_merge($cleaned, [Permission::ACTION_WEBSOCKET_CONNECT]));
return collect($request->input('permissions') ?? [])
->intersect(Subuser::allPermissionKeys())
->push(SubuserPermission::WebsocketConnect->value)
->unique()
->values()
->toArray();
}
}

View File

@@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\Permission;
use App\Models\Server;
use App\Services\Nodes\NodeJWTService;
use App\Services\Servers\GetUserPermissionsService;
@@ -37,7 +37,7 @@ class WebsocketController extends ClientApiController
public function __invoke(ClientApiRequest $request, Server $server): JsonResponse
{
$user = $request->user();
if ($user->cannot(Permission::ACTION_WEBSOCKET_CONNECT, $server)) {
if ($user->cannot(SubuserPermission::WebsocketConnect, $server)) {
throw new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.');
}

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

@@ -2,11 +2,11 @@
namespace App\Http\Controllers\Api\Remote;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Facades\Activity;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\SftpAuthenticationFormRequest;
use App\Models\Permission;
use App\Models\Server;
use App\Models\User;
use App\Services\Servers\GetUserPermissionsService;
@@ -141,7 +141,7 @@ class SftpAuthenticationController extends Controller
if ($user->cannot('update server', $server) && $server->owner_id !== $user->id) {
$permissions = $this->permissions->handle($server, $user);
if (!in_array(Permission::ACTION_FILE_SFTP, $permissions)) {
if (!in_array(SubuserPermission::FileSftp->value, $permissions)) {
Activity::event('server:sftp.denied')->actor($user)->subject($server)->log();
throw new HttpForbiddenException('You do not have permission to access SFTP for this server.');

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();
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Http\Requests\Api\Application\Eggs;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
use App\Models\Egg;
use App\Services\Acl\Api\AdminAcl;
class ImportEggRequest extends ApplicationApiRequest
{
protected ?string $resource = Egg::RESOURCE_NAME;
protected int $permission = AdminAcl::WRITE;
}

View File

@@ -22,6 +22,7 @@ class StoreUserRequest extends ApplicationApiRequest
return collect($rules)->only([
'external_id',
'is_managed_externally',
'email',
'username',
'password',
@@ -39,6 +40,7 @@ class StoreUserRequest extends ApplicationApiRequest
{
return [
'external_id' => 'Third Party Identifier',
'is_managed_externally' => 'Is managed by Third Party?',
];
}
}

View File

@@ -26,7 +26,7 @@ class UpdateEmailRequest extends ClientApiRequest
throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password'));
}
return true;
return !$this->user()->is_managed_externally;
}
public function rules(): array

View File

@@ -25,7 +25,7 @@ class UpdatePasswordRequest extends ClientApiRequest
throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password'));
}
return true;
return !$this->user()->is_managed_externally;
}
public function rules(): array

View File

@@ -26,7 +26,7 @@ class UpdateUsernameRequest extends ClientApiRequest
throw new InvalidPasswordProvidedException(trans('validation.internal.invalid_password'));
}
return true;
return !$this->user()->is_managed_externally;
}
public function rules(): array

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Api\Client\Servers\Backups;
use App\Enums\SubuserPermission;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\Permission;
class RenameBackupRequest extends ClientApiRequest
{
public function permission(): string
public function permission(): SubuserPermission
{
return Permission::ACTION_BACKUP_DELETE;
return SubuserPermission::BackupDelete;
}
public function rules(): array

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Api\Client\Servers\Backups;
use App\Enums\SubuserPermission;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\Permission;
class RestoreBackupRequest extends ClientApiRequest
{
public function permission(): string
public function permission(): SubuserPermission
{
return Permission::ACTION_BACKUP_RESTORE;
return SubuserPermission::BackupRestore;
}
public function rules(): array

View File

@@ -2,14 +2,14 @@
namespace App\Http\Requests\Api\Client\Servers\Backups;
use App\Enums\SubuserPermission;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\Permission;
class StoreBackupRequest extends ClientApiRequest
{
public function permission(): string
public function permission(): SubuserPermission
{
return Permission::ACTION_BACKUP_CREATE;
return SubuserPermission::BackupCreate;
}
public function rules(): array

View File

@@ -3,13 +3,13 @@
namespace App\Http\Requests\Api\Client\Servers\Databases;
use App\Contracts\Http\ClientPermissionsRequest;
use App\Enums\SubuserPermission;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\Permission;
class DeleteDatabaseRequest extends ClientApiRequest implements ClientPermissionsRequest
{
public function permission(): string
public function permission(): SubuserPermission
{
return Permission::ACTION_DATABASE_DELETE;
return SubuserPermission::DatabaseDelete;
}
}

View File

@@ -3,13 +3,13 @@
namespace App\Http\Requests\Api\Client\Servers\Databases;
use App\Contracts\Http\ClientPermissionsRequest;
use App\Enums\SubuserPermission;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\Permission;
class GetDatabasesRequest extends ClientApiRequest implements ClientPermissionsRequest
{
public function permission(): string
public function permission(): SubuserPermission
{
return Permission::ACTION_DATABASE_READ;
return SubuserPermission::DatabaseRead;
}
}

View File

@@ -2,16 +2,16 @@
namespace App\Http\Requests\Api\Client\Servers\Databases;
use App\Enums\SubuserPermission;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Models\Permission;
class RotatePasswordRequest extends ClientApiRequest
{
/**
* Check that the user has permission to rotate the password.
*/
public function permission(): string
public function permission(): SubuserPermission
{
return Permission::ACTION_DATABASE_UPDATE;
return SubuserPermission::DatabaseUpdate;
}
}

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