Compare commits

..

4 Commits

Author SHA1 Message Date
notCharles
90c50da83b wip 2026-05-12 10:06:33 -04:00
notCharles
396d71c4a5 Updates + stan 2026-05-11 06:38:53 -04:00
notCharles
4e56b857d1 pint 2026-05-09 12:49:30 -04:00
notCharles
e22e07aae0 Add CURD 2026-05-09 12:45:46 -04:00
528 changed files with 5507 additions and 14500 deletions

View File

@@ -16,7 +16,7 @@ jobs:
node-version: [22, 24]
steps:
- name: Code Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -30,7 +30,7 @@ jobs:
run: composer install --no-interaction --no-suggest --no-progress --no-autoloader --no-scripts --no-dev
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"

View File

@@ -32,7 +32,7 @@ jobs:
DB_DATABASE: testing.sqlite
steps:
- name: Code Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Get cache directory
id: composer-cache
@@ -40,7 +40,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -89,7 +89,7 @@ jobs:
MYSQL_DATABASE: testing
ports:
- 3306
options: --tmpfs /var/lib/mysql:rw --health-cmd="mysqladmin ping" --health-interval=2s --health-timeout=5s --health-retries=30
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
@@ -97,7 +97,7 @@ jobs:
DB_USERNAME: root
steps:
- name: Code Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Get cache directory
id: composer-cache
@@ -105,7 +105,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -129,6 +129,12 @@ jobs:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel --do-not-fail-on-phpunit-warning
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning
env:
@@ -151,7 +157,7 @@ jobs:
MYSQL_DATABASE: testing
ports:
- 3306
options: --tmpfs /var/lib/mysql:rw --health-cmd="mariadb-admin ping || mysqladmin ping" --health-interval=2s --health-timeout=5s --health-retries=30
options: --health-cmd="mariadb-admin ping || mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
env:
DB_CONNECTION: mariadb
DB_HOST: 127.0.0.1
@@ -159,7 +165,7 @@ jobs:
DB_USERNAME: root
steps:
- name: Code Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Get cache directory
id: composer-cache
@@ -167,7 +173,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -191,6 +197,12 @@ jobs:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel --do-not-fail-on-phpunit-warning
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning
env:
@@ -205,13 +217,6 @@ jobs:
matrix:
php: [8.5]
database: ["postgres:17", "postgres:18"]
include:
# postgres:17 stores data in /var/lib/postgresql/data, 18+ requires a
# single mount at /var/lib/postgresql and errors on a data submount
- database: "postgres:17"
tmpfs: /var/lib/postgresql/data
- database: "postgres:18"
tmpfs: /var/lib/postgresql
services:
database:
image: ${{ matrix.database }}
@@ -223,11 +228,10 @@ jobs:
ports:
- 5432:5432
options: >-
--tmpfs ${{ matrix.tmpfs }}:rw
--health-cmd pg_isready
--health-interval 2s
--health-interval 10s
--health-timeout 5s
--health-retries 30
--health-retries 5
env:
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
@@ -236,7 +240,7 @@ jobs:
DB_PASSWORD: postgres
steps:
- name: Code Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Get cache directory
id: composer-cache
@@ -244,7 +248,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -265,5 +269,11 @@ jobs:
- name: Run Migrations
run: php artisan migrate --force --seed
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel --do-not-fail-on-phpunit-warning
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning

View File

@@ -7,8 +7,6 @@ on:
release:
types:
- published
schedule:
- cron: '0 0 * * 1'
env:
REGISTRY: ghcr.io
@@ -33,13 +31,13 @@ jobs:
platform: linux/arm64
steps:
- name: Code checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
- name: Build the base PHP image
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile.base
@@ -54,7 +52,7 @@ jobs:
run: docker save -o base-php-${{ matrix.arch }}.tar base-php:${{ matrix.arch }}
- name: Push the docker build to the artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: base-php-${{ matrix.arch }}.tar
path: base-php-${{ matrix.arch }}.tar
@@ -77,54 +75,32 @@ jobs:
# Always run against a tag, even if the commit into the tag has [docker skip] within the commit message.
if: "!contains(github.ref, 'main') || (!contains(github.event.head_commit.message, 'skip docker') && !contains(github.event.head_commit.message, 'docker skip'))"
steps:
# Fetch the latest release tag from GitHub API before checkout and metadata so we can check out the correct ref and tag the image
- name: Get latest release tag (scheduled)
id: release_info
if: github.event_name == 'schedule'
run: |
LATEST_TAG=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" -w "\n%{http_code}" https://api.github.com/repos/${{ github.repository }}/releases/latest | tail -1 | head -1)
HTTP_CODE=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" -w "%{http_code}" -o /dev/null https://api.github.com/repos/${{ github.repository }}/releases/latest)
if [ "$HTTP_CODE" != "200" ]; then
echo "Failed to fetch latest release (HTTP $HTTP_CODE)"
exit 1
fi
LATEST_TAG=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r '.tag_name')
if [ "$LATEST_TAG" = "null" ] || [ -z "$LATEST_TAG" ]; then
echo "No release found"
exit 1
fi
echo "version_tag=${LATEST_TAG#v}" >> $GITHUB_OUTPUT
echo "schedule_tag=$LATEST_TAG" >> $GITHUB_OUTPUT
- name: Code checkout
uses: actions/checkout@v6
with:
ref: ${{ steps.release_info.outputs.schedule_tag || github.ref }}
uses: actions/checkout@v4
- name: Docker metadata
id: docker_meta
uses: docker/metadata-action@v6
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ (github.event_name == 'release' && github.event.action == 'published' && github.event.release.prerelease == false) || github.event_name == 'schedule' }}
type=raw,value=${{ steps.release_info.outputs.version_tag }},enable=${{ github.event_name == 'schedule' }}
type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.action == 'published' && github.event.release.prerelease == false }}
type=ref,event=tag
type=ref,event=branch
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
# We Need to start it in host mode else it can't acces the local registry on port 5000
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -133,16 +109,16 @@ jobs:
- name: Get Build Information
id: build_info
run: |
echo "version_tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
echo "version_tag=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
# Download the base PHP image AMD64
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v4
with:
name: base-php-amd64.tar
# Download the base PHP image ARM64
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v4
with:
name: base-php-arm64.tar
@@ -157,32 +133,27 @@ jobs:
rm base-php-arm64.tar base-php-amd64.tar
- name: Update version in config/app.php (tag)
if: "(github.event_name == 'release' && github.event.action == 'published') || github.event_name == 'schedule'"
if: "github.event_name == 'release' && github.event.action == 'published'"
run: |
VERSION="${{ github.event_name == 'schedule' && steps.release_info.outputs.version_tag || steps.build_info.outputs.version_tag }}"
if [ -z "$VERSION" ] || [ "$VERSION" = "refs/heads/main" ]; then
echo "Invalid version tag, refusing to update config"
exit 1
fi
sed -i "s|'version' => 'canary',|'version' => '$VERSION',|" config/app.php
sed -i "s/'version' => 'canary',/'version' => '${{ steps.build_info.outputs.version_tag }}',/" config/app.php
- name: Build and Push (tag)
uses: docker/build-push-action@v7
if: "(github.event_name == 'release' && github.event.action == 'published') || github.event_name == 'schedule'"
uses: docker/build-push-action@v6
if: "github.event_name == 'release' && github.event.action == 'published'"
with:
context: .
file: ./Dockerfile
push: true
platforms: 'linux/amd64,linux/arm64'
build-args: |
VERSION=${{ github.event_name == 'schedule' && steps.release_info.outputs.version_tag || steps.build_info.outputs.version_tag }}
VERSION=${{ steps.build_info.outputs.version_tag }}
labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=gha,scope=tagged${{ matrix.os }}
cache-to: type=gha,scope=tagged${{ matrix.os }},mode=max
- name: Build and Push (main)
uses: docker/build-push-action@v7
uses: docker/build-push-action@v6
if: "github.event_name == 'push' && contains(github.ref, 'main')"
with:
context: .

View File

@@ -1,54 +0,0 @@
name: Download Crowdin Translations
on:
schedule:
- cron: '0 0 * * 0' # Every Sunday at midnight
workflow_dispatch:
jobs:
download-translations:
name: Download Translations from Crowdin
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Download Translations from Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
export_only_approved: true
push_translations: false
create_pull_request: false
crowdin_branch_name: main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Run Laravel Pint
uses: aglipanci/laravel-pint-action@8eedec46a1856977c41667f9c66d5562fa3f9c08 # 2.4
# The Crowdin and Pint steps run in Docker as root, so new translation
# files land owned by root. Reclaim them so the host-side git operations
# in create-pull-request can manage the working tree.
- name: Fix file ownership
run: sudo chown -R "$(id -u):$(id -g)" "${{ github.workspace }}"
- name: Create Pull Request
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: 'chore: update translations and format with pint'
title: 'New Crowdin Translations'
body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action), formatted by Laravel Pint.'
branch: 'i18n_crowdin_translations'
base: 'main'

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Code Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -38,7 +38,7 @@ jobs:
php: [8.3, 8.4, 8.5]
steps:
- name: Code Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Get cache directory
id: composer-cache
@@ -46,7 +46,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Code checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
@@ -28,7 +28,7 @@ jobs:
run: composer install --no-interaction --no-suggest --no-progress --no-autoloader --no-scripts --no-dev
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v4
with:
node-version: 22
cache: "yarn"

View File

@@ -1,32 +0,0 @@
name: Upload Crowdin Sources
on:
push:
branches: [ main ]
paths:
- 'lang/en/**'
- 'crowdin.yml'
workflow_dispatch:
jobs:
upload-sources:
name: Upload Sources to Crowdin
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Upload Sources to Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: true
upload_translations: false
download_translations: false
crowdin_branch_name: main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -22,7 +22,7 @@ RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts
# ================================
# Stage 1-2: Yarn Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH node:22-alpine AS yarn
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
WORKDIR /build
@@ -74,14 +74,16 @@ COPY --chown=root:www-data --chmod=770 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=770 --from=yarnbuild /build/public ./public
# Create and remove directories
RUN mkdir -p /pelican-data/storage /var/run/supervisord \
# Symlinks for env, database, storage
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 ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& 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/ /var/www/html/composer.json /var/www/html/composer.lock
# Configure Supervisor

View File

@@ -1,7 +1,7 @@
# ================================
# Stage 0: Build PHP Base Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH php:8.5-fpm-alpine
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/

View File

@@ -1,7 +1,7 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Development Dockerfile
FROM --platform=$TARGETOS/$TARGETARCH php:8.5-fpm-alpine AS base
FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine AS base
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
@@ -26,7 +26,7 @@ RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts
# ================================
# Stage 1-2: Yarn Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH node:22-alpine AS yarn
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
WORKDIR /build
@@ -79,14 +79,16 @@ COPY --chown=root:www-data --chmod=770 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=770 --from=yarnbuild /build/public ./public
# Create and remove directories
RUN mkdir -p /pelican-data/storage /var/run/supervisord \
# Symlinks for env, database, storage
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 ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& 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/ /var/www/html/composer.json /var/www/html/composer.lock

View File

@@ -2,8 +2,8 @@
namespace App\Checks;
use Carbon\Carbon;
use Composer\InstalledVersions;
use Illuminate\Support\Carbon;
use Spatie\Health\Checks\Checks\ScheduleCheck as BaseCheck;
use Spatie\Health\Checks\Result;

View File

@@ -35,11 +35,8 @@ class CheckEggUpdatesCommand extends Command
return;
}
$extension = strtolower(pathinfo(parse_url($egg->update_url, PHP_URL_PATH), PATHINFO_EXTENSION));
throw_if(empty($extension), new Exception('Unsupported file format.'));
$isYaml = in_array($extension, ['yaml', 'yml']);
$ext = strtolower(pathinfo(parse_url($egg->update_url, PHP_URL_PATH), PATHINFO_EXTENSION));
$isYaml = in_array($ext, ['yaml', 'yml']);
$local = $isYaml
? Yaml::parse($exporterService->handle($egg->id, EggFormat::YAML))
@@ -47,7 +44,9 @@ class CheckEggUpdatesCommand extends Command
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url);
throw_if($remote->failed(), new Exception("HTTP request returned status code {$remote->status()}"));
if ($remote->failed()) {
throw new Exception("HTTP request returned status code {$remote->status()}");
}
$remote = $remote->body();
$remote = $isYaml ? Yaml::parse($remote) : json_decode($remote, true);

View File

@@ -1,152 +0,0 @@
<?php
namespace App\Console\Commands\Egg;
use App\Enums\EggFormat;
use App\Exceptions\Service\InvalidFileUploadException;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Symfony\Component\Yaml\Yaml;
class NormalizeEggCommand extends Command
{
protected $description = 'Upgrades egg json/yaml to latest format as yaml';
protected $signature = 'p:egg:normalize {file} {--delete-original}';
public function __construct(
private EggExporterService $exporter,
) {
parent::__construct();
}
public function handle(EggImporterService $importer): int
{
$inputFile = $this->argument('file');
$unparsed = file_get_contents($inputFile);
if ($unparsed === false) {
$this->error("Failed to read file: {$inputFile}");
return Command::FAILURE;
}
$extension = strtolower(pathinfo($inputFile, PATHINFO_EXTENSION));
$this->info("Importing {$inputFile}");
$format = match ($extension) {
'yaml', 'yml' => EggFormat::YAML,
'json' => EggFormat::JSON,
default => null,
};
if (is_null($format)) {
$this->error(" -> unsupported extension {$extension} for {$inputFile}");
return Command::FAILURE;
}
try {
$eggArray = $importer->parse($unparsed, $format);
} catch (InvalidFileUploadException) {
$this->error(" -> unsupported file version, is it actually an egg? ({$inputFile})");
return Command::FAILURE;
}
if (
!array_key_exists('meta', $eggArray) ||
!is_array($eggArray['meta'])
) {
$this->error(" -> does not contain existing meta or meta is not array, is it actually an egg? ({$inputFile})");
return Command::FAILURE;
}
if (!array_key_exists('exported_at', $eggArray)) {
$this->error(" -> does not contain existing exported_at, is it actually an egg? ({$inputFile})");
return Command::FAILURE;
}
if (
!array_key_exists('update_url', $eggArray['meta']) ||
!is_string($eggArray['meta']['update_url'])
) {
$this->error(" -> does not contain existing meta.update_url or is not a string, is it actually an egg? ({$inputFile})");
return Command::FAILURE;
}
// We upgraded our in-memory array when we imported, so set to latest version before export
$eggArray['meta']['version'] = Egg::EXPORT_VERSION;
$eggArray['meta']['update_url'] = self::replaceExtension($eggArray['meta']['update_url']);
self::fixVariableRules($eggArray);
$outputFile = self::replaceExtension($inputFile);
if ($outputFile === $inputFile && !$this->hasFileChanged($unparsed, $eggArray)) {
$this->info(' -> no changes required');
return Command::SUCCESS;
} else {
$eggArray['exported_at'] = Carbon::now()->toAtomString();
}
$this->info(" -> exporting to {$outputFile}");
$yaml = $this->eggToYaml($eggArray);
if (file_put_contents($outputFile, $yaml) === false) {
$this->error(" -> failed to write output file: {$outputFile}");
return Command::FAILURE;
}
if ($this->option('delete-original') && $outputFile !== $inputFile) {
$this->info(' -> deleting input file as requested');
if (!unlink($inputFile)) {
$this->warn(" -> failed to delete original file: {$inputFile}");
}
}
return Command::SUCCESS;
}
private static function replaceExtension(string $path): string
{
return preg_replace('/^(.*\.)(?:yml|json|yaml)$/', '$1yaml', $path) ?? $path;
}
/**
* @param array<string, mixed> $eggArray
*/
private static function fixVariableRules(array &$eggArray): void
{
if (!array_key_exists('variables', $eggArray)) {
return;
}
foreach ($eggArray['variables'] as &$var) {
unset($var['field_type']);
if (!array_key_exists('rules', $var)) {
continue;
}
$var['rules'] = is_array($var['rules']) ? $var['rules'] : explode('|', $var['rules']);
}
}
/**
* @param array<string, mixed> $eggArray
*/
private function hasFileChanged(string $unparsed, array $eggArray): bool
{
$yaml = $this->eggToYaml($eggArray);
return $unparsed !== $yaml;
}
/**
* @param array<string, mixed> $eggArray
*/
private function eggToYaml(array $eggArray): string
{
return Yaml::dump($this->exporter->yamlExport($eggArray), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK | Yaml::DUMP_OBJECT_AS_MAP);
}
}

View File

@@ -24,7 +24,6 @@ class DatabaseSettingsCommand extends Command
protected $signature = 'p:environment:database
{--driver= : The database driver backend to use.}
{--database= : The database to use.}
{--schema= : The schema to use for the PostgreSQL database.}
{--host= : The connection address for the MySQL/ MariaDB/ PostgreSQL server.}
{--port= : The connection port for the MySQL/ MariaDB/ PostgreSQL server.}
{--username= : Username to use when connecting to the MySQL/ MariaDB/ PostgreSQL server.}
@@ -197,11 +196,6 @@ class DatabaseSettingsCommand extends Command
config('database.connections.pgsql.database', 'panel')
);
$this->variables['DB_SCHEMA'] = $this->option('schema') ?? $this->ask(
'Database Schema',
config('database.connections.pgsql.search_path', 'public')
);
$this->output->note(trans('commands.database_settings.DB_USERNAME_note'));
$this->variables['DB_USERNAME'] = $this->option('username') ?? $this->ask(
'Database Username',
@@ -230,7 +224,7 @@ class DatabaseSettingsCommand extends Command
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => $this->variables['DB_SCHEMA'],
'search_path' => 'public',
'sslmode' => 'prefer',
]);

View File

@@ -2,10 +2,10 @@
namespace App\Console\Commands\Maintenance;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Contracts\Filesystem\Factory as FilesystemFactory;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Carbon;
use SplFileInfo;
class CleanServiceBackupFilesCommand extends Command

View File

@@ -16,7 +16,9 @@ class PruneOrphanedBackupsCommand extends Command
public function handle(): void
{
$since = $this->option('prune-age') ?? config('backups.prune_age', 360);
throw_if(!$since || !is_digit($since), new InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.'));
if (!$since || !is_digit($since)) {
throw new InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.');
}
$query = Backup::query()
->whereNull('completed_at')

View File

@@ -6,11 +6,6 @@ use Filament\Schemas\Components\Component;
interface HasPluginSettings
{
/**
* @return array<string, mixed>
*/
public function getSettingsFormData(): array;
/**
* @return Component[]
*/

View File

@@ -3,9 +3,10 @@
namespace App\Eloquent;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* @template TModel of \Illuminate\Database\Eloquent\Model
* @template TModel of Model
*
* @extends Builder<TModel>
*/

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Enums;
enum NodeJwtScope: string
{
case BackupDownload = 'backup-download';
case FileDownload = 'file-download';
case FileUpload = 'file-upload';
case ServerTransfer = 'transfer';
case Websocket = 'websocket';
}

View File

@@ -43,12 +43,12 @@ enum ResourceLimit: string
public function limit(): Limit
{
return match ($this) {
self::Websocket => Limit::perMinutes(config('http.rate_limit.websocket_period'), config('http.rate_limit.websocket')),
self::BackupRestore => Limit::perMinutes(config('http.rate_limit.backup_restore_period'), config('http.rate_limit.backup_restore')),
self::DatabaseCreate => Limit::perMinutes(config('http.rate_limit.database_create_period'), config('http.rate_limit.database_create')),
self::SubuserCreate => Limit::perMinutes(config('http.rate_limit.subuser_create_period'), config('http.rate_limit.subuser_create')),
self::FilePull => Limit::perMinutes(config('http.rate_limit.file_pull_period'), config('http.rate_limit.file_pull')),
default => Limit::perMinutes(config('http.rate_limit.default_period'), config('http.rate_limit.default')),
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),
};
}

View File

@@ -6,7 +6,6 @@ enum RolePermissionModels: string
{
case ApiKey = 'apiKey';
case Allocation = 'allocation';
case BackupHost = 'backupHost';
case DatabaseHost = 'databaseHost';
case Database = 'database';
case Egg = 'egg';

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Enums;
enum WebhookScope: string
{
case Global = 'global';
case Server = 'server';
public function getLabel(): string
{
return match ($this) {
self::Global => 'Global',
self::Server => 'Server',
};
}
}

View File

@@ -11,6 +11,6 @@ class FileSizeTooLargeException extends DisplayException
*/
public function __construct()
{
parent::__construct(trans('exceptions.server.file_too_large'));
parent::__construct('The file you are attempting to open is too large to view in the file editor.');
}
}

View File

@@ -15,17 +15,17 @@ class ServerStateConflictException extends ConflictHttpException
*/
public function __construct(Server $server, ?Throwable $previous = null)
{
$message = trans('exceptions.server.state_conflict');
$message = 'This server is currently in an unsupported state, please try again later.';
if ($server->isSuspended()) {
$message = trans('exceptions.server.suspended');
$message = 'This server is currently suspended and the functionality requested is unavailable.';
} elseif ($server->node->isUnderMaintenance()) {
$message = trans('exceptions.server.maintenance');
$message = 'The node of this server is currently under maintenance and the functionality requested is unavailable.';
} elseif (!$server->isInstalled()) {
$message = trans('exceptions.server.marked_as_failed');
$message = 'This server has not yet completed its installation process, please try again later.';
} elseif ($server->status === ServerState::RestoringBackup) {
$message = trans('exceptions.server.restoring_backup');
$message = 'This server is currently restoring from a backup, please try again later.';
} elseif (!is_null($server->transfer)) {
$message = trans('exceptions.server.transferring');
$message = 'This server is currently being transferred to a new machine, please try again later.';
}
parent::__construct($message, $previous);

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter;
use App\Models\Backup;
use App\Models\User;
use Filament\Schemas\Components\Component;
interface BackupAdapterSchemaInterface
{
public function getId(): string;
public function getName(): string;
public function createBackup(Backup $backup): void;
public function deleteBackup(Backup $backup): void;
public function getDownloadLink(Backup $backup, User $user): string;
/** @return Component[] */
public function getConfigurationForm(): array;
}

View File

@@ -1,35 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter;
class BackupAdapterService
{
/** @var array<string, BackupAdapterSchemaInterface> */
private array $schemas = [];
/** @return BackupAdapterSchemaInterface[] */
public function getAll(): array
{
return $this->schemas;
}
public function get(string $id): ?BackupAdapterSchemaInterface
{
return array_get($this->schemas, $id);
}
public function register(BackupAdapterSchemaInterface $schema): void
{
if (array_key_exists($schema->getId(), $this->schemas)) {
return;
}
$this->schemas[$schema->getId()] = $schema;
}
/** @return array<string, string> */
public function getMappings(): array
{
return collect($this->schemas)->mapWithKeys(fn ($schema) => [$schema->getId() => $schema->getName()])->all();
}
}

View File

@@ -1,14 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter\Schemas;
use App\Extensions\BackupAdapter\BackupAdapterSchemaInterface;
use Illuminate\Support\Str;
abstract class BackupAdapterSchema implements BackupAdapterSchemaInterface
{
public function getName(): string
{
return Str::title($this->getId());
}
}

View File

@@ -1,207 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter\Schemas;
use App\Enums\TablerIcon;
use App\Http\Controllers\Api\Remote\Backups\BackupRemoteUploadController;
use App\Models\Backup;
use App\Models\BackupHost;
use App\Models\User;
use App\Repositories\Daemon\DaemonBackupRepository;
use Aws\S3\S3Client;
use Carbon\CarbonImmutable;
use Exception;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\StateCasts\BooleanStateCast;
use Illuminate\Support\Arr;
final class S3BackupSchema extends BackupAdapterSchema
{
public function __construct(private readonly DaemonBackupRepository $repository) {}
private function createClient(BackupHost $backupHost): S3Client
{
$config = $backupHost->configuration;
$config['version'] = 'latest';
if (!empty($config['key']) && !empty($config['secret'])) {
$config['credentials'] = Arr::only($config, ['key', 'secret', 'token']);
}
return new S3Client($config);
}
public function getId(): string
{
return 's3';
}
public function createBackup(Backup $backup): void
{
$this->repository->setServer($backup->server)->create($backup);
}
public function deleteBackup(Backup $backup): void
{
$client = $this->createClient($backup->backupHost);
$client->deleteObject([
'Bucket' => $backup->backupHost->configuration['bucket'],
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
]);
}
public function getDownloadLink(Backup $backup, User $user): string
{
$client = $this->createClient($backup->backupHost);
$request = $client->createPresignedRequest(
$client->getCommand('GetObject', [
'Bucket' => $backup->backupHost->configuration['bucket'],
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
'ContentType' => 'application/x-gzip',
]),
CarbonImmutable::now()->addMinutes(5)
);
return $request->getUri()->__toString();
}
/** @return Component[] */
public function getConfigurationForm(): array
{
return [
TextInput::make('configuration.region')
->label(trans('admin/setting.backup.s3.default_region'))
->required(),
TextInput::make('configuration.key')
->label(trans('admin/setting.backup.s3.access_key'))
->required(),
TextInput::make('configuration.secret')
->label(trans('admin/setting.backup.s3.secret_key'))
->required(),
TextInput::make('configuration.bucket')
->label(trans('admin/setting.backup.s3.bucket'))
->required(),
TextInput::make('configuration.endpoint')
->label(trans('admin/setting.backup.s3.endpoint'))
->required(),
Toggle::make('configuration.use_path_style_endpoint')
->label(trans('admin/setting.backup.s3.use_path_style_endpoint'))
->inline(false)
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->stateCast(new BooleanStateCast(false)),
];
}
/** @return array{parts: string[], part_size: int} */
public function getUploadParts(Backup $backup, int $size): array
{
$expires = CarbonImmutable::now()->addMinutes(config('backups.presigned_url_lifespan', 60));
// Params for generating the presigned urls
$params = [
'Bucket' => $backup->backupHost->configuration['bucket'],
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
'ContentType' => 'application/x-gzip',
];
$storageClass = $backup->backupHost->configuration['storage_class'];
if (!is_null($storageClass)) {
$params['StorageClass'] = $storageClass;
}
$client = $this->createClient($backup->backupHost);
// Execute the CreateMultipartUpload request
$result = $client->execute($client->getCommand('CreateMultipartUpload', $params));
// Get the UploadId from the CreateMultipartUpload request, this is needed to create
// the other presigned urls.
$params['UploadId'] = $result->get('UploadId');
// Retrieve configured part size
$maxPartSize = config('backups.max_part_size', BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE);
if ($maxPartSize <= 0) {
$maxPartSize = BackupRemoteUploadController::DEFAULT_MAX_PART_SIZE;
}
// Create as many UploadPart presigned urls as needed
$parts = [];
for ($i = 0; $i < ($size / $maxPartSize); $i++) {
$parts[] = $client->createPresignedRequest(
$client->getCommand('UploadPart', array_merge($params, ['PartNumber' => $i + 1])),
$expires
)->getUri()->__toString();
}
// Set the upload_id on the backup in the database.
$backup->update(['upload_id' => $params['UploadId']]);
return [
'parts' => $parts,
'part_size' => $maxPartSize,
];
}
/**
* Marks a multipart upload in a given S3-compatible instance as failed or successful for the given backup.
*
* @param ?array<array{int, etag: string, part_number: string}> $parts
*
* @throws Exception
*/
public function completeMultipartUpload(Backup $backup, bool $successful, ?array $parts): void
{
// This should never really happen, but if it does don't let us fall victim to Amazon's
// wildly fun error messaging. Just stop the process right here.
if (empty($backup->upload_id)) {
// A failed backup doesn't need to error here, this can happen if the backup encounters
// an error before we even start the upload. AWS gives you tooling to clear these failed
// multipart uploads as needed too.
if (!$successful) {
return;
}
throw new Exception('Cannot complete backup request: no upload_id present on model.');
}
$params = [
'Bucket' => $backup->backupHost->configuration['bucket'],
'Key' => "{$backup->server->uuid}/$backup->uuid.tar.gz",
'UploadId' => $backup->upload_id,
];
$client = $this->createClient($backup->backupHost);
if (!$successful) {
$client->execute($client->getCommand('AbortMultipartUpload', $params));
return;
}
// Otherwise send a CompleteMultipartUpload request.
$params['MultipartUpload'] = [
'Parts' => [],
];
if (is_null($parts)) {
$params['MultipartUpload']['Parts'] = $client->execute($client->getCommand('ListParts', $params))['Parts'];
} else {
foreach ($parts as $part) {
$params['MultipartUpload']['Parts'][] = [
'ETag' => $part['etag'],
'PartNumber' => $part['part_number'],
];
}
}
$client->execute($client->getCommand('CompleteMultipartUpload', $params));
}
}

View File

@@ -1,66 +0,0 @@
<?php
namespace App\Extensions\BackupAdapter\Schemas;
use App\Enums\NodeJwtScope;
use App\Models\Backup;
use App\Models\User;
use App\Repositories\Daemon\DaemonBackupRepository;
use App\Services\Nodes\NodeJWTService;
use Carbon\CarbonImmutable;
use Exception;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Component;
use Illuminate\Http\Response;
final class WingsBackupSchema extends BackupAdapterSchema
{
public function __construct(private readonly DaemonBackupRepository $repository, private readonly NodeJWTService $jwtService) {}
public function getId(): string
{
return 'wings';
}
public function createBackup(Backup $backup): void
{
$this->repository->setServer($backup->server)->create($backup);
}
/** @throws Exception */
public function deleteBackup(Backup $backup): void
{
try {
$this->repository->setServer($backup->server)->delete($backup);
} catch (Exception $exception) {
// Don't fail the request if the Daemon responds with a 404, just assume the backup
// doesn't actually exist and remove its reference from the Panel as well.
if ($exception->getCode() !== Response::HTTP_NOT_FOUND) {
throw $exception;
}
}
}
public function getDownloadLink(Backup $backup, User $user): string
{
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setScopes(NodeJwtScope::BackupDownload)
->setUser($user)
->setClaims([
'backup_uuid' => $backup->uuid,
'server_uuid' => $backup->server->uuid,
])
->handle($backup->server->node, $user->id . $backup->server->uuid);
return $backup->server->node->getConnectionAddress() . '/download/backup?token=' . $token->toString();
}
/** @return Component[] */
public function getConfigurationForm(): array
{
return [
TextEntry::make(trans('admin/backuphost.no_configuration')),
];
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace App\Extensions\Backups;
use App\Extensions\Filesystem\S3Filesystem;
use Aws\S3\S3Client;
use Closure;
use Illuminate\Foundation\Application;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
use Webmozart\Assert\Assert;
class BackupManager
{
/**
* The array of resolved backup drivers.
*
* @var array<string, FilesystemAdapter>
*/
protected array $adapters = [];
/**
* The registered custom driver creators.
*
* @var array<string, callable>
*/
protected array $customCreators;
public function __construct(protected Application $app) {}
/**
* Returns a backup adapter instance.
*/
public function adapter(?string $name = null): FilesystemAdapter
{
return $this->get($name ?: $this->getDefaultAdapter());
}
/**
* Set the given backup adapter instance.
*/
public function set(string $name, FilesystemAdapter $disk): self
{
$this->adapters[$name] = $disk;
return $this;
}
/**
* Gets a backup adapter.
*/
protected function get(string $name): FilesystemAdapter
{
return $this->adapters[$name] = $this->resolve($name);
}
/**
* Resolve the given backup disk.
*/
protected function resolve(string $name): FilesystemAdapter
{
$config = $this->getConfig($name);
if (empty($config['adapter'])) {
throw new InvalidArgumentException("Backup disk [$name] does not have a configured adapter.");
}
$adapter = $config['adapter'];
if (isset($this->customCreators[$name])) {
return $this->callCustomCreator($config);
}
$adapterMethod = 'create' . Str::studly($adapter) . 'Adapter';
if (method_exists($this, $adapterMethod)) {
$instance = $this->{$adapterMethod}($config);
Assert::isInstanceOf($instance, FilesystemAdapter::class);
return $instance;
}
throw new InvalidArgumentException("Adapter [$adapter] is not supported.");
}
/**
* Calls a custom creator for a given adapter type.
*
* @param array{adapter: string} $config
*/
protected function callCustomCreator(array $config): mixed
{
return $this->customCreators[$config['adapter']]($this->app, $config);
}
/**
* Creates a new daemon adapter.
*
* @param array<string, string> $config
*/
public function createWingsAdapter(array $config): FilesystemAdapter
{
return new InMemoryFilesystemAdapter();
}
/**
* Creates a new S3 adapter.
*
* @param array<string, string> $config
*/
public function createS3Adapter(array $config): FilesystemAdapter
{
$config['version'] = 'latest';
if (!empty($config['key']) && !empty($config['secret'])) {
$config['credentials'] = Arr::only($config, ['key', 'secret', 'token']);
}
$client = new S3Client($config);
return new S3Filesystem($client, $config['bucket'], $config['prefix'] ?? '', $config['options'] ?? []);
}
/**
* Returns the configuration associated with a given backup type.
*
* @return array<mixed>
*/
protected function getConfig(string $name): array
{
return config("backups.disks.$name") ?: [];
}
/**
* Get the default backup driver name.
*/
public function getDefaultAdapter(): string
{
return config('backups.default');
}
/**
* Set the default session driver name.
*/
public function setDefaultAdapter(string $name): void
{
config()->set('backups.default', $name);
}
/**
* Unset the given adapter instances.
*
* @param string|string[] $adapter
*/
public function forget(array|string $adapter): self
{
$adapters = &$this->adapters;
foreach ((array) $adapter as $adapterName) {
unset($adapters[$adapterName]);
}
return $this;
}
/**
* Register a custom adapter creator closure.
*/
public function extend(string $adapter, Closure $callback): self
{
$this->customCreators[$adapter] = $callback;
return $this;
}
}

View File

@@ -21,7 +21,7 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
public function isEnabled(): bool
{
return (bool) env('CAPTCHA_TURNSTILE_ENABLED', false);
return env('CAPTCHA_TURNSTILE_ENABLED', false);
}
public function getFormComponent(): Component
@@ -75,7 +75,9 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
{
$captchaResponse ??= request()->get('cf-turnstile-response');
throw_unless($secret = env('CAPTCHA_TURNSTILE_SECRET_KEY'), new Exception('Turnstile secret key is not defined.'));
if (!$secret = env('CAPTCHA_TURNSTILE_SECRET_KEY')) {
throw new Exception('Turnstile secret key is not defined.');
}
$response = Http::asJson()
->timeout(15)
@@ -99,7 +101,9 @@ class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
};
}
throw_unless($this->verifyDomain($response['hostname'] ?? ''), new Exception('Domain verification failed.'));
if (!$this->verifyDomain($response['hostname'] ?? '')) {
throw new Exception('Domain verification failed.');
}
}
private function verifyDomain(string $hostname): bool

View File

@@ -2,8 +2,6 @@
namespace App\Extensions\Features;
use App\Models\Server;
use App\Models\User;
use Filament\Actions\Action;
interface FeatureSchemaInterface
@@ -13,7 +11,5 @@ interface FeatureSchemaInterface
public function getId(): string;
public function authorize(User $user, Server $server): bool;
public function getAction(): Action;
}

View File

@@ -8,7 +8,6 @@ use App\Extensions\Features\FeatureSchemaInterface;
use App\Facades\Activity;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Models\User;
use App\Repositories\Daemon\DaemonServerRepository;
use Closure;
use Exception;
@@ -38,11 +37,6 @@ class GSLTokenSchema implements FeatureSchemaInterface
return 'gsl_token';
}
public function authorize(User $user, Server $server): bool
{
return $user->can(SubuserPermission::StartupUpdate, $server);
}
/**
* @throws Exception
*/
@@ -58,12 +52,13 @@ class GSLTokenSchema implements FeatureSchemaInterface
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading(trans('server/feature.gsl_token.heading'))
->modalDescription(trans('server/feature.gsl_token.description'))
->modalSubmitActionLabel(trans('server/feature.gsl_token.submit'))
->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(SubuserPermission::StartupUpdate, $server))
->schema([
TextEntry::make('info')
->label(new HtmlString(Blade::render(trans('server/feature.gsl_token.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.'))),
TextInput::make('gsltoken')
->label('GSL Token')
->rules([
@@ -109,13 +104,13 @@ class GSLTokenSchema implements FeatureSchemaInterface
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title(trans('server/feature.gsl_token.updated'))
->body(trans('server/feature.restart_now'))
->title('GSL Token updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title(trans('server/feature.gsl_token.failed'))
->title('Could not update GSL Token')
->body($exception->getMessage())
->danger()
->send();

View File

@@ -6,7 +6,6 @@ use App\Enums\SubuserPermission;
use App\Extensions\Features\FeatureSchemaInterface;
use App\Facades\Activity;
use App\Models\Server;
use App\Models\User;
use App\Repositories\Daemon\DaemonServerRepository;
use Exception;
use Filament\Actions\Action;
@@ -35,11 +34,6 @@ class JavaVersionSchema implements FeatureSchemaInterface
return 'java_version';
}
public function authorize(User $user, Server $server): bool
{
return $user->can(SubuserPermission::StartupDockerImage, $server);
}
public function getAction(): Action
{
/** @var Server $server */
@@ -47,14 +41,15 @@ class JavaVersionSchema implements FeatureSchemaInterface
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading(trans('server/feature.java_version.heading'))
->modalDescription(trans('server/feature.java_version.description'))
->modalSubmitActionLabel(trans('server/feature.java_version.submit'))
->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(SubuserPermission::StartupDockerImage, $server))
->schema([
TextEntry::make('java')
->label(trans('server/feature.java_version.select_version')),
->label('Please select a supported version from the list below to continue starting the server.'),
Select::make('image')
->label(trans('server/feature.java_version.docker_image'))
->label('Docker Image')
->disabled(fn () => !in_array($server->image, $server->egg->docker_images))
->options(fn () => collect($server->egg->docker_images)->mapWithKeys(fn ($key, $value) => [$key => $value]))
->selectablePlaceholder(false)
@@ -78,13 +73,13 @@ class JavaVersionSchema implements FeatureSchemaInterface
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title(trans('server/feature.java_version.updated'))
->body(trans('server/feature.restart_now'))
->title('Docker image updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title(trans('server/feature.java_version.failed'))
->title('Could not update docker image')
->body($exception->getMessage())
->danger()
->send();

View File

@@ -2,10 +2,8 @@
namespace App\Extensions\Features\Schemas;
use App\Enums\SubuserPermission;
use App\Extensions\Features\FeatureSchemaInterface;
use App\Models\Server;
use App\Models\User;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Repositories\Daemon\DaemonServerRepository;
use Exception;
@@ -30,18 +28,13 @@ class MinecraftEulaSchema implements FeatureSchemaInterface
return 'eula';
}
public function authorize(User $user, Server $server): bool
{
return $user->can(SubuserPermission::FileUpdate, $server);
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading(trans('server/feature.eula.heading'))
->modalDescription(new HtmlString(Blade::render(trans('server/feature.eula.description'))))
->modalSubmitActionLabel(trans('server/feature.eula.accept'))
->modalHeading('Minecraft EULA')
->modalDescription(new HtmlString(Blade::render('By pressing "I Accept" below you are indicating your agreement to the <x-filament::link href="https://minecraft.net/eula" target="_blank">Minecraft EULA </x-filament::link>.')))
->modalSubmitActionLabel('I Accept')
->action(function (DaemonFileRepository $fileRepository, DaemonServerRepository $serverRepository) {
try {
/** @var Server $server */
@@ -52,13 +45,13 @@ class MinecraftEulaSchema implements FeatureSchemaInterface
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title(trans('server/feature.eula.accepted'))
->body(trans('server/feature.restart_now'))
->title('Minecraft EULA accepted')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title(trans('server/feature.eula.failed'))
->title('Could not accept Minecraft EULA')
->body($exception->getMessage())
->danger()
->send();

View File

@@ -4,9 +4,8 @@ namespace App\Extensions\Features\Schemas;
use App\Enums\TablerIcon;
use App\Extensions\Features\FeatureSchemaInterface;
use App\Models\Server;
use App\Models\User;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class PIDLimitSchema implements FeatureSchemaInterface
@@ -29,19 +28,40 @@ class PIDLimitSchema implements FeatureSchemaInterface
return 'pid_limit';
}
public function authorize(User $user, Server $server): bool
{
return true;
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->icon(TablerIcon::AlertTriangle)
->modalHeading(fn () => user()?->isAdmin() ? trans('server/feature.pid_limit.heading_admin') : trans('server/feature.pid_limit.heading_user'))
->modalDescription(new HtmlString(user()?->isAdmin() ? trans('server/feature.pid_limit.description_admin') : trans('server/feature.pid_limit.description_user')))
->modalCancelActionLabel(trans('server/feature.close'))
->modalHeading(fn () => user()?->isAdmin() ? 'Memory or process limit reached...' : 'Possible resource limit reached...')
->modalDescription(new HtmlString(Blade::render(
user()?->isAdmin() ? <<<'HTML'
<p>
This server has reached the maximum process or memory limit.
</p>
<p class="mt-4">
Increasing <code>container_pid_limit</code> in the wings
configuration, <code>config.yml</code>, might help resolve
this issue.
</p>
<p class="mt-4">
<b>Note: Wings must be restarted for the configuration file changes to take effect</b>
</p>
HTML
:
<<<'HTML'
<p>
This server is attempting to use more resources than allocated. Please contact the administrator
and give them the error below.
</p>
<p class="mt-4">
<code>
pthread_create failed, Possibly out of memory or process/resource limits reached
</code>
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
}

View File

@@ -3,9 +3,8 @@
namespace App\Extensions\Features\Schemas;
use App\Extensions\Features\FeatureSchemaInterface;
use App\Models\Server;
use App\Models\User;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class SteamDiskSpaceSchema implements FeatureSchemaInterface
@@ -24,18 +23,32 @@ class SteamDiskSpaceSchema implements FeatureSchemaInterface
return 'steam_disk_space';
}
public function authorize(User $user, Server $server): bool
{
return true;
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading(trans('server/feature.steam_disk_space.heading'))
->modalDescription(new HtmlString(user()?->isAdmin() ? trans('server/feature.steam_disk_space.description_admin') : trans('server/feature.steam_disk_space.description_user')))
->modalCancelActionLabel(trans('server/feature.close'))
->modalHeading('Out of available disk space...')
->modalDescription(new HtmlString(Blade::render(
user()?->isAdmin() ? <<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process.
</p>
<p class="mt-4">
Ensure the machine has enough disk space by typing{' '}
<code class="rounded py-1 px-2">df -h</code> on the machine hosting
this server. Delete files or increase the available disk space to resolve the issue.
</p>
HTML
:
<<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process. Please get in touch with the administrator(s) and inform them of disk space issues.
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Extensions\Filesystem;
use Aws\S3\S3ClientInterface;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
class S3Filesystem extends AwsS3V3Adapter
{
/**
* @param array<mixed> $options
*/
public function __construct(
private S3ClientInterface $client,
private string $bucket,
string $prefix = '',
array $options = [],
) {
parent::__construct(
$client,
$bucket,
$prefix,
null,
null,
$options,
);
}
public function getClient(): S3ClientInterface
{
return $this->client;
}
public function getBucket(): string
{
return $this->bucket;
}
}

View File

@@ -73,11 +73,11 @@ final class AuthentikSchema extends OAuthSchema
public function getName(): string
{
return (string) env('OAUTH_AUTHENTIK_DISPLAY_NAME', 'Authentik');
return env('OAUTH_AUTHENTIK_DISPLAY_NAME', 'Authentik');
}
public function getHexColor(): string
{
return (string) env('OAUTH_AUTHENTIK_DISPLAY_COLOR', '#fd4b2d');
return env('OAUTH_AUTHENTIK_DISPLAY_COLOR', '#fd4b2d');
}
}

View File

@@ -120,20 +120,20 @@ abstract class OAuthSchema implements OAuthSchemaInterface
public function isEnabled(): bool
{
return (bool) env($this->getConfigKey(), false);
return env($this->getConfigKey(), false);
}
public function shouldCreateMissingUser(OAuthUser $user): bool
{
$id = Str::upper($this->getId());
return (bool) env("OAUTH_{$id}_SHOULD_CREATE_MISSING_USERS", false);
return env("OAUTH_{$id}_SHOULD_CREATE_MISSING_USERS", false);
}
public function shouldLinkMissingUser(User $user, OAuthUser $oauthUser): bool
{
$id = Str::upper($this->getId());
return (bool) env("OAUTH_{$id}_SHOULD_LINK_MISSING_USERS", false);
return env("OAUTH_{$id}_SHOULD_LINK_MISSING_USERS", false);
}
}

View File

@@ -44,17 +44,4 @@ class Fractal extends SpatieFractal
return parent::createData();
}
/**
* Perform the transformation to array.
*
* @return array<mixed>
*
* @throws InvalidTransformation
* @throws NoTransformerSpecified
*/
public function toArray(): array
{
return parent::toArray() ?? [];
}
}

View File

@@ -8,7 +8,7 @@ use App\Services\Backups\InitiateBackupService;
final class CreateBackupSchema extends TaskSchema
{
public function __construct(private InitiateBackupService $initiateService) {}
public function __construct(private InitiateBackupService $backupService) {}
public function getId(): string
{
@@ -17,7 +17,7 @@ final class CreateBackupSchema extends TaskSchema
public function runTask(Task $task): void
{
$this->initiateService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
$this->backupService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($task->server, null, true);
}
public function canCreate(Schedule $schedule): bool

View File

@@ -25,7 +25,7 @@ class Dashboard extends BaseDashboard
public function getHeading(): string
{
return trans('admin/dashboard.heading', ['app' => config('app.name')]);
return trans('admin/dashboard.heading');
}
public function getSubheading(): string

View File

@@ -4,10 +4,10 @@ namespace App\Filament\Admin\Pages;
use App\Enums\TablerIcon;
use BackedEnum;
use Carbon\Carbon;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Spatie\Health\Commands\RunHealthChecksCommand;
use Spatie\Health\Enums\Status;
@@ -41,7 +41,7 @@ class Health extends Page
public static function canAccess(): bool
{
return user()?->can('view health') ?? false;
return user()?->can('view health');
}
protected function getActions(): array

View File

@@ -6,22 +6,16 @@ use App\Enums\TablerIcon;
use Boquizo\FilamentLogViewer\Actions\DeleteAction;
use Boquizo\FilamentLogViewer\Actions\DownloadAction;
use Boquizo\FilamentLogViewer\Actions\ViewLogAction;
use Boquizo\FilamentLogViewer\FilamentLogViewerPlugin;
use Boquizo\FilamentLogViewer\Pages\ListLogs as BaseListLogs;
use Boquizo\FilamentLogViewer\Tables\Columns\LevelColumn;
use Boquizo\FilamentLogViewer\Tables\Columns\NameColumn;
use Boquizo\FilamentLogViewer\UseCases\ParseDateUseCase;
use Boquizo\FilamentLogViewer\Utils\Level;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Notifications\Notification;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ListLogs extends BaseListLogs
{
@@ -37,12 +31,6 @@ class ListLogs extends BaseListLogs
return parent::table($table)
->emptyStateHeading(trans('admin/log.empty_table'))
->emptyStateIcon(TablerIcon::Check)
->toolbarActions([
BulkActionGroup::make([
self::exclude_downloadBulkAction(),
self::exclude_deleteBulkAction(),
]),
])
->columns([
NameColumn::make('date'),
LevelColumn::make(Level::ALL)
@@ -62,9 +50,7 @@ class ListLogs extends BaseListLogs
ViewLogAction::make()
->icon(TablerIcon::FileDescription)->iconButton(),
DownloadAction::make()
->tooltip(function (array $record): string {
return trans('filament-log-viewer::log.table.actions.download.label', ['log' => ParseDateUseCase::execute((string) ($record['date'] ?? ''))]);
})
->tooltip(fn ($record) => trans('filament-log-viewer::log.table.actions.download.label', ['log' => ParseDateUseCase::execute($record['date'])]))
->icon(TablerIcon::FileDownload)->iconButton(),
Action::make('uploadLogs')
->hiddenLabel()
@@ -73,15 +59,15 @@ class ListLogs extends BaseListLogs
->requiresConfirmation()
->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 (array $record): void {
->action(function ($record) {
$prefix = config('filament-log-viewer.pattern.prefix', 'laravel-');
$extension = config('filament-log-viewer.pattern.extension', '.log');
$logPath = storage_path('logs/' . $prefix . (string) ($record['date'] ?? '') . $extension);
$logPath = storage_path('logs/' . $prefix . $record['date'] . $extension);
if (!file_exists($logPath)) {
Notification::make()
->title(trans('admin/log.actions.log_not_found'))
->body(trans('admin/log.actions.log_not_found_description', ['filename' => (string) ($record['date'] ?? '')]))
->body(trans('admin/log.actions.log_not_found_description', ['filename' => $record['date']]))
->danger()
->send();
@@ -89,9 +75,9 @@ class ListLogs extends BaseListLogs
}
$lines = file($logPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$totalLines = is_array($lines) ? count($lines) : 0;
$uploadLines = $totalLines <= 1000 ? ($lines ?: []) : array_slice($lines ?: [], -1000);
$content = implode("\n", (array) $uploadLines);
$totalLines = count($lines);
$uploadLines = $totalLines <= 1000 ? $lines : array_slice($lines, -1000);
$content = implode("\n", $uploadLines);
try {
$response = Http::timeout(10)
@@ -111,7 +97,7 @@ class ListLogs extends BaseListLogs
}
$data = $response->json();
$url = (string) ($data['url'] ?? '');
$url = $data['url'];
Notification::make()
->title(trans('admin/log.actions.log_upload'))
@@ -140,50 +126,4 @@ class ListLogs extends BaseListLogs
->icon(TablerIcon::Trash)->iconButton(),
]);
}
private static function exclude_downloadBulkAction(): BulkAction
{
return BulkAction::make('exclude_download')
->label(trans('filament-log-viewer::log.table.actions.download.bulk.label'))
->icon(TablerIcon::FileZip)
->color('success')
->requiresConfirmation()
->modalHeading(trans('filament-log-viewer::log.table.actions.download.bulk.label'))
->failureNotificationTitle(trans('filament-log-viewer::log.table.actions.download.bulk.error'))
->action(self::exclude_downloadBulkAction_getAction(...));
}
/**
* @param Collection<array-key, array<string, mixed>> $records
*/
private static function exclude_downloadBulkAction_getAction(
BulkAction $action,
Collection $records,
): ?BinaryFileResponse {
try {
$logs = $records->pluck('date')->all();
return FilamentLogViewerPlugin::make()->downloadLogs($logs);
} catch (\Exception) {
$action->failure();
return null;
}
}
private static function exclude_deleteBulkAction(): DeleteBulkAction
{
return DeleteBulkAction::make('exclude_delete')
->modalHeading(trans('filament-log-viewer::log.table.actions.delete.bulk.label'))
->action(function (DeleteBulkAction $action) {
$action->process(function (Collection $records): void {
/** @var array<string, mixed> $record */
foreach ($records as $record) {
FilamentLogViewerPlugin::make()->deleteLog((string) ($record['date'] ?? ''));
}
});
$action->success();
});
}
}

View File

@@ -6,6 +6,8 @@ use App\Enums\TablerIcon;
use App\Extensions\Avatar\AvatarService;
use App\Extensions\Captcha\CaptchaService;
use App\Extensions\OAuth\OAuthService;
use App\Facades\Activity;
use App\Models\Backup;
use App\Notifications\MailTested;
use App\Traits\EnvironmentWriterTrait;
use App\Traits\Filament\CanCustomizeHeaderActions;
@@ -30,13 +32,11 @@ use Filament\Pages\Concerns\InteractsWithHeaderActions;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Group;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\StateCasts\BooleanStateCast;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Contracts\HasSchemas;
@@ -47,6 +47,7 @@ use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Notification as MailNotification;
use Illuminate\Support\Str;
use Livewire\Attributes\Locked;
/**
* @property Schema $form
@@ -76,9 +77,14 @@ class Settings extends Page implements HasSchemas
/** @var array<mixed>|null */
public ?array $data = [];
/** @var array<mixed> Snapshot of normalized form state captured on mount, used to detect which settings changed before saving. */
#[Locked]
public array $initialSettings = [];
public function mount(): void
{
$this->form->fill();
$this->initialSettings = $this->sanitizeSettingsData($this->form->getState());
}
public function boot(OAuthService $oauthService, AvatarService $avatarService, CaptchaService $captchaService, IconFactory $iconFactory): void
@@ -91,7 +97,7 @@ class Settings extends Page implements HasSchemas
public static function canAccess(): bool
{
return user()?->can('view settings') ?? false;
return user()?->can('view settings');
}
public function getTitle(): string
@@ -491,6 +497,16 @@ class Settings extends Page implements HasSchemas
private function backupSettings(): array
{
return [
ToggleButtons::make('APP_BACKUP_DRIVER')
->label(trans('admin/setting.backup.backup_driver'))
->columnSpanFull()
->inline()
->options([
Backup::ADAPTER_DAEMON => 'Wings',
Backup::ADAPTER_AWS_S3 => 'S3',
])
->live()
->default(env('APP_BACKUP_DRIVER', config('backups.default'))),
Section::make(trans('admin/setting.backup.throttle'))
->description(trans('admin/setting.backup.throttle_help'))
->columns()
@@ -510,6 +526,41 @@ class Settings extends Page implements HasSchemas
->suffix('Seconds')
->default(config('backups.throttles.period')),
]),
Section::make(trans('admin/setting.backup.s3.s3_title'))
->columns()
->visible(fn (Get $get) => $get('APP_BACKUP_DRIVER') === Backup::ADAPTER_AWS_S3)
->schema([
TextInput::make('AWS_DEFAULT_REGION')
->label(trans('admin/setting.backup.s3.default_region'))
->required()
->default(config('backups.disks.s3.region')),
TextInput::make('AWS_ACCESS_KEY_ID')
->label(trans('admin/setting.backup.s3.access_key'))
->required()
->default(config('backups.disks.s3.key')),
TextInput::make('AWS_SECRET_ACCESS_KEY')
->label(trans('admin/setting.backup.s3.secret_key'))
->required()
->default(config('backups.disks.s3.secret')),
TextInput::make('AWS_BACKUPS_BUCKET')
->label(trans('admin/setting.backup.s3.bucket'))
->required()
->default(config('backups.disks.s3.bucket')),
TextInput::make('AWS_ENDPOINT')
->label(trans('admin/setting.backup.s3.endpoint'))
->required()
->default(config('backups.disks.s3.endpoint')),
Toggle::make('AWS_USE_PATH_STYLE_ENDPOINT')
->label(trans('admin/setting.backup.s3.use_path_style_endpoint'))
->inline(false)
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->stateCast(new BooleanStateCast(false))
->default(env('AWS_USE_PATH_STYLE_ENDPOINT', config('backups.disks.s3.use_path_style_endpoint'))),
]),
];
}
@@ -750,172 +801,24 @@ class Settings extends Page implements HasSchemas
]),
Section::make(trans('admin/setting.misc.api.title'))
->description(trans('admin/setting.misc.api.helper'))
->columns(1)
->columns()
->collapsible()
->collapsed()
->schema([
Fieldset::make()
->label(trans('admin/setting.misc.api.client_title'))
->schema([
TextInput::make('APP_API_CLIENT_RATELIMIT')
->label(trans('admin/setting.misc.api.requests_per_period'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.requests'))->default(env('APP_API_CLIENT_RATELIMIT', config('http.rate_limit.client'))),
TextInput::make('APP_API_CLIENT_RATELIMIT_PERIOD')
->label(trans('admin/setting.misc.api.period_length'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.minutes'))->default(env('APP_API_CLIENT_RATELIMIT_PERIOD', config('http.rate_limit.client_period'))),
Text::make(trans('admin/setting.misc.api.client_helper')),
]),
Fieldset::make()
->label(trans('admin/setting.misc.api.application_title'))
->schema([
TextInput::make('APP_API_APPLICATION_RATELIMIT')
->label(trans('admin/setting.misc.api.requests_per_period'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.requests'))->default(env('APP_API_APPLICATION_RATELIMIT', config('http.rate_limit.application'))),
TextInput::make('APP_API_APPLICATION_RATELIMIT_PERIOD')
->label(trans('admin/setting.misc.api.period_length'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.minutes'))->default(env('APP_API_APPLICATION_RATELIMIT_PERIOD', config('http.rate_limit.application_period'))),
Text::make(trans('admin/setting.misc.api.application_helper')),
]),
Fieldset::make()
->label(trans('admin/setting.misc.api.password_reset_title'))
->schema([
TextInput::make('APP_API_PASSWORD_RESET_RATELIMIT')
->label(trans('admin/setting.misc.api.requests_per_period'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.requests'))->default(env('APP_API_PASSWORD_RESET_RATELIMIT', config('http.rate_limit.password_reset'))),
TextInput::make('APP_API_PASSWORD_RESET_RATELIMIT_PERIOD')
->label(trans('admin/setting.misc.api.period_length'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.minutes'))->default(env('APP_API_PASSWORD_RESET_RATELIMIT_PERIOD', config('http.rate_limit.password_reset_period'))),
Text::make(trans('admin/setting.misc.api.password_reset_helper')),
]),
Fieldset::make()
->label(trans('admin/setting.misc.api.websocket_title'))
->schema([
TextInput::make('APP_API_WEBSOCKET_RATELIMIT')
->label(trans('admin/setting.misc.api.requests_per_period'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.requests'))->default(env('APP_API_WEBSOCKET_RATELIMIT', config('http.rate_limit.websocket'))),
TextInput::make('APP_API_WEBSOCKET_RATELIMIT_PERIOD')
->label(trans('admin/setting.misc.api.period_length'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.minutes'))->default(env('APP_API_WEBSOCKET_RATELIMIT_PERIOD', config('http.rate_limit.websocket_period'))),
Text::make(trans('admin/setting.misc.api.websocket_helper')),
]),
Fieldset::make()
->label(trans('admin/setting.misc.api.backup_restore_title'))
->schema([
TextInput::make('APP_API_BACKUP_RESTORE_RATELIMIT')
->label(trans('admin/setting.misc.api.requests_per_period'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.requests'))->default(env('APP_API_BACKUP_RESTORE_RATELIMIT', config('http.rate_limit.backup_restore'))),
TextInput::make('APP_API_BACKUP_RESTORE_RATELIMIT_PERIOD')
->label(trans('admin/setting.misc.api.period_length'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.minutes'))->default(env('APP_API_BACKUP_RESTORE_RATELIMIT_PERIOD', config('http.rate_limit.backup_restore_period'))),
Text::make(trans('admin/setting.misc.api.backup_restore_helper')),
]),
Fieldset::make()
->label(trans('admin/setting.misc.api.database_create_title'))
->schema([
TextInput::make('APP_API_DATABASE_CREATE_RATELIMIT')
->label(trans('admin/setting.misc.api.requests_per_period'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.requests'))->default(env('APP_API_DATABASE_CREATE_RATELIMIT', config('http.rate_limit.database_create'))),
TextInput::make('APP_API_DATABASE_CREATE_RATELIMIT_PERIOD')
->label(trans('admin/setting.misc.api.period_length'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.minutes'))->default(env('APP_API_DATABASE_CREATE_RATELIMIT_PERIOD', config('http.rate_limit.database_create_period'))),
Text::make(trans('admin/setting.misc.api.database_create_helper')),
]),
Fieldset::make()
->label(trans('admin/setting.misc.api.subuser_create_title'))
->schema([
TextInput::make('APP_API_SUBUSER_CREATE_RATELIMIT')
->label(trans('admin/setting.misc.api.requests_per_period'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.requests'))->default(env('APP_API_SUBUSER_CREATE_RATELIMIT', config('http.rate_limit.subuser_create'))),
TextInput::make('APP_API_SUBUSER_CREATE_RATELIMIT_PERIOD')
->label(trans('admin/setting.misc.api.period_length'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.minutes'))->default(env('APP_API_SUBUSER_CREATE_RATELIMIT_PERIOD', config('http.rate_limit.subuser_create_period'))),
Text::make(trans('admin/setting.misc.api.subuser_create_helper')),
]),
Fieldset::make()
->label(trans('admin/setting.misc.api.file_pull_title'))
->schema([
TextInput::make('APP_API_FILE_PULL_RATELIMIT')
->label(trans('admin/setting.misc.api.requests_per_period'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.requests'))->default(env('APP_API_FILE_PULL_RATELIMIT', config('http.rate_limit.file_pull'))),
TextInput::make('APP_API_FILE_PULL_RATELIMIT_PERIOD')
->label(trans('admin/setting.misc.api.period_length'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.minutes'))->default(env('APP_API_FILE_PULL_RATELIMIT_PERIOD', config('http.rate_limit.file_pull_period'))),
Text::make(trans('admin/setting.misc.api.file_pull_helper')),
]),
Fieldset::make()
->label(trans('admin/setting.misc.api.default_title'))
->schema([
TextInput::make('APP_API_DEFAULT_RATELIMIT')
->label(trans('admin/setting.misc.api.requests_per_period'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.requests'))->default(env('APP_API_DEFAULT_RATELIMIT', config('http.rate_limit.default'))),
TextInput::make('APP_API_DEFAULT_RATELIMIT_PERIOD')
->label(trans('admin/setting.misc.api.period_length'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.minutes'))->default(env('APP_API_DEFAULT_RATELIMIT_PERIOD', config('http.rate_limit.default_period'))),
Text::make(trans('admin/setting.misc.api.default_helper')),
]),
TextInput::make('APP_API_CLIENT_RATELIMIT')
->label(trans('admin/setting.misc.api.client_rate'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.rpm'))
->default(env('APP_API_CLIENT_RATELIMIT', config('http.rate_limit.client'))),
TextInput::make('APP_API_APPLICATION_RATELIMIT')
->label(trans('admin/setting.misc.api.app_rate'))
->required()
->numeric()
->minValue(1)
->suffix(trans('admin/setting.misc.api.rpm'))
->default(env('APP_API_APPLICATION_RATELIMIT', config('http.rate_limit.application'))),
]),
Section::make(trans('admin/setting.misc.server.title'))
->description(trans('admin/setting.misc.server.helper'))
@@ -934,14 +837,13 @@ class Settings extends Page implements HasSchemas
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_EDITABLE_SERVER_DESCRIPTIONS', (bool) $state))
->default(env('PANEL_EDITABLE_SERVER_DESCRIPTIONS', config('panel.editable_server_descriptions'))),
FileUpload::make('console_font')
FileUpload::make('ConsoleFonts')
->hint(trans('admin/setting.misc.server.console_font_hint'))
->label(trans('admin/setting.misc.server.console_font_upload'))
->directory('fonts')
->disk('public')
->columnSpan(1)
->maxFiles(1)
->acceptedFileTypes(['font/*'])
->preserveFilenames(),
]),
Section::make(trans('admin/setting.misc.webhook.title'))
@@ -969,11 +871,9 @@ class Settings extends Page implements HasSchemas
public function save(): void
{
abort_unless(user()?->can('update settings'), 403);
try {
$data = $this->form->getState();
unset($data['console_font']);
$data = $this->sanitizeSettingsData($this->form->getState());
$changedSettings = $this->getChangedSettings($this->initialSettings, $data);
$data = array_map(function ($value) {
// Convert bools to a string, so they are correctly written to the .env file
@@ -993,6 +893,14 @@ class Settings extends Page implements HasSchemas
Artisan::call('queue:restart');
if (!empty($changedSettings)) {
Activity::event('admin:settings.update')
->actor(user())
->property('count', count($changedSettings))
->property('settings', implode(', ', $changedSettings))
->log();
}
$this->redirect($this->getUrl());
Notification::make()
@@ -1008,6 +916,63 @@ class Settings extends Page implements HasSchemas
}
}
/**
* @param array<mixed> $data
* @return array<mixed>
*/
private function sanitizeSettingsData(array $data): array
{
unset($data['ConsoleFonts']);
return $data;
}
/**
* @param array<mixed> $before
* @param array<mixed> $after
* @return string[]
*/
private function getChangedSettings(array $before, array $after): array
{
$changed = [];
foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $key) {
$old = $before[$key] ?? null;
$new = $after[$key] ?? null;
if ($this->normalizeSettingValue($old) !== $this->normalizeSettingValue($new)) {
$changed[] = $key;
}
}
sort($changed);
return $changed;
}
/**
* Converts a setting value to a normalized string for diffing.
* Handles enums, booleans, arrays (JSON-encoded), and scalar values.
*/
private function normalizeSettingValue(mixed $value): string
{
if ($value instanceof BackedEnum) {
return (string) $value->value;
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_array($value)) {
$json = json_encode($value);
return $json === false ? '' : $json;
}
return (string) ($value ?? '');
}
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array
{
@@ -1020,5 +985,6 @@ class Settings extends Page implements HasSchemas
->authorize(fn () => user()?->can('update settings'))
->keyBindings(['mod+s']),
];
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Filament\Admin\Resources\ActivityLogs;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\ActivityLogs\Pages\ListActivityLogs;
use App\Models\ActivityLog;
use App\Traits\Filament\CanCustomizePages;
use BackedEnum;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Illuminate\Database\Eloquent\Model;
class ActivityLogResource extends Resource
{
use CanCustomizePages;
protected static ?string $model = ActivityLog::class;
protected static string|BackedEnum|null $navigationIcon = TablerIcon::ShieldSearch;
protected static ?int $navigationSort = 10;
public static function getNavigationLabel(): string
{
return trans('admin/log.navigation.admin_audit_log');
}
public static function getModelLabel(): string
{
return trans('admin/log.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/log.model_label_plural');
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.advanced');
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function canView(Model $record): bool
{
return false;
}
public static function canViewAny(): bool
{
$user = user();
if (!$user) {
return false;
}
if ($user->isRootAdmin()) {
return true;
}
return $user->can('view adminAuditLog') || $user->can('view panelLog');
}
/** @return array<string, PageRegistration> */
public static function getDefaultPages(): array
{
return [
'index' => ListActivityLogs::route('/'),
];
}
}

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Filament\Admin\Resources\ActivityLogs\Pages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\ActivityLogs\ActivityLogResource;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use App\Models\ActivityLog;
use App\Models\User;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\HtmlString;
class ListActivityLogs extends ListRecords
{
protected static string $resource = ActivityLogResource::class;
public function getHeading(): string
{
return trans('admin/log.navigation.admin_audit_log');
}
public function table(Table $table): Table
{
return $table
->query(
ActivityLog::query()
->where('event', 'like', 'admin:%')
->with(['actor'])
->latest('timestamp')
)
->columns([
TextColumn::make('actor.username')
->label(trans('admin/log.table.actor'))
->state(function (ActivityLog $log): string {
if ($log->actor instanceof User) {
return $log->actor->username;
}
return trans('admin/log.table.system');
})
->searchable(query: function (Builder $query, string $search): Builder {
$escapedSearch = addcslashes($search, '%_\\\\');
return $query->whereHas('actor', fn (Builder $q) => $q->where('username', 'like', "%{$escapedSearch}%"));
}),
TextColumn::make('event')
->label(trans('admin/log.table.event'))
->badge()
->searchable(),
TextColumn::make('description')
->label(trans('admin/log.table.description'))
->html()
->state(fn (ActivityLog $log) => new HtmlString($log->getLabel()))
->grow(),
TextColumn::make('ip')
->label(trans('admin/log.table.ip'))
->visibleFrom('lg')
->visible(fn () => user()?->can('seeIps activityLog')),
DateTimeColumn::make('timestamp')
->label(trans('admin/log.table.timestamp'))
->sortable()
->since(),
])
->defaultSort('timestamp', 'desc')
->searchable()
->filters([
SelectFilter::make('event')
->label(trans('admin/log.table.event'))
->options(fn () => ActivityLog::query()
->where('event', 'like', 'admin:%')
->distinct()
->pluck('event')
->mapWithKeys(fn (string $event) => [$event => $event])
->toArray())
->searchable(),
])
->emptyStateHeading(trans('admin/log.empty_audit_log'))
->emptyStateIcon(TablerIcon::ShieldSearch);
}
}

View File

@@ -1,162 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts;
use App\Enums\TablerIcon;
use App\Extensions\BackupAdapter\BackupAdapterService;
use App\Filament\Admin\Resources\BackupHosts\Pages\CreateBackupHost;
use App\Filament\Admin\Resources\BackupHosts\Pages\EditBackupHost;
use App\Filament\Admin\Resources\BackupHosts\Pages\ListBackupHosts;
use App\Filament\Admin\Resources\BackupHosts\Pages\ViewBackupHost;
use App\Filament\Admin\Resources\BackupHosts\RelationManagers\BackupsRelationManager;
use App\Models\BackupHost;
use App\Traits\Filament\CanCustomizePages;
use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable;
use BackedEnum;
use Exception;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BackupHostResource extends Resource
{
use CanCustomizePages;
use CanCustomizeRelations;
use CanModifyForm;
use CanModifyTable;
protected static ?string $model = BackupHost::class;
protected static string|BackedEnum|null $navigationIcon = TablerIcon::FileZip;
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return (string) static::getEloquentQuery()->count() ?: null;
}
public static function getNavigationLabel(): string
{
return static::getPluralModelLabel();
}
public static function getModelLabel(): string
{
return trans_choice('admin/backuphost.model_label', 1);
}
public static function getPluralModelLabel(): string
{
return trans_choice('admin/backuphost.model_label', 2);
}
public static function getNavigationGroup(): ?string
{
return trans('admin/dashboard.advanced');
}
/** @throws Exception */
public static function defaultTable(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(trans('admin/backuphost.name')),
TextColumn::make('schema')
->label(trans('admin/backuphost.schema'))
->badge(),
TextColumn::make('backups_count')
->counts('backups')
->label(trans('admin/backuphost.backups')),
TextColumn::make('nodes.name')
->badge()
->placeholder(trans('admin/backuphost.all_nodes')),
])
->recordActions([
ViewAction::make()
->hidden(fn ($record) => static::getEditAuthorizationResponse($record)->allowed()),
EditAction::make(),
])
->toolbarActions([
CreateAction::make(),
])
->emptyStateIcon(TablerIcon::FileZip)
->emptyStateDescription(trans('admin/backuphost.local_backups_only'))
->emptyStateHeading(trans('admin/backuphost.no_backup_hosts'));
}
/** @throws Exception */
public static function defaultForm(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label(trans('admin/backuphost.name'))
->required(),
Select::make('schema')
->label(trans('admin/backuphost.schema'))
->required()
->selectablePlaceholder(false)
->searchable()
->options(fn (BackupAdapterService $service) => $service->getMappings())
->live(onBlur: true),
Select::make('node_ids')
->label(trans('admin/backuphost.linked_nodes'))
->multiple()
->searchable()
->preload()
->relationship('nodes', 'name', fn (Builder $query) => $query->whereIn('nodes.id', user()?->accessibleNodes()->pluck('id'))),
Section::make(trans('admin/backuphost.configuration'))
->columnSpanFull()
->columns()
->schema(function (?BackupHost $backupHost, Get $get, BackupAdapterService $service) {
$schema = $get('schema') ?? $backupHost?->schema;
if (!$schema) {
return [];
}
$schema = $service->get($schema);
if ($schema) {
return $schema->getConfigurationForm();
}
return [];
}),
]);
}
/** @return class-string<RelationManager>[] */
public static function getDefaultRelations(): array
{
return [
BackupsRelationManager::class,
];
}
/** @return array<string, PageRegistration> */
public static function getDefaultPages(): array
{
return [
'index' => ListBackupHosts::route('/'),
'create' => CreateBackupHost::route('/create'),
'view' => ViewBackupHost::route('/{record}'),
'edit' => EditBackupHost::route('/{record}/edit'),
];
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Resources\Pages\CreateRecord;
class CreateBackupHost extends CreateRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = BackupHostResource::class;
protected static bool $canCreateAnother = false;
}

View File

@@ -1,43 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
use App\Models\BackupHost;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditBackupHost extends EditRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = BackupHostResource::class;
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array
{
return [
DeleteAction::make()
->label(fn (BackupHost $backupHost) => $backupHost->backups()->count() > 0 ? trans('admin/backuphost.delete_help') : trans('filament-actions::delete.single.modal.actions.delete.label'))
->disabled(fn (BackupHost $backupHost) => $backupHost->backups()->count() > 0)
->hidden(fn () => BackupHost::count() === 1),
Action::make('save')
->hiddenLabel()
->action('save')
->keyBindings(['mod+s'])
->tooltip(trans('filament-panels::resources/pages/edit-record.form.actions.save.label'))
->icon(TablerIcon::DeviceFloppy),
];
}
protected function getFormActions(): array
{
return [];
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Resources\Pages\ListRecords;
class ListBackupHosts extends ListRecords
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = BackupHostResource::class;
}

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\Pages;
use App\Filament\Admin\Resources\BackupHosts\BackupHostResource;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewBackupHost extends ViewRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = BackupHostResource::class;
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Filament\Admin\Resources\BackupHosts\RelationManagers;
use App\Enums\TablerIcon;
use App\Filament\Components\Tables\Columns\BytesColumn;
use App\Filament\Components\Tables\Columns\DateTimeColumn;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class BackupsRelationManager extends RelationManager
{
protected static string $relationship = 'backups';
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->heading(null)
->columns([
TextColumn::make('name')
->label(trans('server/backup.actions.create.name'))
->searchable(),
BytesColumn::make('bytes')
->label(trans('server/backup.size')),
DateTimeColumn::make('created_at')
->label(trans('server/backup.created_at'))
->since()
->sortable(),
TextColumn::make('status')
->label(trans('server/backup.status'))
->badge(),
IconColumn::make('is_locked')
->label(trans('server/backup.is_locked'))
->visibleFrom('md')
->trueIcon(TablerIcon::Lock)
->falseIcon(TablerIcon::LockOpen),
]);
}
}

View File

@@ -89,7 +89,6 @@ class DatabaseHostResource extends Resource
->counts('databases')
->label(trans('admin/databasehost.databases')),
TextColumn::make('nodes.name')
->label(trans('admin/databasehost.nodes'))
->badge()
->placeholder(trans('admin/databasehost.no_nodes')),
])

View File

@@ -66,7 +66,6 @@ class DatabasesRelationManager extends RelationManager
DateTimeColumn::make('created_at')
->label(trans('admin/databasehost.table.created_at')),
])
->emptyStateHeading(trans('admin/databasehost.no_databases'))
->recordActions([
ViewAction::make()
->color('primary'),

View File

@@ -34,11 +34,7 @@ class EggResource extends Resource
public static function getNavigationGroup(): ?string
{
if (user()?->getCustomization(CustomizationKey::TopNavigation) === 'topbar') {
return null;
}
return trans('admin/dashboard.server');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
}
public static function getNavigationLabel(): string

View File

@@ -303,11 +303,7 @@ class EditEgg extends EditRecord
->tooltip(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? trans('filament-actions::delete.single.label') : trans('admin/egg.in_use')),
ExportEggAction::make(),
ImportEggAction::make()
->multiple(false)
->after(function () {
$this->record->refresh();
$this->refreshForm();
}),
->multiple(false),
Action::make('save')
->hiddenLabel()
->action('save')
@@ -320,8 +316,6 @@ class EditEgg extends EditRecord
public function refreshForm(): void
{
$this->fillForm();
$this->dispatch('setContent', content: $this->record->script_install ?? '');
}
protected function getFormActions(): array

View File

@@ -38,9 +38,7 @@ class ListEggs extends ListRecords
*/
public function table(Table $table): Table
{
$defaultEggIcon = config('app.logo');
$defaultEggIcon = empty($defaultEggIcon) || !is_file(public_path($defaultEggIcon)) ? 'pelican.svg' : $defaultEggIcon;
$defaultEggIcon = 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path($defaultEggIcon)));
$defaultEggIcon = 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg')));
return $table
->searchable(true)

View File

@@ -46,11 +46,7 @@ class NodeResource extends Resource
public static function getNavigationGroup(): ?string
{
if (user()?->getCustomization(CustomizationKey::TopNavigation) === 'topbar') {
return null;
}
return trans('admin/dashboard.server');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string

View File

@@ -665,7 +665,6 @@ class EditNode extends EditRecord
->disabled(fn (Get $get) => $get('pulled'))
->headerActions([
Action::make('pull')
->label(trans('admin/node.diagnostics.pull'))
->tooltip(trans('admin/node.diagnostics.pull'))
->icon(TablerIcon::CloudDownload)
->hidden(fn (Get $get) => $get('pulled'))
@@ -765,20 +764,17 @@ class EditNode extends EditRecord
])
->schema([
ToggleButtons::make('include_endpoints')
->label(trans('admin/node.diagnostics.include_endpoints'))
->hintIcon(TablerIcon::QuestionMark)->inline()
->hintIconTooltip(trans('admin/node.diagnostics.include_endpoints_hint'))
->formatStateUsing(fn () => 1)
->boolean(),
ToggleButtons::make('include_logs')
->label(trans('admin/node.diagnostics.include_logs'))
->live()
->hintIcon(TablerIcon::QuestionMark)->inline()
->hintIconTooltip(trans('admin/node.diagnostics.include_logs_hint'))
->formatStateUsing(fn () => 1)
->boolean(),
Slider::make('log_lines')
->label(trans('admin/node.diagnostics.log_lines'))
->columnSpan(2)
->hiddenLabel()
->live()

View File

@@ -83,12 +83,10 @@ class AllocationsRelationManager extends RelationManager
->searchable()
->label(trans('admin/node.table.ip')),
])
->emptyStateHeading(trans('admin/node.no_allocations'))
->toolbarActions([
DeleteBulkAction::make()
->authorize(fn () => user()?->can('update', $this->getOwnerRecord())),
Action::make('create new allocation')
->label(trans('admin/node.create_allocation'))
->tooltip(trans('admin/node.create_allocation'))
->icon(TablerIcon::WorldPlus)
->schema(fn () => [

View File

@@ -67,7 +67,6 @@ class ServersRelationManager extends RelationManager
->label(trans('admin/node.backups'))
->numeric()
->sortable(),
])
->emptyStateHeading(trans('admin/server.no_servers'));
]);
}
}

View File

@@ -113,7 +113,6 @@ class PluginResource extends Resource
->color('primary')
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::Enabled && $plugin->hasSettings())
->schema(fn (Plugin $plugin) => $plugin->getSettingsForm())
->fillForm(fn (Plugin $plugin) => $plugin->getSettingsFormData())
->action(fn (array $data, Plugin $plugin) => $plugin->saveSettings($data))
->slideOver(),
ActionGroup::make([
@@ -125,7 +124,7 @@ class PluginResource extends Resource
->hidden(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled)
->action(function (Plugin $plugin) {
try {
InstallPlugin::dispatch(user(), $plugin->id);
InstallPlugin::dispatch(user(), $plugin);
Notification::make()
->success()
@@ -148,7 +147,7 @@ class PluginResource extends Resource
->visible(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled && $plugin->isUpdateAvailable())
->action(function (Plugin $plugin) {
try {
UpdatePlugin::dispatch(user(), $plugin->id);
UpdatePlugin::dispatch(user(), $plugin);
Notification::make()
->success()
@@ -224,7 +223,7 @@ class PluginResource extends Resource
->hidden(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled || $plugin->status === PluginStatus::Errored)
->action(function (Plugin $plugin) {
try {
UninstallPlugin::dispatch(user(), $plugin->id);
UninstallPlugin::dispatch(user(), $plugin);
Notification::make()
->success()
@@ -243,8 +242,6 @@ class PluginResource extends Resource
])
->headerActions([
Action::make('import_from_file')
->label(trans('admin/plugin.import_from_file'))
->modalHeading(trans('admin/plugin.import_from_file'))
->hiddenLabel()
->tooltip(trans('admin/plugin.import_from_file'))
->authorize(fn () => user()?->can('create', Plugin::class))
@@ -252,7 +249,6 @@ class PluginResource extends Resource
->schema([
// TODO: switch to new file upload
FileUpload::make('file')
->label(trans('admin/plugin.file'))
->required()
->acceptedFileTypes(['application/zip', 'application/zip-compressed', 'application/x-zip-compressed'])
->preserveFilenames()
@@ -264,9 +260,11 @@ class PluginResource extends Resource
/** @var UploadedFile $file */
$file = $data['file'];
$pluginName = str($file->getClientOriginalName())->basename()->before('.zip')->toString();
$pluginName = str($file->getClientOriginalName())->before('.zip')->toString();
throw_if(Plugin::where('id', $pluginName)->exists(), new Exception(trans('admin/plugin.notifications.import_exists')));
if (Plugin::where('id', $pluginName)->exists()) {
throw new Exception(trans('admin/plugin.notifications.import_exists'));
}
$pluginService->downloadPluginFromFile($file);
@@ -287,8 +285,6 @@ class PluginResource extends Resource
}
}),
Action::make('import_from_url')
->label(trans('admin/plugin.import_from_url'))
->modalHeading(trans('admin/plugin.import_from_url'))
->hiddenLabel()
->tooltip(trans('admin/plugin.import_from_url'))
->authorize(fn () => user()?->can('create', Plugin::class))
@@ -303,7 +299,9 @@ class PluginResource extends Resource
try {
$pluginName = str($data['url'])->before('.zip')->explode('/')->last();
throw_if(Plugin::where('id', $pluginName)->exists(), new Exception(trans('admin/plugin.notifications.import_exists')));
if (Plugin::where('id', $pluginName)->exists()) {
throw new Exception(trans('admin/plugin.notifications.import_exists'));
}
$pluginService->downloadPluginFromUrl($data['url']);

View File

@@ -67,7 +67,7 @@ class RoleResource extends Resource
public static function getNavigationGroup(): ?string
{
return user()?->getCustomization(CustomizationKey::TopNavigation) === 'topbar' ? trans('admin/dashboard.advanced') : trans('admin/dashboard.user');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? trans('admin/dashboard.advanced') : trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string
@@ -123,9 +123,7 @@ class RoleResource extends Resource
$options = [];
foreach ($permissions as $permission) {
$options[$permission . ' ' . $model] = trans()->has("admin/role.permissions_list.$permission")
? trans("admin/role.permissions_list.$permission")
: Str::headline($permission);
$options[$permission . ' ' . $model] = Str::headline($permission);
}
$permissionSections[] = self::makeSection($model, $options);
@@ -168,9 +166,7 @@ class RoleResource extends Resource
*/
private static function makeSection(string $model, array $options): Section
{
return Section::make(trans()->has("admin/role.models.$model")
? trans("admin/role.models.$model")
: Str::headline($model))
return Section::make(Str::headline($model))
->columnSpan(1)
->collapsible()
->collapsed()

View File

@@ -305,7 +305,7 @@ class CreateServer extends CreateRecord
->createOptionUsing(function (array $data, Get $get, AssignmentService $assignmentService): int {
return collect(
$assignmentService->handle(Node::find($get('node_id')), $data)
)->firstOrFail();
)->first();
}),
Repeater::make('allocation_additional')
->label(trans('admin/server.additional_allocations'))

View File

@@ -4,8 +4,6 @@ namespace App\Filament\Admin\Resources\Servers\Pages;
use App\Enums\SuspendAction;
use App\Enums\TablerIcon;
use App\Extensions\BackupAdapter\BackupAdapterService;
use App\Extensions\BackupAdapter\Schemas\WingsBackupSchema;
use App\Filament\Admin\Resources\Servers\ServerResource;
use App\Filament\Components\Actions\DeleteIcon;
use App\Filament\Components\Actions\PreviewStartupAction;
@@ -15,6 +13,7 @@ use App\Filament\Components\Forms\Fields\StartupVariable;
use App\Filament\Components\StateCasts\ServerConditionStateCast;
use App\Filament\Server\Pages\Console;
use App\Models\Allocation;
use App\Models\Backup;
use App\Models\Egg;
use App\Models\Server;
use App\Models\User;
@@ -391,7 +390,7 @@ class EditServer extends EditRecord
->label(trans('admin/server.cpu_pin'))->inlineLabel()->inline()
->default(0)
->afterStateUpdated(fn (Set $set) => $set('threads', []))
->formatStateUsing(fn (Get $get) => filled($get('threads')))
->formatStateUsing(fn (Get $get) => !empty($get('threads')))
->live()
->stateCast(new BooleanStateCast(false, true))
->options([
@@ -851,20 +850,16 @@ class EditServer extends EditRecord
->disabled(fn (Server $server) => user()?->accessibleNodes()->count() <= 1 || $server->isInConflictState())
->modalHeading(trans('admin/server.transfer'))
->schema($this->transferServer())
->action(function (TransferServerService $transfer, BackupAdapterService $backupService, Server $server, $data) {
->action(function (TransferServerService $transfer, Server $server, $data) {
try {
$selectedBackupUuids = Arr::get($data, 'backups', []);
$transfer->handle($server, Arr::get($data, 'node_id'), Arr::get($data, 'allocation_id'), Arr::get($data, 'allocation_additional', []), $selectedBackupUuids);
$server->backups
->whereNotIn('uuid', $selectedBackupUuids)
->each(function ($backup) use ($backupService) {
$schema = $backupService->get($backup->backupHost->schema);
// Wings backups that aren't transferred only need to be delete on the panel, wings will cleanup the backup files automatically
if ($schema instanceof WingsBackupSchema) {
$backup->delete();
}
->where('disk', Backup::ADAPTER_DAEMON)
->each(function ($backup) {
$backup->delete();
});
Notification::make()
@@ -956,17 +951,17 @@ class EditServer extends EditRecord
->placeholder(trans('admin/server.select_additional')),
Grid::make()
->columnSpanFull()
->schema(fn (BackupAdapterService $backupService) => [
->schema([
CheckboxList::make('backups')
->label(trans('admin/server.backups'))
->bulkToggleable()
->options(fn (Server $server) => $server->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name]))
->columns(fn (Server $record) => (int) ceil($record->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->count() / 4)),
->options(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->mapWithKeys(fn ($backup) => [$backup->uuid => $backup->name]))
->columns(fn (Server $record) => (int) ceil($record->backups->where('disk', Backup::ADAPTER_DAEMON)->count() / 4)),
Text::make('backup_helper')
->columnSpanFull()
->content(trans('admin/server.warning_backups')),
])
->hidden(fn (Server $server, BackupAdapterService $backupService) => $server->backups->filter(fn ($backup) => $backupService->get($backup->backupHost->schema) instanceof WingsBackupSchema)->count() === 0),
->hidden(fn (Server $server) => $server->backups->where('disk', Backup::ADAPTER_DAEMON)->count() === 0),
];
}

View File

@@ -69,7 +69,6 @@ class AllocationsRelationManager extends RelationManager
->trueIcon(TablerIcon::Lock)
->falseIcon(TablerIcon::LockOpen),
])
->emptyStateHeading(trans('admin/server.no_allocations'))
->recordActions([
Action::make('make-primary')
->label(trans('admin/server.make_primary'))

View File

@@ -79,7 +79,6 @@ class DatabasesRelationManager extends RelationManager
DateTimeColumn::make('created_at')
->label(trans('admin/databasehost.table.created_at')),
])
->emptyStateHeading(trans('admin/server.no_databases'))
->recordActions([
ViewAction::make()
->color('primary'),

View File

@@ -50,11 +50,7 @@ class ServerResource extends Resource
public static function getNavigationGroup(): ?string
{
if (user()?->getCustomization(CustomizationKey::TopNavigation) === 'topbar') {
return null;
}
return trans('admin/dashboard.server');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.server');
}
public static function getNavigationBadge(): ?string

View File

@@ -26,8 +26,7 @@ class ServersRelationManager extends RelationManager
->searchable(false)
->heading(trans('admin/user.servers'))
->headerActions([
Action::make('toggle_suspend')
->authorize(fn () => user()?->can('update server'))
Action::make('toggleSuspend')
->hidden(fn () => $user->servers()
->whereNot('status', ServerState::Suspended)
->orWhereNull('status')
@@ -39,8 +38,7 @@ class ServersRelationManager extends RelationManager
collect($user->servers)->filter(fn ($server) => !$server->isSuspended())
->each(fn ($server) => $suspensionService->handle($server, SuspendAction::Suspend));
}),
Action::make('toggle_unsuspend')
->authorize(fn () => user()?->can('update server'))
Action::make('toggleUnsuspend')
->hidden(fn () => $user->servers()->where('status', ServerState::Suspended)->count() === 0)
->label(trans('admin/server.unsuspend_all'))
->color('primary')
@@ -89,7 +87,6 @@ class ServersRelationManager extends RelationManager
->label(trans('admin/server.backups'))
->numeric()
->sortable(),
])
->emptyStateHeading(trans('admin/server.no_servers'));
]);
}
}

View File

@@ -91,11 +91,7 @@ class UserResource extends Resource
public static function getNavigationGroup(): ?string
{
if (user()?->getCustomization(CustomizationKey::TopNavigation) === 'topbar') {
return null;
}
return trans('admin/dashboard.user');
return user()?->getCustomization(CustomizationKey::TopNavigation) ? false : trans('admin/dashboard.user');
}
public static function getNavigationBadge(): ?string
@@ -485,7 +481,35 @@ class UserResource extends Resource
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderByDesc('timestamp');
$query->orderBy('timestamp', 'desc');
})
->schema([
TextEntry::make('log')
->hiddenLabel()
->state(fn (ActivityLog $log) => new HtmlString($log->htmlable())),
]),
]),
Tab::make('admin_log')
->visible(function (?User $user): bool {
if (!$user) {
return false;
}
$viewer = user();
return $viewer?->isRootAdmin() || $viewer?->can('view adminAuditLog');
})
->disabledOn('create')
->label(trans('admin/user.tabs.admin_log'))
->icon(TablerIcon::ShieldSearch)
->schema([
Repeater::make('adminLog')
->hiddenLabel()
->inlineLabel(false)
->deletable(false)
->addable(false)
->relationship('adminLog', function (Builder $query) {
$query->orderBy('timestamp', 'desc');
})
->schema([
TextEntry::make('log')

View File

@@ -3,7 +3,6 @@
namespace App\Filament\Admin\Resources\Webhooks\Pages;
use App\Enums\TablerIcon;
use App\Enums\WebhookScope;
use App\Enums\WebhookType;
use App\Filament\Admin\Resources\Webhooks\WebhookResource;
use App\Traits\Filament\CanCustomizeHeaderActions;
@@ -11,7 +10,6 @@ use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Validation\ValidationException;
class CreateWebhookConfiguration extends CreateRecord
{
@@ -46,15 +44,6 @@ class CreateWebhookConfiguration extends CreateRecord
protected function mutateFormDataBeforeCreate(array $data): array
{
// Ensure name is set (required field)
if (empty($data['name'] ?? null)) {
throw ValidationException::withMessages(['name' => 'Webhook name is required']);
}
// Set scope to GLOBAL by default for admin webhooks
$data['scope'] = WebhookScope::Global;
unset($data['server_id']);
if (($data['type'] ?? null) === WebhookType::Discord->value) {
$embeds = data_get($data, 'embeds', []);
@@ -64,8 +53,6 @@ class CreateWebhookConfiguration extends CreateRecord
}
$flags = collect($data['flags'] ?? [])->reduce(fn ($carry, $bit) => $carry | $bit, 0);
$selected = data_get($data, 'allowed_mentions', []);
$allowedMentions = $selected ? ['parse' => array_values($selected)] : [];
$tmp = collect([
'username' => data_get($data, 'username'),
@@ -76,7 +63,7 @@ class CreateWebhookConfiguration extends CreateRecord
'embeds' => $embeds,
'thread_name' => data_get($data, 'thread_name'),
'flags' => $flags,
'allowed_mentions' => $allowedMentions,
'allowed_mentions' => data_get($data, 'allowed_mentions', []),
])->filter(fn ($key) => !empty($key))->all();
unset($data['username'], $data['avatar_url'], $data['content'], $data['image'], $data['thumbnail'], $data['embeds'], $data['thread_name'], $data['flags'], $data['allowed_mentions']);

View File

@@ -56,8 +56,6 @@ class EditWebhookConfiguration extends EditRecord
}
$flags = collect($data['flags'] ?? [])->reduce(fn ($carry, $bit) => $carry | $bit, 0);
$selected = data_get($data, 'allowed_mentions', []);
$allowedMentions = $selected ? ['parse' => array_values($selected)] : [];
$tmp = collect([
'username' => data_get($data, 'username'),
@@ -68,7 +66,7 @@ class EditWebhookConfiguration extends EditRecord
'embeds' => $embeds,
'thread_name' => data_get($data, 'thread_name'),
'flags' => $flags,
'allowed_mentions' => $allowedMentions,
'allowed_mentions' => data_get($data, 'allowed_mentions', []),
])->filter(fn ($key) => !empty($key))->all();
unset($data['username'], $data['avatar_url'], $data['content'], $data['image'], $data['thumbnail'], $data['embeds'], $data['thread_name'], $data['flags'], $data['allowed_mentions']);
@@ -112,7 +110,7 @@ class EditWebhookConfiguration extends EditRecord
'embeds' => $embeds,
'thread_name' => data_get($data, 'payload.thread_name'),
'flags' => $flags,
'allowed_mentions' => data_get($data, 'payload.allowed_mentions.parse'),
'allowed_mentions' => data_get($data, 'payload.allowed_mentions'),
])->filter(fn ($key) => !empty($key))->all();
unset($data['payload'], $data['created_at'], $data['updated_at'], $data['deleted_at']);

View File

@@ -2,16 +2,10 @@
namespace App\Filament\Admin\Resources\Webhooks\Pages;
use App\Enums\WebhookScope;
use App\Filament\Admin\Resources\Webhooks\WebhookResource;
use App\Models\WebhookConfiguration;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Schemas\Components\Tabs\Tab;
use Illuminate\Database\Eloquent\Builder;
class ListWebhookConfigurations extends ListRecords
{
@@ -19,24 +13,4 @@ class ListWebhookConfigurations extends ListRecords
use CanCustomizeHeaderWidgets;
protected static string $resource = WebhookResource::class;
/** @return array<Action> */
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->hidden(fn () => $this->activeTab === 'server-webhooks'),
];
}
public function getTabs(): array
{
return collect(WebhookScope::cases())
->mapWithKeys(fn (WebhookScope $scope) => [
$scope->value . '-webhooks' => Tab::make($scope->getLabel() . ' Webhooks')
->modifyQueryUsing(fn (Builder $query) => $query->where('scope', $scope))
->badge(WebhookConfiguration::where('scope', $scope)->count()),
])
->all();
}
}

View File

@@ -3,14 +3,12 @@
namespace App\Filament\Admin\Resources\Webhooks;
use App\Enums\TablerIcon;
use App\Enums\WebhookScope;
use App\Enums\WebhookType;
use App\Filament\Admin\Resources\Webhooks\Pages\CreateWebhookConfiguration;
use App\Filament\Admin\Resources\Webhooks\Pages\EditWebhookConfiguration;
use App\Filament\Admin\Resources\Webhooks\Pages\ListWebhookConfigurations;
use App\Filament\Admin\Resources\Webhooks\Pages\ViewWebhookConfiguration;
use App\Livewire\AlertBanner;
use App\Models\Server;
use App\Models\WebhookConfiguration;
use App\Traits\Filament\CanCustomizePages;
use App\Traits\Filament\CanCustomizeRelations;
@@ -18,6 +16,7 @@ use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction;
@@ -27,19 +26,14 @@ use Filament\Actions\ViewAction;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
@@ -48,6 +42,7 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Livewire\Component as Livewire;
use Livewire\Features\SupportEvents\HandlesEvents;
class WebhookResource extends Resource
@@ -62,7 +57,7 @@ class WebhookResource extends Resource
protected static string|BackedEnum|null $navigationIcon = TablerIcon::Webhook;
protected static ?string $recordTitleAttribute = 'name';
protected static ?string $recordTitleAttribute = 'description';
public static function getNavigationLabel(): string
{
@@ -92,26 +87,17 @@ class WebhookResource extends Resource
public static function defaultTable(Table $table): Table
{
return $table
->groups([
'server.name',
])
->columns([
TextColumn::make('name')
->label(trans('admin/webhook.name')),
TextColumn::make('description')
->label(trans('admin/webhook.table.description')),
IconColumn::make('type')
->label(trans('admin/webhook.type')),
TextColumn::make('server.name')
->label('Server')
->placeholder('—')
->icon('tabler-server')
->iconColor('info'),
IconColumn::make('type'),
TextColumn::make('endpoint')
->label(trans('admin/webhook.endpoint'))
->label(trans('admin/webhook.table.endpoint'))
->formatStateUsing(fn (string $state) => str($state)->after('://'))
->limit(60)
->wrap(),
TextColumn::make('description')
->label(trans('admin/webhook.table.description')),
TextColumn::make('endpoint')
->label(trans('admin/webhook.table.endpoint')),
])
->recordActions([
ViewAction::make()
@@ -122,7 +108,7 @@ class WebhookResource extends Resource
->tooltip(trans('filament-actions::replicate.single.label'))
->modal(false)
->excludeAttributes(['created_at', 'updated_at'])
->beforeReplicaSaved(fn (WebhookConfiguration $replica) => $replica->name .= ' Copy ' . now()->format('Y-m-d H:i:s'))
->beforeReplicaSaved(fn (WebhookConfiguration $replica) => $replica->description .= ' Copy ' . now()->format('Y-m-d H:i:s'))
->successRedirectUrl(fn (WebhookConfiguration $replica) => EditWebhookConfiguration::getUrl(['record' => $replica])),
])
->toolbarActions([
@@ -139,9 +125,6 @@ class WebhookResource extends Resource
SelectFilter::make('type')
->options(WebhookType::class)
->attribute('type'),
SelectFilter::make('server_id')
->label('Server')
->options(Server::query()->pluck('name', 'id')->toArray()),
]);
}
@@ -149,74 +132,50 @@ class WebhookResource extends Resource
{
return $schema
->components([
Tabs::make('webhook_tabs')
->persistTab()
ToggleButtons::make('type')
->live()
->inline()
->options(WebhookType::class)
->default(WebhookType::Regular),
TextInput::make('description')
->label(trans('admin/webhook.description'))
->required(),
TextInput::make('endpoint')
->label(trans('admin/webhook.endpoint'))
->required()
->columnSpanFull()
->tabs([
Tab::make(trans('admin/webhook.information'))
->icon(TablerIcon::InfoCircle)
->schema([
Grid::make()
->schema([
TextInput::make('name')
->label(trans('admin/webhook.name'))
->required(),
Select::make('server_id')
->label(trans('admin/webhook.server'))
->relationship('server', 'id')
->preload()
->disabled(),
]),
TextInput::make('description')
->label(trans('admin/webhook.description'))
->required(),
Grid::make()
->schema([
ToggleButtons::make('type')
->label(trans('admin/webhook.type'))
->live()
->inline()
->options(WebhookType::class)
->default(WebhookType::Regular),
Hidden::make('scope')
->formatStateUsing(fn (Get $get) => $get('server_id') ? WebhookScope::Server : WebhookScope::Global),
TextInput::make('endpoint')
->label(trans('admin/webhook.endpoint'))
->required()
->afterStateUpdated(fn (?string $state, Set $set) => $set('type', $state && str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)),
]),
]),
Tab::make(trans('admin/webhook.payload'))
->icon(TablerIcon::FileCode)
->schema([
Section::make()
->schema(fn (Get $get) => $get('type') === WebhookType::Discord
? self::getDiscordFields()
: self::getRegularFields()
),
]),
Tab::make(trans('admin/webhook.events'))
->icon(TablerIcon::Star)
->schema([
Section::make()
->schema([
CheckboxList::make('events')
->live()
->options(function (Get $get) {
$scope = $get('scope');
if (!$scope instanceof WebhookScope) {
$scope = WebhookScope::from($scope ?? 'global');
}
return WebhookConfiguration::filamentCheckboxList($scope);
})
->searchable()
->bulkToggleable()
->columns(3)
->columnSpanFull()
->required(),
]),
]),
->afterStateUpdated(fn (string $state, Set $set) => $set('type', str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)),
Section::make(trans('admin/webhook.regular'))
->hidden(fn (Get $get) => $get('type') === WebhookType::Discord)
->schema(fn () => self::getRegularFields())
->headerActions([
Action::make('reset_headers')
->tooltip(trans('admin/webhook.reset_headers'))
->color('danger')
->icon(TablerIcon::Restore)
->action(fn (Get $get, Set $set) => $set('headers', [
'X-Webhook-Event' => '{{event}}',
])),
])
->formBefore(),
Section::make(trans('admin/webhook.discord'))
->hidden(fn (Get $get) => $get('type') === WebhookType::Regular)
->afterStateUpdated(fn (Livewire $livewire) => $livewire->dispatch('refresh-widget'))
->schema(fn () => self::getDiscordFields())
->view('filament.components.webhooksection')
->aside()
->formBefore()
->columnSpanFull(),
Section::make(trans('admin/webhook.events'))
->schema([
CheckboxList::make('events')
->live()
->options(fn () => WebhookConfiguration::filamentCheckboxList())
->searchable()
->bulkToggleable()
->columns(3)
->columnSpanFull()
->required(),
]),
]);
}
@@ -241,150 +200,134 @@ class WebhookResource extends Resource
private static function getDiscordFields(): array
{
return [
Grid::make()
Section::make(trans('admin/webhook.discord_message.profile'))
->collapsible()
->schema([
Section::make()
->columnSpanFull()
->poll('15s')
->view('filament.components.webhooksection'),
Grid::make()
->columnSpan(8)
TextInput::make('username')
->live(debounce: 500)
->label(trans('admin/webhook.discord_message.username')),
TextInput::make('avatar_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_message.avatar_url')),
]),
Section::make(trans('admin/webhook.discord_message.message'))
->collapsible()
->schema([
TextInput::make('content')
->label(trans('admin/webhook.discord_message.message'))
->live(debounce: 500)
->required(fn (Get $get) => empty($get('embeds'))),
TextInput::make('thread_name')
->label(trans('admin/webhook.discord_message.forum_thread')),
CheckboxList::make('flags')
->label(trans('admin/webhook.discord_embed.flags'))
->options([
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds'),
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications'),
])
->descriptions([
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds_text'),
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications_text'),
]),
CheckboxList::make('allowed_mentions')
->label(trans('admin/webhook.discord_embed.allowed_mentions'))
->options([
'roles' => trans('admin/webhook.discord_embed.roles'),
'users' => trans('admin/webhook.discord_embed.users'),
'everyone' => trans('admin/webhook.discord_embed.everyone'),
]),
]),
Repeater::make('embeds')
->live(debounce: 500)
->itemLabel(fn (array $state) => $state['title'])
->addActionLabel(trans('admin/webhook.discord_embed.add_embed'))
->required(fn (Get $get) => empty($get('content')))
->reorderable()
->collapsible()
->maxItems(10)
->schema([
Section::make(trans('admin/webhook.discord_embed.author'))
->collapsible()
->collapsed()
->schema([
Section::make(trans('admin/webhook.discord_message.profile'))
->collapsible()
->columnSpanFull()
->schema([
TextInput::make('username')
->live(debounce: 500)
->label(trans('admin/webhook.discord_message.username')),
TextInput::make('avatar_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_message.avatar_url')),
]),
Section::make(trans('admin/webhook.discord_message.message'))
->columnSpanFull()
->collapsible()
->schema([
TextInput::make('content')
->label(trans('admin/webhook.discord_message.message'))
->live(debounce: 500)
->required(fn (Get $get) => empty($get('embeds'))),
TextInput::make('thread_name')
->label(trans('admin/webhook.discord_message.forum_thread')),
CheckboxList::make('flags')
->label(trans('admin/webhook.discord_embed.flags'))
->options([
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds'), // Discord flag: SUPPRESS_EMBEDS (4)
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications'), // Discord flag: SUPPRESS_NOTIFICATIONS (4096)
])
->descriptions([
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds_text'),
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications_text'),
]),
CheckboxList::make('allowed_mentions')
->label(trans('admin/webhook.discord_embed.allowed_mentions'))
->options([
'roles' => trans('admin/webhook.discord_embed.roles'),
'users' => trans('admin/webhook.discord_embed.users'),
'everyone' => trans('admin/webhook.discord_embed.everyone'),
]),
]),
Repeater::make('embeds')
TextInput::make('author.name')
->live(debounce: 500)
->itemLabel(fn (array $state) => $state['title'] ?? '')
->addActionLabel(trans('admin/webhook.discord_embed.add_embed'))
->required(fn (Get $get) => empty($get('content')))
->label(trans('admin/webhook.discord_embed.author'))
->required(fn (Get $get) => filled($get('author.url')) || filled($get('author.icon_url'))),
TextInput::make('author.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author_url')),
TextInput::make('author.icon_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author_icon_url')),
]),
Section::make(trans('admin/webhook.discord_embed.body'))
->collapsible()
->collapsed()
->schema([
TextInput::make('title')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.title'))
->required(fn (Get $get) => $get('description') === null),
Textarea::make('description')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.body'))
->required(fn (Get $get) => $get('title') === null),
ColorPicker::make('color')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.color'))
->hex(),
TextInput::make('url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.url')),
]),
Section::make(trans('admin/webhook.discord_embed.images'))
->collapsible()
->collapsed()
->schema([
TextInput::make('image.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.image_url')),
TextInput::make('thumbnail.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.image_thumbnail')),
]),
Section::make(trans('admin/webhook.discord_embed.footer'))
->collapsible()
->collapsed()
->schema([
TextInput::make('footer.text')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.footer')),
Checkbox::make('has_timestamp')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.has_timestamp')),
TextInput::make('footer.icon_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.footer_icon_url')),
]),
Section::make(trans('admin/webhook.discord_embed.fields'))
->collapsible()->collapsed()
->schema([
Repeater::make('fields')
->reorderable()
->columnSpanFull()
->addActionLabel(trans('admin/webhook.discord_embed.add_field'))
->collapsible()
->maxItems(10)
->schema([
Section::make(trans('admin/webhook.discord_embed.author'))
->collapsible()
->collapsed()
->schema([
TextInput::make('author.name')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author'))
->required(fn (Get $get) => filled($get('author.url')) || filled($get('author.icon_url'))),
TextInput::make('author.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author_url')),
TextInput::make('author.icon_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author_icon_url')),
]),
Section::make(trans('admin/webhook.discord_embed.body'))
->collapsible()
->collapsed()
->schema([
TextInput::make('title')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.title'))
->required(fn (Get $get) => $get('description') === null),
Textarea::make('description')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.body'))
->required(fn (Get $get) => $get('title') === null),
ColorPicker::make('color')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.color'))
->hex(),
TextInput::make('url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.url')),
]),
Section::make(trans('admin/webhook.discord_embed.images'))
->collapsible()
->collapsed()
->schema([
TextInput::make('image.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.image_url')),
TextInput::make('thumbnail.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.image_thumbnail')),
]),
Section::make(trans('admin/webhook.discord_embed.footer'))
->collapsible()
->collapsed()
->schema([
TextInput::make('footer.text')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.footer')),
Checkbox::make('has_timestamp')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.has_timestamp')),
TextInput::make('footer.icon_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.footer_icon_url')),
]),
Section::make(trans('admin/webhook.discord_embed.fields'))
->collapsible()->collapsed()
->schema([
Repeater::make('fields')
->reorderable()
->addActionLabel(trans('admin/webhook.discord_embed.add_field'))
->collapsible()
->schema([
TextInput::make('name')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.field_name'))
->required(),
Textarea::make('value')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.field_value'))
->rows(4)
->required(),
Checkbox::make('inline')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.inline_field')),
]),
]),
TextInput::make('name')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.field_name'))
->required(),
Textarea::make('value')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.field_value'))
->rows(4)
->required(),
Checkbox::make('inline')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.inline_field')),
]),
]),
]),
];
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Filament\Admin\Widgets;
use App\Models\WebhookConfiguration;
use Filament\Widgets\Widget;
use Illuminate\Support\Carbon;
class DiscordPreview extends Widget
{
protected string $view = 'filament.admin.widgets.discord-preview';
/** @var array<string, string> */
protected $listeners = [
'refresh-widget' => '$refresh',
];
protected static bool $isDiscovered = false; // Without this its shown on every Admin Pages
protected int|string|array $columnSpan = 1;
public ?WebhookConfiguration $record = null;
/** @var string|array<string, mixed>|null */
public string|array|null $payload = null;
/**
* @return array{
* link: callable,
* content: mixed,
* sender: array{name: string, avatar: string},
* embeds: array<int, mixed>,
* getTime: mixed
* }
*/
public function getViewData(): array
{
if (!$this->record || !$this->record->payload) {
return [
'link' => fn ($href, $child) => $href ? "<a href=\"$href\" target=\"_blank\" class=\"link\">$child</a>" : $child,
'content' => null,
'sender' => [
'name' => 'Pelican',
'avatar' => 'https://raw.githubusercontent.com/pelican-dev/panel/refs/heads/main/public/pelican.ico',
],
'embeds' => [],
'getTime' => 'Today at ' . Carbon::now()->format('h:i A'),
];
}
$data = $this->getWebhookSampleData();
if (is_string($this->record->payload)) {
$payload = $this->replaceVarsInStringPayload($this->record->payload, $data);
} else {
$payload = $this->replaceVarsInArrayPayload($this->record->payload, $data);
}
$embeds = data_get($payload, 'embeds', []);
foreach ($embeds as &$embed) {
if (data_get($embed, 'has_timestamp')) {
unset($embed['has_timestamp']);
$embed['timestamp'] = 'Today at ' . Carbon::now()->format('h:i A');
}
}
return [
'link' => fn ($href, $child) => $href ? sprintf('<a href="%s" target="_blank" class="link">%s</a>', $href, $child) : $child,
'content' => data_get($payload, 'content'),
'sender' => [
'name' => data_get($payload, 'username', 'Pelican'),
'avatar' => data_get($payload, 'avatar_url', 'https://raw.githubusercontent.com/pelican-dev/panel/refs/heads/main/public/pelican.ico'),
],
'embeds' => $embeds,
'getTime' => 'Today at ' . Carbon::now()->format('h:i A'),
];
}
/**
* @param array<string, mixed> $data
*/
private function replaceVarsInStringPayload(?string $payload, array $data): ?string
{
if ($payload === null) {
return null;
}
return preg_replace_callback('/{{\s*([\w\.]+)\s*}}/', fn ($m) => data_get($data, $m[1], $m[0]),
$payload
);
}
/**
* @param array<string, mixed>|null $payload
* @param array<string, mixed> $data
* @return array<string, mixed>|null
*/
private function replaceVarsInArrayPayload(?array $payload, array $data): ?array
{
if ($payload === null) {
return null;
}
foreach ($payload as $key => $value) {
if (is_string($value)) {
$payload[$key] = $this->replaceVarsInStringPayload($value, $data);
} elseif (is_array($value)) {
$payload[$key] = $this->replaceVarsInArrayPayload($value, $data);
}
}
return $payload;
}
/**
* @return array<string, mixed>
*/
public function getWebhookSampleData(): array
{
return [
'event' => 'updated: server',
'id' => 2,
'external_id' => 10,
'uuid' => '651fgbc1-dee6-4250-814e-10slda13f1e',
'uuid_short' => '651fgbc1',
'node_id' => 1,
'name' => 'Example Server',
'description' => 'This is an example server description.',
'status' => 'running',
'skip_scripts' => false,
'owner_id' => 1,
'memory' => 512,
'swap' => 128,
'disk' => 10240,
'io' => 500,
'cpu' => 500,
'threads' => '1, 3, 5',
'oom_killer' => false,
'allocation_id' => 4,
'egg_id' => 2,
'startup' => 'This is a example startup command.',
'image' => 'Image here',
'allocation_limit' => 5,
'database_limit' => 1,
'backup_limit' => 3,
'created_at' => '2025-03-17T15:20:32.000000Z',
'updated_at' => '2025-05-12T17:53:12.000000Z',
'installed_at' => '2025-04-27T21:06:01.000000Z',
'docker_labels' => [],
'allocation' => [
'id' => 4,
'node_id' => 1,
'ip' => '192.168.0.3',
'ip_alias' => null,
'port' => 25567,
'server_id' => 2,
'notes' => null,
'created_at' => '2025-03-17T15:20:09.000000Z',
'updated_at' => '2025-03-17T15:20:32.000000Z',
],
];
}
}

View File

@@ -131,7 +131,7 @@ class ListServers extends ListRecords
->contentGrid($usingGrid ? ['default' => 1, 'md' => 2] : null)
->emptyStateIcon(TablerIcon::BrandDocker)
->emptyStateDescription('')
->emptyStateHeading(fn () => $this->activeTab === 'my' ? trans('server/dashboard.empty_own') : trans('server/dashboard.empty_other'))
->emptyStateHeading(fn () => $this->activeTab === 'my' ? 'You don\'t own any servers!' : 'You don\'t have access to any servers!')
->persistFiltersInSession()
->filters([
SelectFilter::make('egg')

View File

@@ -21,8 +21,6 @@ class UpdateNodeAllocations extends Action
{
parent::setUp();
$this->label(trans('admin/node.bulk_update_ip'));
$this->tooltip(trans('admin/node.bulk_update_ip'));
$this->icon(TablerIcon::Replace);

View File

@@ -104,12 +104,12 @@ class UploadIcon extends Action
$content = Http::timeout(5)->connectTimeout(1)->withoutRedirecting()->get($data['icon_url'])->body();
throw_if(empty($content), new Exception(trans('admin/egg.import.invalid_url')));
if (empty($content)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($data['icon_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
throw_if(empty($extension), new Exception(trans('admin/egg.import.invalid_url')));
$record->writeIcon($extension, $content);
Notification::make()
@@ -132,14 +132,20 @@ class UploadIcon extends Action
protected function validateIconUrl(string $url): void
{
throw_unless(in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true), new Exception(trans('admin/egg.import.invalid_url')));
if (!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
throw_unless(filter_var($url, FILTER_VALIDATE_URL), new Exception(trans('admin/egg.import.invalid_url')));
if (!filter_var($url, FILTER_VALIDATE_URL)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$host = parse_url($url, PHP_URL_HOST);
$ip = gethostbyname($host);
throw_if(filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false, 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'));
}
}
/** @param string[] $iconFormats */

View File

@@ -36,7 +36,9 @@ class MonacoEditor extends Field
{
$theme = $this->evaluate($this->theme);
throw_unless(isset(config('monaco-editor.themes')[$theme]), new Exception("Theme {$theme} not found in config file."));
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"),

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Components\Tables\Columns\Concerns;
use Closure;
use Filament\Support\Colors\Color;
/**
* Trait extracted for progress-related shared functionality between columns.
@@ -16,19 +17,19 @@ trait HasProgress
protected float|Closure|null $dangerThresholdPercent = null;
/**
* @var string|array<int|string,int|string>|Closure|null
* @var string|array<int|string,string>|Closure|Color|null
*/
protected string|array|Closure|null $dangerColor = null;
protected string|array|Closure|Color|null $dangerColor = null;
/**
* @var string|array<int|string,int|string>|Closure|null
* @var string|array<int|string,string>|Closure|Color|null
*/
protected string|array|Closure|null $warningColor = null;
protected string|array|Closure|Color|null $warningColor = null;
/**
* @var string|array<int|string,int|string>|Closure|null
* @var string|array<int|string,string>|Closure|Color|null
*/
protected string|array|Closure|null $color = null;
protected string|array|Closure|Color|null $color = null;
public function warningThresholdPercent(float|Closure $value): static
{
@@ -55,9 +56,9 @@ trait HasProgress
}
/**
* @param string|array<int|string,int|string>|Closure $color
* @param string|array<int|string,string>|Closure|Color $color
*/
public function dangerColor(string|array|Closure $color): static
public function dangerColor(string|array|Closure|Color $color): static
{
$this->dangerColor = $color;
@@ -65,17 +66,17 @@ trait HasProgress
}
/**
* @return string|array<int|string,int|string>|null
* @return string|array<int|string,string>|Color|null
*/
public function getDangerColor(): string|array|null
public function getDangerColor(): string|array|Color|null
{
return $this->evaluate($this->dangerColor);
}
/**
* @param string|array<int|string,int|string>|Closure $color
* @param string|array<int|string,string>|Closure|Color $color
*/
public function warningColor(string|array|Closure $color): static
public function warningColor(string|array|Closure|Color $color): static
{
$this->warningColor = $color;
@@ -83,17 +84,17 @@ trait HasProgress
}
/**
* @return string|array<int|string,int|string>|null
* @return string|array<int|string,string>|Color|null
*/
public function getWarningColor(): string|array|null
public function getWarningColor(): string|array|Color|null
{
return $this->evaluate($this->warningColor);
}
/**
* @param string|array<int|string,int|string>|Closure $color
* @param string|array<int|string,string>|Closure|Color $color
*/
public function color(string|array|Closure $color): static
public function color(string|array|Closure|Color $color): static
{
$this->color = $color;
@@ -101,9 +102,9 @@ trait HasProgress
}
/**
* @return string|array<int|string,int|string>|null
* @return string|array<int|string,string>|Color|null
*/
public function getColor(): string|array|null
public function getColor(): string|array|Color|null
{
return $this->evaluate($this->color);
}
@@ -111,9 +112,9 @@ trait HasProgress
/**
* Resolve a progress color for a given status string ('danger','warning','success').
*
* @return string|array<int|string,int|string>
* @return string|array<int|string,string>|Color
*/
public function getProgressColorForStatus(string $status): string|array
public function getProgressColorForStatus(string $status): string|array|Color
{
$color = match ($status) {
'danger' => $this->getDangerColor(),

View File

@@ -104,7 +104,7 @@ class ProgressBarColumn extends Column
}
/**
* @return string|array<int|string,int|string>
* @return string|array<int|string,string>
*/
public function getProgressColor(): string|array
{

View File

@@ -39,12 +39,10 @@ use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Support\Colors\Color;
use Filament\Support\Enums\Width;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString;
@@ -63,12 +61,9 @@ class EditProfile extends BaseEditProfile
protected OAuthService $oauthService;
protected Request $request;
public function boot(OAuthService $oauthService, Request $request): void
public function boot(OAuthService $oauthService): void
{
$this->oauthService = $oauthService;
$this->request = $request;
}
public function getMaxWidth(): Width|string
@@ -239,17 +234,6 @@ class EditProfile extends BaseEditProfile
->map(fn (MultiFactorAuthenticationProvider $multiFactorAuthenticationProvider) => Group::make($multiFactorAuthenticationProvider->getManagementSchemaComponents())
->statePath($multiFactorAuthenticationProvider->getId()))
->all()),
Tab::make('passkeys')
->label(trans('profile.tabs.passkeys'))
->icon(TablerIcon::Fingerprint)
->hidden(fn () => !$this->request->isSecure())
->schema([
Section::make(trans('profile.tabs.passkeys'))
->description(trans('passkeys.description'))
->schema([
View::make('passkeys.livewire.passkeys-tab'),
]),
]),
Tab::make('api_keys')
->label(trans('profile.tabs.api_keys'))
->icon(TablerIcon::Key)
@@ -448,7 +432,7 @@ class EditProfile extends BaseEditProfile
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderByDesc('timestamp')->limit(50);
$query->orderBy('timestamp', 'desc')->limit(50);
})
->schema([
TextEntry::make('log')
@@ -481,12 +465,13 @@ class EditProfile extends BaseEditProfile
'mixed' => trans('profile.mixed'),
]),
ToggleButtons::make('button_style')
->label(trans('profile.button_style'))
->label('Button Style')
->inline()
->default('icon')
->options([
true => trans('profile.icon'),
false => trans('profile.icon_button'),
true => 'Icon',
false => 'Icon Button',
]),
]),
Section::make(trans('profile.admin'))

View File

@@ -2,7 +2,6 @@
namespace App\Filament\Pages\Auth;
use App\Enums\TablerIcon;
use App\Extensions\Captcha\CaptchaService;
use App\Extensions\OAuth\OAuthService;
use BladeUI\Icons\Exceptions\SvgNotFound;
@@ -10,13 +9,10 @@ use BladeUI\Icons\Factory as IconFactory;
use Filament\Actions\Action;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Actions;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Schema;
use Filament\Support\Colors\Color;
use Filament\Support\Enums\Alignment;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class Login extends BaseLogin
@@ -27,40 +23,20 @@ class Login extends BaseLogin
protected IconFactory $iconFactory;
protected Request $request;
public function boot(OAuthService $oauthService, CaptchaService $captchaService, IconFactory $iconFactory, Request $request): void
public function boot(OAuthService $oauthService, CaptchaService $captchaService, IconFactory $iconFactory): void
{
$this->oauthService = $oauthService;
$this->captchaService = $captchaService;
$this->iconFactory = $iconFactory;
$this->request = $request;
}
public function mount(): void
{
parent::mount();
if ($message = session()->pull('authenticatePasskey::message')) {
Notification::make()->title($message)->danger()->send();
}
}
public function form(Schema $schema): Schema
{
if (config('auth.disable_password_login', false)) {
return $schema->components([
$this->getOAuthFormComponent(),
$this->getPasskeyFormComponent(),
]);
}
$components = [
$this->getLoginFormComponent(),
$this->getPasswordFormComponent(),
$this->getRememberFormComponent(),
$this->getOAuthFormComponent(),
$this->getPasskeyFormComponent(),
];
if ($captchaComponent = $this->getCaptchaComponent()) {
@@ -68,7 +44,8 @@ class Login extends BaseLogin
->hidden(fn () => filled($this->userUndertakingMultiFactorAuthentication));
}
return $schema->components($components);
return $schema
->components($components);
}
private function getCaptchaComponent(): ?Component
@@ -76,18 +53,6 @@ class Login extends BaseLogin
return $this->captchaService->getActiveSchema()?->getFormComponent();
}
protected function getPasskeyFormComponent(): Component
{
return Actions::make([
Action::make('passkey')
->label(trans('passkeys.authenticate_using_passkey'))
->icon(TablerIcon::Key->value)
->color('gray')
->alpineClickHandler('window.authenticateWithPasskey()')
->extraAttributes(['type' => 'button']),
])->fullWidth()->hidden(fn () => !$this->request->isSecure());
}
protected function throwFailureValidationException(): never
{
$this->dispatch('reset-captcha');
@@ -143,22 +108,11 @@ class Login extends BaseLogin
->url(route('auth.oauth.redirect', ['driver' => $id], false));
}
return Actions::make($actions)->alignment(fn () => config('auth.disable_password_login', false) ? Alignment::Center : null);
}
protected function getFormActions(): array
{
return config('auth.disable_password_login', false) ? [] : parent::getFormActions();
return Actions::make($actions);
}
protected function getCredentialsFromFormData(array $data): array
{
if (config('auth.disable_password_login', false)) {
throw ValidationException::withMessages([
'data.login' => trans('auth.password_login_disabled'),
]);
}
$loginType = filter_var($data['login'], FILTER_VALIDATE_EMAIL) ? 'email' : 'username';
return [

View File

@@ -64,15 +64,9 @@ class Console extends Page
public function boot(FeatureService $featureService): void
{
$this->featureService = $featureService;
/** @var Server $server */
$server = Filament::getTenant();
foreach ($featureService->getActiveSchemas($server->egg->features) as $feature) {
if (!$feature->authorize(user(), $server)) {
continue;
}
$this->cacheAction($feature->getAction());
}
}
@@ -88,13 +82,6 @@ class Console extends Page
return;
}
/** @var Server $server */
$server = Filament::getTenant();
if (!$feature->authorize(user(), $server)) {
return;
}
if ($this->getMountedAction()) {
$this->replaceMountedAction($feature->getId());
} else {

View File

@@ -56,7 +56,6 @@ class Startup extends ServerFormPage
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::StartupUpdate, $server))
->formatStateUsing(fn (Server $server) => $server->startup)
->afterStateUpdated(function ($state, Server $server, Set $set) {
abort_unless(user()?->can(SubuserPermission::StartupUpdate, $server), 403);
$original = $server->startup;
$server->forceFill(['startup' => $state])->saveOrFail();
@@ -90,7 +89,6 @@ class Startup extends ServerFormPage
->visible(fn (Server $server) => in_array($server->image, $server->egg->docker_images))
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::StartupDockerImage, $server))
->afterStateUpdated(function ($state, Server $server) {
abort_unless(user()?->can(SubuserPermission::StartupDockerImage, $server), 403);
$original = $server->image;
$server->forceFill(['image' => $state])->saveOrFail();
@@ -153,8 +151,6 @@ class Startup extends ServerFormPage
public function update(null|string|bool $state, ServerVariable $serverVariable): void
{
abort_unless(user()?->can(SubuserPermission::StartupUpdate, $this->getRecord()), 403);
if (!$serverVariable->variable->user_editable) {
return;
}

View File

@@ -133,7 +133,7 @@ class ActivityResource extends Resource
])
->filters([
SelectFilter::make('event')
->options(fn (Table $table) => $table->getQuery()->select('event')->distinct()->orderBy('event')->pluck('event', 'event'))
->options(fn (Table $table) => $table->getQuery()->pluck('event', 'event')->unique()->sort())
->searchable()
->preload(),
]);

View File

@@ -170,7 +170,7 @@ class BackupResource extends Resource
->color('primary')
->icon(TablerIcon::Download)
->authorize(fn () => user()?->can(SubuserPermission::BackupDownload, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup) => $downloadLinkService->handle($backup, user()), true)
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('exclude_restore')
->label(trans('server/backup.actions.restore.title'))
@@ -207,13 +207,17 @@ class BackupResource extends Resource
->property(['name' => $backup->name, 'truncate' => $data['truncate']]);
$log->transaction(function () use ($downloadLinkService, $daemonRepository, $backup, $server, $data) {
$url = $downloadLinkService->handle($backup, user());
// If the backup is for an S3 file we need to generate a unique Download link for
// it that will allow daemon to actually access the file.
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$url = $downloadLinkService->handle($backup, user());
}
// Update the status right away for the server so that we know not to allow certain
// actions against it via the Panel API.
$server->update(['status' => ServerState::RestoringBackup]);
$daemonRepository->setServer($server)->restore($backup, $url, $data['truncate']);
$daemonRepository->setServer($server)->restore($backup, $url ?? null, $data['truncate']);
});
return Notification::make()

View File

@@ -2,7 +2,6 @@
namespace App\Filament\Server\Resources\Files\Pages;
use App\Enums\NodeJwtScope;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Server\Resources\Files\FileResource;
@@ -40,7 +39,6 @@ class DownloadFiles extends Page
$token = $this->nodeJWTService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setScopes(NodeJwtScope::FileDownload)
->setUser(user())
->setClaims([
'file_path' => rawurldecode($path),

View File

@@ -16,8 +16,10 @@ use App\Livewire\AlertBanner;
use App\Models\File;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Services\Nodes\NodeJWTService;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Carbon\CarbonImmutable;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@@ -614,6 +616,28 @@ class ListFiles extends ListRecords
};
}
public function getUploadUrl(NodeJWTService $jwtService): string
{
/** @var Server $server */
$server = Filament::getTenant();
if (!user()?->can(SubuserPermission::FileCreate, $server)) {
abort(403, 'You do not have permission to upload files.');
}
$token = $jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setUser(user())
->setClaims(['server_uuid' => $server->uuid])
->handle($server->node, user()->id . $server->uuid);
return sprintf(
'%s/upload/file?token=%s',
$server->node->getConnectionAddress(),
$token->toString()
);
}
public function getUploadSizeLimit(): int
{
/** @var Server $server */
@@ -632,7 +656,9 @@ class ListFiles extends ListRecords
/** @var Server $server */
$server = Filament::getTenant();
abort_unless(user()?->can(SubuserPermission::FileCreate, $server), 403, 'You do not have permission to create folders.');
if (!user()?->can(SubuserPermission::FileCreate, $server)) {
abort(403, 'You do not have permission to create folders.');
}
try {
$this->getDaemonFileRepository()->createDirectory($folderPath, $this->path);

View File

@@ -26,7 +26,7 @@ class CreateSchedule extends CreateRecord
$hasFormWrapper = $this->hasFormWrapper();
return Action::make('exclude_create')
->label(trans('filament-panels::resources/pages/create-record.form.actions.create.label'))
->label(__('filament-panels::resources/pages/create-record.form.actions.create.label'))
->submit($hasFormWrapper ? $this->getSubmitFormLivewireMethodName() : null)
->action($hasFormWrapper ? null : $this->getSubmitFormLivewireMethodName())
->keyBindings(['mod+s']);

View File

@@ -95,7 +95,6 @@ class TasksRelationManager extends RelationManager
->label(trans('server/schedule.tasks.continue_on_failure'))
->boolean(),
])
->emptyStateHeading(trans('server/schedule.no_tasks'))
->recordActions([
EditAction::make()
->schema($this->getTaskForm($schedule))

View File

@@ -21,6 +21,7 @@ use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable;
use BackedEnum;
use Carbon\Carbon;
use Exception;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
@@ -44,7 +45,6 @@ use Filament\Support\Exceptions\Halt;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Carbon;
use Illuminate\Support\HtmlString;
use Throwable;
@@ -93,10 +93,9 @@ class ScheduleResource extends Resource
->required()
->default(1),
ToggleButtons::make('status')
->label(trans('server/schedule.status'))
->enum(ScheduleStatus::class)
->formatStateUsing(fn (?Schedule $schedule) => $schedule?->status->value ?? 'new')
->options(fn (?Schedule $schedule) => [$schedule?->status->value ?? 'new' => $schedule?->status->getLabel() ?? trans('server/schedule.schedule_status.new')])
->options(fn (?Schedule $schedule) => [$schedule?->status->value ?? 'new' => $schedule?->status->getLabel() ?? 'New'])
->visibleOn('view'),
Section::make(trans('server/schedule.cron'))
->description(function (Get $get) {

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Filament\Server\Resources\Webhooks\Pages;
use App\Enums\WebhookScope;
use App\Filament\Server\Resources\Webhooks\WebhookResource;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Filament\Resources\Pages\CreateRecord;
class CreateWebhook extends CreateRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = WebhookResource::class;
protected static bool $canCreateAnother = false;
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array
{
return [
$this->getCancelFormAction()->formId('form')->icon('tabler-cancel'),
$this->getCreateFormAction()->formId('form')->icon('tabler-plus'),
];
}
protected function getFormActions(): array
{
return [];
}
protected function mutateFormDataBeforeCreate(array $data): array
{
$server = Filament::getTenant();
abort_unless($server instanceof Server, 403);
$data['server_id'] = $server->id;
$data['scope'] = WebhookScope::Server;
return $data;
}
protected function getRedirectUrl(): string
{
return EditWebhook::getUrl(['record' => $this->getRecord()]);
}
public function mount(): void
{
parent::mount();
WebhookResource::sendHelpBanner();
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Filament\Server\Resources\Webhooks\Pages;
use App\Filament\Server\Resources\Webhooks\WebhookResource;
use App\Models\WebhookConfiguration;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWebhook extends EditRecord
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = WebhookResource::class;
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array
{
return [
DeleteAction::make()
->icon('tabler-trash'),
Action::make('test_now')
->label(trans('admin/webhook.test_now'))
->color('primary')
->icon('tabler-send')
->action(fn (WebhookConfiguration $record) => $record->run())
->tooltip(trans('admin/webhook.test_now_help')),
$this->getSaveFormAction()->formId('form')->icon('tabler-device-floppy'),
];
}
protected function getFormActions(): array
{
return [];
}
}

View File

@@ -1,39 +0,0 @@
<?php
namespace App\Filament\Server\Resources\Webhooks\Pages;
use App\Filament\Server\Resources\Webhooks\WebhookResource;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
use Filament\Facades\Filament;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\IconSize;
class ListWebhooks extends ListRecords
{
use CanCustomizeHeaderActions;
use CanCustomizeHeaderWidgets;
protected static string $resource = WebhookResource::class;
/** @return array<Action|ActionGroup> */
protected function getDefaultHeaderActions(): array
{
return [
CreateAction::make()
->icon('tabler-plus')
->hiddenLabel()
->iconButton()
->iconSize(IconSize::ExtraLarge)
->hidden(function () {
$server = Filament::getTenant();
return !$server instanceof Server || $server->webhookConfigurations()->count() <= 0;
}),
];
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Filament\Server\Resources\Webhooks\Pages;
use App\Filament\Server\Resources\Webhooks\WebhookResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewWebhook extends ViewRecord
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View File

@@ -1,358 +0,0 @@
<?php
namespace App\Filament\Server\Resources\Webhooks;
use App\Enums\WebhookScope;
use App\Enums\WebhookType;
use App\Filament\Server\Resources\Webhooks\Pages\CreateWebhook;
use App\Filament\Server\Resources\Webhooks\Pages\EditWebhook;
use App\Filament\Server\Resources\Webhooks\Pages\ListWebhooks;
use App\Filament\Server\Resources\Webhooks\Pages\ViewWebhook;
use App\Livewire\AlertBanner;
use App\Models\Server;
use App\Models\WebhookConfiguration;
use App\Traits\Filament\CanCustomizePages;
use App\Traits\Filament\CanCustomizeRelations;
use App\Traits\Filament\CanModifyForm;
use App\Traits\Filament\CanModifyTable;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ReplicateAction;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Support\Components\Component;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Livewire\Component as Livewire;
use Livewire\Features\SupportEvents\HandlesEvents;
class WebhookResource extends Resource
{
use CanCustomizePages;
use CanCustomizeRelations;
use CanModifyForm;
use CanModifyTable;
use HandlesEvents;
protected static ?string $model = WebhookConfiguration::class;
protected static string|\BackedEnum|null $navigationIcon = 'tabler-webhook';
protected static ?int $navigationSort = 11;
protected static ?string $slug = 'webhook';
public static function getNavigationLabel(): string
{
return trans('admin/webhook.nav_title');
}
public static function getModelLabel(): string
{
return trans('admin/webhook.model_label');
}
public static function getPluralModelLabel(): string
{
return trans('admin/webhook.model_label_plural');
}
public static function getNavigationBadge(): ?string
{
/** @var Server|null $server */
$server = Filament::getTenant();
if (!$server instanceof Server) {
return null;
}
$count = static::getModel()::where('server_id', $server->id)->count();
return $count > 0 ? (string) $count : null;
}
public static function defaultTable(Table $table): Table
{
return $table
->columns([
IconColumn::make('type')
->label(trans('admin/webhook.type')),
TextColumn::make('name')
->label(trans('admin/webhook.name')),
TextColumn::make('endpoint')
->label(trans('admin/webhook.endpoint'))
->formatStateUsing(fn (string $state) => str($state)->after('://'))
->limit(60)
->wrap(),
])
->recordActions([
ViewAction::make()
->hidden(fn (WebhookConfiguration $record) => static::canEdit($record)),
EditAction::make(),
ReplicateAction::make()
->iconButton()
->tooltip(trans('filament-actions::replicate.single.label'))
->modal(false)
->excludeAttributes(['created_at', 'updated_at']),
])
->groupedBulkActions([
DeleteBulkAction::make(),
])
->emptyStateIcon('tabler-webhook')
->emptyStateDescription('')
->emptyStateHeading(trans('admin/webhook.no_webhooks'))
->emptyStateActions([
CreateAction::make(),
])
->persistFiltersInSession()
->filters([
SelectFilter::make('type')
->options(WebhookType::class)
->attribute('type'),
]);
}
public static function defaultForm(Schema $schema): Schema
{
return $schema
->components([
ToggleButtons::make('type')
->label(trans('admin/webhook.type'))
->live()
->inline()
->options(WebhookType::class)
->default(WebhookType::Regular),
TextInput::make('endpoint')
->label(trans('admin/webhook.endpoint'))
->required()
->afterStateUpdated(fn (?string $state, Set $set) => $set('type', $state && str($state)->contains('discord.com') ? WebhookType::Discord : WebhookType::Regular)),
TextInput::make('name')
->label(trans('admin/webhook.name'))
->columnSpanFull()
->required(),
Section::make(trans('admin/webhook.regular'))
->hidden(fn (Get $get) => $get('type') === WebhookType::Discord)
->schema(fn () => self::getRegularFields())
->headerActions([
Action::make('reset_headers')
->label(trans('admin/webhook.reset_headers'))
->color('danger')
->icon('heroicon-o-trash')
->action(fn (Set $set) => $set('headers', [
'X-Webhook-Event' => '{{event}}',
])),
])
->formBefore(),
Section::make(trans('admin/webhook.discord'))
->hidden(fn (Get $get) => $get('type') === WebhookType::Regular)
->afterStateUpdated(fn (Livewire $livewire) => $livewire->dispatch('refresh-widget'))
->schema(fn () => self::getDiscordFields())
->poll('15s')
->view('filament.components.webhooksection')
->aside()
->formBefore()
->columnSpanFull(),
Section::make(trans('admin/webhook.events'))
->schema([
CheckboxList::make('events')
->live()
->options(fn () => WebhookConfiguration::filamentCheckboxList(WebhookScope::Server))
->searchable()
->bulkToggleable()
->columns(3)
->columnSpanFull()
->required(),
]),
]);
}
/** @return Component[]
* @throws Exception
*/
private static function getRegularFields(): array
{
return [
KeyValue::make('headers')
->label(trans('admin/webhook.headers'))
->default(fn () => [
'X-Webhook-Event' => '{{event}}',
]),
];
}
/** @return Component[]
* @throws Exception
*/
private static function getDiscordFields(): array
{
return [
Section::make(trans('admin/webhook.discord_message.profile'))
->collapsible()
->schema([
TextInput::make('username')
->live(debounce: 500)
->label(trans('admin/webhook.discord_message.username')),
TextInput::make('avatar_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_message.avatar_url')),
]),
Section::make(trans('admin/webhook.discord_message.message'))
->collapsible()
->schema([
TextInput::make('content')
->label(trans('admin/webhook.discord_message.message'))
->live(debounce: 500)
->required(fn (Get $get) => empty($get('embeds'))),
TextInput::make('thread_name')
->label(trans('admin/webhook.discord_message.forum_thread')),
CheckboxList::make('flags')
->label(trans('admin/webhook.discord_embed.flags'))
->options([
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds'), // Discord flag: SUPPRESS_EMBEDS (4)
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications'), // Discord flag: SUPPRESS_NOTIFICATIONS (4096)
])
->descriptions([
(1 << 2) => trans('admin/webhook.discord_message.supress_embeds_text'),
(1 << 12) => trans('admin/webhook.discord_message.supress_notifications_text'),
]),
CheckboxList::make('allowed_mentions')
->label(trans('admin/webhook.discord_embed.allowed_mentions'))
->options([
'roles' => trans('admin/webhook.discord_embed.roles'),
'users' => trans('admin/webhook.discord_embed.users'),
'everyone' => trans('admin/webhook.discord_embed.everyone'),
]),
]),
Repeater::make('embeds')
->live(debounce: 500)
->itemLabel(fn (array $state) => $state['title'] ?? '')
->addActionLabel(trans('admin/webhook.discord_embed.add_embed'))
->required(fn (Get $get) => empty($get('content')))
->reorderable()
->collapsible()
->maxItems(10)
->schema([
Section::make(trans('admin/webhook.discord_embed.author'))
->collapsible()
->collapsed()
->schema([
TextInput::make('author.name')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author'))
->required(fn (Get $get) => filled($get('author.url')) || filled($get('author.icon_url'))),
TextInput::make('author.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author_url')),
TextInput::make('author.icon_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.author_icon_url')),
]),
Section::make(trans('admin/webhook.discord_embed.body'))
->collapsible()
->collapsed()
->schema([
TextInput::make('title')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.title'))
->required(fn (Get $get) => $get('name') === null),
Textarea::make('name')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.body'))
->required(fn (Get $get) => $get('title') === null),
ColorPicker::make('color')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.color'))
->hex(),
TextInput::make('url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.url')),
]),
Section::make(trans('admin/webhook.discord_embed.images'))
->collapsible()
->collapsed()
->schema([
TextInput::make('image.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.image_url')),
TextInput::make('thumbnail.url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.image_thumbnail')),
]),
Section::make(trans('admin/webhook.discord_embed.footer'))
->collapsible()
->collapsed()
->schema([
TextInput::make('footer.text')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.footer')),
Checkbox::make('has_timestamp')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.has_timestamp')),
TextInput::make('footer.icon_url')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.footer_icon_url')),
]),
Section::make(trans('admin/webhook.discord_embed.fields'))
->collapsible()->collapsed()
->schema([
Repeater::make('fields')
->reorderable()
->addActionLabel(trans('admin/webhook.discord_embed.add_field'))
->collapsible()
->schema([
TextInput::make('name')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.field_name'))
->required(),
Textarea::make('value')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.field_value'))
->rows(4)
->required(),
Checkbox::make('inline')
->live(debounce: 500)
->label(trans('admin/webhook.discord_embed.inline_field')),
]),
]),
]),
];
}
public static function sendHelpBanner(): void
{
AlertBanner::make('discord_webhook_help')
->title(trans('admin/webhook.help'))
->body(trans('admin/webhook.help_text'))
->icon('tabler-question-mark')
->info()
->send();
}
/** @return array<string, PageRegistration> */
public static function getDefaultPages(): array
{
return [
'index' => ListWebhooks::route('/'),
'create' => CreateWebhook::route('/create'),
'view' => ViewWebhook::route('/{record}'),
'edit' => EditWebhook::route('/{record}/edit'),
];
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Filament\Server\Widgets;
use App\Enums\NodeJwtScope;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Livewire\AlertBanner;
@@ -47,13 +46,14 @@ class ServerConsole extends Widget
protected function getToken(): string
{
throw_if(!$this->user || !$this->server || $this->user->cannot(SubuserPermission::WebsocketConnect, $this->server), new HttpForbiddenException('You do not have permission to connect to this server\'s websocket.'));
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.');
}
$permissions = $this->getUserPermissionsService->handle($this->server, $this->user);
return $this->nodeJWTService
->setExpiresAt(now()->addMinutes(10)->toImmutable())
->setScopes(NodeJwtScope::Websocket)
->setUser($this->user)
->setClaims([
'server_uuid' => $this->server->uuid,

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