Compare commits

...

44 Commits

Author SHA1 Message Date
github-actions[bot]
483145aac8 New Crowdin Translations (#2407)
Co-authored-by: notAreYouScared <1757840+notAreYouScared@users.noreply.github.com>
2026-06-22 09:10:23 -04:00
kazamin
5953a268c4 fix(i18n): add missing labels and translation keys across multiple resources (#2401) 2026-06-22 04:58:39 -04:00
Lance Pioch
2486446e23 Fix root-owned files breaking the translations PR step (#2405) 2026-06-22 00:09:20 -04:00
Lance Pioch
553c87597f Bump create-pull-request to v8 in the translations workflow (#2404) 2026-06-21 23:37:39 -04:00
Lance Pioch
0f1aa46aeb Fix translations workflow not opening PRs (#2403) 2026-06-21 23:23:03 -04:00
Charles
2771ccc421 Add proper checks for subuser permissions (#2402) 2026-06-21 16:37:42 -04:00
wuzuantw
fab5da496f fix: Egg edit form not refreshing after import or update (#2358)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2026-06-19 15:10:29 -04:00
JoanFo
ed7d4f5a6b Webhook fix + server area webhooks (#2377) 2026-06-18 23:28:14 -04:00
Harry Youd
d5df688638 add p:egg:normalize command (#2379) 2026-06-18 12:22:13 -04:00
Charles
0e75027c12 Add Jwt Scopes from upstream (#2397) 2026-06-17 06:42:03 -04:00
Boy132
475a454806 Add correct form requests for updating mount relations (eggs, nodes, servers) (#2394) 2026-06-17 12:40:33 +02:00
Charles
3a015e8081 composer update (#2398) 2026-06-17 06:34:42 -04:00
Dr.Reeves
29d774eae4 Enhance Docker publish workflow with scheduling (#2301)
Co-authored-by: Lance Pioch <git@lance.sh>
2026-06-16 12:31:50 -04:00
Boy132
2e7e4bb6d4 Add can check to suspend actions (#2393) 2026-06-16 11:29:44 +02:00
sangege
420ae022e9 Fix Crowdin language mapping and integrate automated Pint formatting (#2383) 2026-06-14 19:07:07 -04:00
Harry Youd
666a409b66 docker: parse TRUSTED_PROXIES from .env (#2378) 2026-06-12 10:31:57 -04:00
Lance Pioch
2b9c08dc5d Speed up ci checks (#2387) 2026-06-12 10:29:48 -04:00
Quinten
21ed58839c Bump all github action runners (#2386) 2026-06-11 17:21:41 +02:00
Michael (Parker) Parker
69e88a2ea7 Fix docker issues (#2375) 2026-06-11 10:00:01 -04:00
stdpi
cc2aa38ad4 Skip daemon RPC calls for servers on maintenance-mode nodes (#2371) 2026-06-11 07:05:47 -04:00
Lance Pioch
1bd4492cdc Laravel 13.15.0 Shift (#2382)
Co-authored-by: Shift <shift@laravelshift.com>
2026-06-09 19:22:44 -04:00
stdpi
90fbc87a8a Fix sed command syntax in docker-publish.yml (#2372) 2026-06-09 14:22:16 -05:00
Boy132
ac3db5af71 Add back bg-white to small stat (#2381) 2026-06-09 15:59:39 +02:00
Noah Stefano
a46443e5e4 restore daemon error surface for wings 4xx (#2325) 2026-06-09 05:56:38 -04:00
O2theC
f791a3a686 new api settings ui stuff (#2350) 2026-06-09 05:56:24 -04:00
Quinten
b3fe96f262 docker: Update php to 8.5 and node to 22 (#2380) 2026-06-08 16:02:30 -04:00
Lance Pioch
4d114571e0 Laravel 13.14.0 Shift (#2374)
Co-authored-by: Shift <shift@laravelshift.com>
2026-06-08 13:59:59 -04:00
stdpi
136224224c Fix dark mode (#2373) 2026-06-03 12:19:57 -04:00
Boy132
28452e2820 Return empty array instead of null when plugin update checking fails (#2364) 2026-06-02 21:05:11 +02:00
Boy132
be6abcefe7 Add acceptedFileTypes to font upload and add can check to save settings method (#2366) 2026-06-02 21:04:38 +02:00
Charles
2a328a24ac Improve Global Searching in admin panel (#2327) 2026-05-28 05:50:28 -04:00
Boy132
02e4527b84 Replace hardcoded pelican with config value (#2362) 2026-05-28 09:18:20 +02:00
Boy132
f676b6c794 Add error handling when trying to get extension from url (#2361) 2026-05-28 08:59:47 +02:00
Charles
cb21f13059 composer upgrade (#2359) 2026-05-27 17:10:20 -04:00
Boy132
13bb47e6eb Make sure plugin name doesn't contain path when importing (#2356) 2026-05-26 13:54:30 +02:00
sangege
447a8c1ff7 ci: Fix Crowdin synchronization paths and optimize configs (#2354) 2026-05-26 06:03:55 -04:00
sangege
a99f18b189 chore(i18n): Automate Crowdin source sync and translation pull workflows (#2339)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-22 05:34:15 -04:00
Lance Pioch
8f01095f9d Update readme.md (#2351) 2026-05-21 09:15:24 -04:00
Lance Pioch
110fdd2bdb Laravel 13.11.2 Shift (#2348)
Co-authored-by: Shift <shift@laravelshift.com>
2026-05-20 16:19:39 -04:00
Nikolaos Karaolidis
81bb09f4cf Add option to disable password login (#2318)
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2026-05-20 06:33:18 -04:00
Charles
dcba4a91d0 Listen for install completed event (#2345) 2026-05-20 03:36:52 -04:00
O2theC
8f7dbe5e16 move some hardcoded ratelimits for the api to env vars (#2332) 2026-05-19 07:25:18 -04:00
Noah Stefano
92bfccb2a4 Fix multi file upload (#2331) 2026-05-19 05:56:55 -04:00
Charles
11a1581a1d Update Node.js package dependencies (#2329) 2026-05-11 04:14:44 -04:00
345 changed files with 11279 additions and 3349 deletions

View File

@@ -16,7 +16,7 @@ jobs:
node-version: [22, 24]
steps:
- name: Code Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- 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@v4
uses: actions/setup-node@v6
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@v4
uses: actions/checkout@v6
- 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@v4
uses: actions/cache@v5
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: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
options: --tmpfs /var/lib/mysql:rw --health-cmd="mysqladmin ping" --health-interval=2s --health-timeout=5s --health-retries=30
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@v4
uses: actions/checkout@v6
- 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@v4
uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -129,12 +129,6 @@ 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:
@@ -157,7 +151,7 @@ jobs:
MYSQL_DATABASE: testing
ports:
- 3306
options: --health-cmd="mariadb-admin ping || mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
options: --tmpfs /var/lib/mysql:rw --health-cmd="mariadb-admin ping || mysqladmin ping" --health-interval=2s --health-timeout=5s --health-retries=30
env:
DB_CONNECTION: mariadb
DB_HOST: 127.0.0.1
@@ -165,7 +159,7 @@ jobs:
DB_USERNAME: root
steps:
- name: Code Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get cache directory
id: composer-cache
@@ -173,7 +167,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -197,12 +191,6 @@ 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:
@@ -217,6 +205,13 @@ 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 }}
@@ -228,10 +223,11 @@ jobs:
ports:
- 5432:5432
options: >-
--tmpfs ${{ matrix.tmpfs }}:rw
--health-cmd pg_isready
--health-interval 10s
--health-interval 2s
--health-timeout 5s
--health-retries 5
--health-retries 30
env:
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
@@ -240,7 +236,7 @@ jobs:
DB_PASSWORD: postgres
steps:
- name: Code Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get cache directory
id: composer-cache
@@ -248,7 +244,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
@@ -269,11 +265,5 @@ 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,6 +7,8 @@ on:
release:
types:
- published
schedule:
- cron: '0 0 * * 1'
env:
REGISTRY: ghcr.io
@@ -31,13 +33,13 @@ jobs:
platform: linux/arm64
steps:
- name: Code checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Docker buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Build the base PHP image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: ./Dockerfile.base
@@ -52,7 +54,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@v4
uses: actions/upload-artifact@v7
with:
name: base-php-${{ matrix.arch }}.tar
path: base-php-${{ matrix.arch }}.tar
@@ -75,32 +77,54 @@ 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@v4
uses: actions/checkout@v6
with:
ref: ${{ steps.release_info.outputs.schedule_tag || github.ref }}
- name: Docker metadata
id: docker_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
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 }}
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=ref,event=tag
type=ref,event=branch
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
# 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@v3
uses: docker/setup-buildx-action@v4
with:
driver-opts: network=host
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -109,16 +133,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@v4
- uses: actions/download-artifact@v8
with:
name: base-php-amd64.tar
# Download the base PHP image ARM64
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
name: base-php-arm64.tar
@@ -133,27 +157,32 @@ 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'"
if: "(github.event_name == 'release' && github.event.action == 'published') || github.event_name == 'schedule'"
run: |
sed -i "s/'version' => 'canary',/'version' => '${{ steps.build_info.outputs.version_tag }}',/" config/app.php
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
- name: Build and Push (tag)
uses: docker/build-push-action@v6
if: "github.event_name == 'release' && github.event.action == 'published'"
uses: docker/build-push-action@v7
if: "(github.event_name == 'release' && github.event.action == 'published') || github.event_name == 'schedule'"
with:
context: .
file: ./Dockerfile
push: true
platforms: 'linux/amd64,linux/arm64'
build-args: |
VERSION=${{ steps.build_info.outputs.version_tag }}
VERSION=${{ github.event_name == 'schedule' && steps.release_info.outputs.version_tag || 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@v6
uses: docker/build-push-action@v7
if: "github.event_name == 'push' && contains(github.ref, 'main')"
with:
context: .

View File

@@ -0,0 +1,54 @@
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@v4
uses: actions/checkout@v6
- 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@v4
uses: actions/checkout@v6
- 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@v4
uses: actions/cache@v5
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@v4
uses: actions/checkout@v6
- 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@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: "yarn"

32
.github/workflows/upload-sources.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
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:20-alpine AS yarn
FROM --platform=$TARGETOS/$TARGETARCH node:22-alpine AS yarn
WORKDIR /build
@@ -74,16 +74,14 @@ 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 /pelican-data/plugins /var/run/supervisord \
&& rm -rf /var/www/html/plugins \
# Symlinks for env, database, storage, and plugins
RUN mkdir -p /pelican-data/storage /var/run/supervisord \
# Symlinks for env, database, storage
&& ln -s /pelican-data/.env /var/www/html/.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -s /pelican-data/storage /var/www/html/public/storage \
&& ln -s /pelican-data/storage /var/www/html/storage/app/public \
&& ln -s /pelican-data/plugins /var/www/html \
# Allow www-data write permissions where necessary
&& chown -R www-data: /pelican-data .env ./storage ./plugins ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chown -R www-data: /pelican-data .env ./storage ./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.4-fpm-alpine
FROM --platform=$TARGETOS/$TARGETARCH php:8.5-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.4-fpm-alpine AS base
FROM --platform=$TARGETOS/$TARGETARCH php:8.5-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:20-alpine AS yarn
FROM --platform=$TARGETOS/$TARGETARCH node:22-alpine AS yarn
WORKDIR /build
@@ -79,16 +79,14 @@ 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 /pelican-data/plugins /var/run/supervisord \
&& rm -rf /var/www/html/plugins \
# Symlinks for env, database, storage, and plugins
RUN mkdir -p /pelican-data/storage /var/run/supervisord \
# Symlinks for env, database, storage
&& ln -s /pelican-data/.env /var/www/html/.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -s /pelican-data/storage /var/www/html/public/storage \
&& ln -s /pelican-data/storage /var/www/html/storage/app/public \
&& ln -s /pelican-data/plugins /var/www/html \
# Allow www-data write permissions where necessary
&& chown -R www-data: /pelican-data .env ./storage ./plugins ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chown -R www-data: /pelican-data .env ./storage ./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

@@ -35,8 +35,13 @@ class CheckEggUpdatesCommand extends Command
return;
}
$ext = strtolower(pathinfo(parse_url($egg->update_url, PHP_URL_PATH), PATHINFO_EXTENSION));
$isYaml = in_array($ext, ['yaml', 'yml']);
$extension = strtolower(pathinfo(parse_url($egg->update_url, PHP_URL_PATH), PATHINFO_EXTENSION));
if (empty($extension)) {
throw new Exception('Unsupported file format.');
}
$isYaml = in_array($extension, ['yaml', 'yml']);
$local = $isYaml
? Yaml::parse($exporterService->handle($egg->id, EggFormat::YAML))

View File

@@ -0,0 +1,152 @@
<?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 Carbon\Carbon;
use Illuminate\Console\Command;
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);
}
/**
* @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

@@ -0,0 +1,12 @@
<?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::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),
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')),
};
}

View File

@@ -0,0 +1,17 @@
<?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('The file you are attempting to open is too large to view in the file editor.');
parent::__construct(trans('exceptions.server.file_too_large'));
}
}

View File

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

View File

@@ -52,13 +52,13 @@ class GSLTokenSchema implements FeatureSchemaInterface
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Invalid GSL token')
->modalDescription('It seems like your Gameserver Login Token (GSL token) is invalid or has expired.')
->modalSubmitActionLabel('Update GSL Token')
->modalHeading(trans('server/feature.gsl_token.heading'))
->modalDescription(trans('server/feature.gsl_token.description'))
->modalSubmitActionLabel(trans('server/feature.gsl_token.submit'))
->disabledSchema(fn () => !user()?->can(SubuserPermission::StartupUpdate, $server))
->schema([
TextEntry::make('info')
->label(new HtmlString(Blade::render('You can either <x-filament::link href="https://steamcommunity.com/dev/managegameservers" target="_blank">generate a new one</x-filament::link> and enter it below or leave the field blank to remove it completely.'))),
->label(new HtmlString(Blade::render(trans('server/feature.gsl_token.info')))),
TextInput::make('gsltoken')
->label('GSL Token')
->rules([
@@ -104,13 +104,13 @@ class GSLTokenSchema implements FeatureSchemaInterface
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title('GSL Token updated')
->body('Server will restart now.')
->title(trans('server/feature.gsl_token.updated'))
->body(trans('server/feature.restart_now'))
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not update GSL Token')
->title(trans('server/feature.gsl_token.failed'))
->body($exception->getMessage())
->danger()
->send();

View File

@@ -41,15 +41,15 @@ class JavaVersionSchema implements FeatureSchemaInterface
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Unsupported Java Version')
->modalDescription('This server is currently running an unsupported version of Java and cannot be started.')
->modalSubmitActionLabel('Update Docker Image')
->modalHeading(trans('server/feature.java_version.heading'))
->modalDescription(trans('server/feature.java_version.description'))
->modalSubmitActionLabel(trans('server/feature.java_version.submit'))
->disabledSchema(fn () => !user()?->can(SubuserPermission::StartupDockerImage, $server))
->schema([
TextEntry::make('java')
->label('Please select a supported version from the list below to continue starting the server.'),
->label(trans('server/feature.java_version.select_version')),
Select::make('image')
->label('Docker Image')
->label(trans('server/feature.java_version.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)
@@ -73,13 +73,13 @@ class JavaVersionSchema implements FeatureSchemaInterface
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title('Docker image updated')
->body('Server will restart now.')
->title(trans('server/feature.java_version.updated'))
->body(trans('server/feature.restart_now'))
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not update docker image')
->title(trans('server/feature.java_version.failed'))
->body($exception->getMessage())
->danger()
->send();

View File

@@ -32,9 +32,9 @@ class MinecraftEulaSchema implements FeatureSchemaInterface
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Minecraft EULA')
->modalDescription(new HtmlString(Blade::render('By pressing "I Accept" below you are indicating your agreement to the <x-filament::link href="https://minecraft.net/eula" target="_blank">Minecraft EULA </x-filament::link>.')))
->modalSubmitActionLabel('I Accept')
->modalHeading(trans('server/feature.eula.heading'))
->modalDescription(new HtmlString(Blade::render(trans('server/feature.eula.description'))))
->modalSubmitActionLabel(trans('server/feature.eula.accept'))
->action(function (DaemonFileRepository $fileRepository, DaemonServerRepository $serverRepository) {
try {
/** @var Server $server */
@@ -45,13 +45,13 @@ class MinecraftEulaSchema implements FeatureSchemaInterface
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title('Minecraft EULA accepted')
->body('Server will restart now.')
->title(trans('server/feature.eula.accepted'))
->body(trans('server/feature.restart_now'))
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not accept Minecraft EULA')
->title(trans('server/feature.eula.failed'))
->body($exception->getMessage())
->danger()
->send();

View File

@@ -5,7 +5,6 @@ namespace App\Extensions\Features\Schemas;
use App\Enums\TablerIcon;
use App\Extensions\Features\FeatureSchemaInterface;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class PIDLimitSchema implements FeatureSchemaInterface
@@ -33,35 +32,9 @@ class PIDLimitSchema implements FeatureSchemaInterface
return Action::make($this->getId())
->requiresConfirmation()
->icon(TablerIcon::AlertTriangle)
->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')
->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'))
->action(fn () => null);
}
}

View File

@@ -4,7 +4,6 @@ namespace App\Extensions\Features\Schemas;
use App\Extensions\Features\FeatureSchemaInterface;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class SteamDiskSpaceSchema implements FeatureSchemaInterface
@@ -27,28 +26,9 @@ class SteamDiskSpaceSchema implements FeatureSchemaInterface
{
return Action::make($this->getId())
->requiresConfirmation()
->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')
->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'))
->action(fn () => null);
}
}

View File

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

View File

@@ -31,11 +31,13 @@ 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;
@@ -794,24 +796,172 @@ class Settings extends Page implements HasSchemas
]),
Section::make(trans('admin/setting.misc.api.title'))
->description(trans('admin/setting.misc.api.helper'))
->columns()
->columns(1)
->collapsible()
->collapsed()
->schema([
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'))),
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')),
]),
]),
Section::make(trans('admin/setting.misc.server.title'))
->description(trans('admin/setting.misc.server.helper'))
@@ -830,13 +980,14 @@ 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('ConsoleFonts')
FileUpload::make('console_font')
->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'))
@@ -864,9 +1015,11 @@ class Settings extends Page implements HasSchemas
public function save(): void
{
abort_unless(user()?->can('update settings'), 403);
try {
$data = $this->form->getState();
unset($data['ConsoleFonts']);
unset($data['console_font']);
$data = array_map(function ($value) {
// Convert bools to a string, so they are correctly written to the .env file
@@ -913,6 +1066,5 @@ class Settings extends Page implements HasSchemas
->authorize(fn () => user()?->can('update settings'))
->keyBindings(['mod+s']),
];
}
}

View File

@@ -89,6 +89,7 @@ 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,6 +66,7 @@ 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

@@ -303,7 +303,11 @@ 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),
->multiple(false)
->after(function () {
$this->record->refresh();
$this->refreshForm();
}),
Action::make('save')
->hiddenLabel()
->action('save')
@@ -316,6 +320,8 @@ 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,7 +38,9 @@ class ListEggs extends ListRecords
*/
public function table(Table $table): Table
{
$defaultEggIcon = 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg')));
$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)));
return $table
->searchable(true)

View File

@@ -665,6 +665,7 @@ 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'))
@@ -764,17 +765,20 @@ 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,10 +83,12 @@ 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,6 +67,7 @@ class ServersRelationManager extends RelationManager
->label(trans('admin/node.backups'))
->numeric()
->sortable(),
]);
])
->emptyStateHeading(trans('admin/server.no_servers'));
}
}

View File

@@ -242,6 +242,8 @@ 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))
@@ -249,6 +251,7 @@ 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()
@@ -260,7 +263,7 @@ class PluginResource extends Resource
/** @var UploadedFile $file */
$file = $data['file'];
$pluginName = str($file->getClientOriginalName())->before('.zip')->toString();
$pluginName = str($file->getClientOriginalName())->basename()->before('.zip')->toString();
if (Plugin::where('id', $pluginName)->exists()) {
throw new Exception(trans('admin/plugin.notifications.import_exists'));
@@ -285,6 +288,8 @@ 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))

View File

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

View File

@@ -69,6 +69,7 @@ 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,6 +79,7 @@ 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

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

View File

@@ -3,6 +3,7 @@
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;
@@ -10,6 +11,7 @@ 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
{
@@ -44,6 +46,15 @@ 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', []);

View File

@@ -2,10 +2,16 @@
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
{
@@ -13,4 +19,24 @@ 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,12 +3,14 @@
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;
@@ -16,7 +18,6 @@ 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;
@@ -26,14 +27,19 @@ 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;
@@ -42,7 +48,6 @@ 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
@@ -57,7 +62,7 @@ class WebhookResource extends Resource
protected static string|BackedEnum|null $navigationIcon = TablerIcon::Webhook;
protected static ?string $recordTitleAttribute = 'description';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationLabel(): string
{
@@ -87,17 +92,26 @@ class WebhookResource extends Resource
public static function defaultTable(Table $table): Table
{
return $table
->groups([
'server.name',
])
->columns([
IconColumn::make('type'),
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'),
TextColumn::make('endpoint')
->label(trans('admin/webhook.table.endpoint'))
->label(trans('admin/webhook.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()
@@ -108,7 +122,7 @@ class WebhookResource extends Resource
->tooltip(trans('filament-actions::replicate.single.label'))
->modal(false)
->excludeAttributes(['created_at', 'updated_at'])
->beforeReplicaSaved(fn (WebhookConfiguration $replica) => $replica->description .= ' Copy ' . now()->format('Y-m-d H:i:s'))
->beforeReplicaSaved(fn (WebhookConfiguration $replica) => $replica->name .= ' Copy ' . now()->format('Y-m-d H:i:s'))
->successRedirectUrl(fn (WebhookConfiguration $replica) => EditWebhookConfiguration::getUrl(['record' => $replica])),
])
->toolbarActions([
@@ -125,6 +139,9 @@ 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()),
]);
}
@@ -132,50 +149,74 @@ class WebhookResource extends Resource
{
return $schema
->components([
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()
Tabs::make('webhook_tabs')
->persistTab()
->columnSpanFull()
->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(),
->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(),
]),
]),
]),
]);
}
@@ -200,134 +241,150 @@ class WebhookResource extends Resource
private static function getDiscordFields(): array
{
return [
Section::make(trans('admin/webhook.discord_message.profile'))
->collapsible()
Grid::make()
->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'),
(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()
Section::make()
->columnSpanFull()
->poll('15s')
->view('filament.components.webhooksection'),
Grid::make()
->columnSpan(8)
->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'))
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('name')
TextInput::make('content')
->label(trans('admin/webhook.discord_message.message'))
->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')),
->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()
->columnSpanFull()
->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')),
]),
]),
]),
]),
]),
];
}

View File

@@ -1,163 +0,0 @@
<?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' ? 'You don\'t own any servers!' : 'You don\'t have access to any servers!')
->emptyStateHeading(fn () => $this->activeTab === 'my' ? trans('server/dashboard.empty_own') : trans('server/dashboard.empty_other'))
->persistFiltersInSession()
->filters([
SelectFilter::make('egg')

View File

@@ -21,6 +21,8 @@ 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

@@ -110,6 +110,10 @@ class UploadIcon extends Action
$extension = strtolower(pathinfo(parse_url($data['icon_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
if (empty($extension)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$record->writeIcon($extension, $content);
Notification::make()

View File

@@ -465,13 +465,12 @@ class EditProfile extends BaseEditProfile
'mixed' => trans('profile.mixed'),
]),
ToggleButtons::make('button_style')
->label('Button Style')
->label(trans('profile.button_style'))
->inline()
->default('icon')
->options([
true => 'Icon',
false => 'Icon Button',
true => trans('profile.icon'),
false => trans('profile.icon_button'),
]),
]),
Section::make(trans('profile.admin'))

View File

@@ -13,6 +13,7 @@ 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\Validation\ValidationException;
class Login extends BaseLogin
@@ -32,6 +33,12 @@ class Login extends BaseLogin
public function form(Schema $schema): Schema
{
if (config('auth.disable_password_login', false)) {
return $schema->components([
$this->getOAuthFormComponent(),
]);
}
$components = [
$this->getLoginFormComponent(),
$this->getPasswordFormComponent(),
@@ -108,11 +115,22 @@ class Login extends BaseLogin
->url(route('auth.oauth.redirect', ['driver' => $id], false));
}
return Actions::make($actions);
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();
}
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

@@ -56,6 +56,7 @@ 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();
@@ -89,6 +90,7 @@ 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();
@@ -151,6 +153,8 @@ 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

@@ -2,6 +2,7 @@
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;
@@ -39,6 +40,7 @@ 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,10 +16,8 @@ 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;
@@ -616,28 +614,6 @@ 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 */

View File

@@ -95,6 +95,7 @@ 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

@@ -93,9 +93,10 @@ 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() ?? 'New'])
->options(fn (?Schedule $schedule) => [$schedule?->status->value ?? 'new' => $schedule?->status->getLabel() ?? trans('server/schedule.schedule_status.new')])
->visibleOn('view'),
Section::make(trans('server/schedule.cron'))
->description(function (Get $get) {

View File

@@ -0,0 +1,59 @@
<?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

@@ -0,0 +1,41 @@
<?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

@@ -0,0 +1,39 @@
<?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

@@ -0,0 +1,19 @@
<?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

@@ -0,0 +1,358 @@
<?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,6 +2,7 @@
namespace App\Filament\Server\Widgets;
use App\Enums\NodeJwtScope;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Livewire\AlertBanner;
@@ -54,6 +55,7 @@ class ServerConsole extends Widget
return $this->nodeJWTService
->setExpiresAt(now()->addMinutes(10)->toImmutable())
->setScopes(NodeJwtScope::Websocket)
->setUser($this->user)
->setClaims([
'server_uuid' => $this->server->uuid,

View File

@@ -6,10 +6,14 @@ use App\Exceptions\Model\DataValidationException;
use App\Exceptions\Service\HasActiveServersException;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Eggs\GetEggsRequest;
use App\Http\Requests\Api\Application\Mounts\DeleteMountRelationRequest;
use App\Http\Requests\Api\Application\Mounts\DeleteMountRequest;
use App\Http\Requests\Api\Application\Mounts\GetMountRequest;
use App\Http\Requests\Api\Application\Mounts\StoreMountRequest;
use App\Http\Requests\Api\Application\Mounts\UpdateMountEggsRequest;
use App\Http\Requests\Api\Application\Mounts\UpdateMountNodesRequest;
use App\Http\Requests\Api\Application\Mounts\UpdateMountRequest;
use App\Http\Requests\Api\Application\Mounts\UpdateMountServersRequest;
use App\Http\Requests\Api\Application\Nodes\GetNodesRequest;
use App\Http\Requests\Api\Application\Servers\GetServerRequest;
use App\Models\Mount;
@@ -18,7 +22,6 @@ use App\Transformers\Api\Application\MountTransformer;
use App\Transformers\Api\Application\NodeTransformer;
use App\Transformers\Api\Application\ServerTransformer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Ramsey\Uuid\Uuid;
use Spatie\QueryBuilder\QueryBuilder;
use Throwable;
@@ -164,14 +167,9 @@ class MountController extends ApplicationApiController
*
* @return array<array-key, mixed>
*/
public function addEggs(Request $request, Mount $mount): array
public function addEggs(UpdateMountEggsRequest $request, Mount $mount): array
{
$validatedData = $request->validate([
'eggs' => 'required|array|exists:eggs,id',
'eggs.*' => 'integer',
]);
$mount->eggs()->attach($validatedData['eggs']);
$mount->eggs()->attach($request->validated('eggs'));
return $this->fractal->item($mount)
->transformWith($this->getTransformer(MountTransformer::class))
@@ -185,14 +183,9 @@ class MountController extends ApplicationApiController
*
* @return array<array-key, mixed>
*/
public function addNodes(Request $request, Mount $mount): array
public function addNodes(UpdateMountNodesRequest $request, Mount $mount): array
{
$validatedData = $request->validate([
'nodes' => 'required|array|exists:nodes,id',
'nodes.*' => 'integer',
]);
$mount->nodes()->attach($validatedData['nodes']);
$mount->nodes()->attach($request->validated('nodes'));
return $this->fractal->item($mount)
->transformWith($this->getTransformer(MountTransformer::class))
@@ -206,14 +199,9 @@ class MountController extends ApplicationApiController
*
* @return array<array-key, mixed>
*/
public function addServers(Request $request, Mount $mount): array
public function addServers(UpdateMountServersRequest $request, Mount $mount): array
{
$validatedData = $request->validate([
'servers' => 'required|array|exists:servers,id',
'servers.*' => 'integer',
]);
$mount->servers()->attach($validatedData['servers']);
$mount->servers()->attach($request->validated('servers'));
return $this->fractal->item($mount)
->transformWith($this->getTransformer(MountTransformer::class))
@@ -225,7 +213,7 @@ class MountController extends ApplicationApiController
*
* Deletes an egg from the mount's many-to-many relation.
*/
public function deleteEgg(Mount $mount, int $egg_id): JsonResponse
public function deleteEgg(DeleteMountRelationRequest $request, Mount $mount, int $egg_id): JsonResponse
{
$mount->eggs()->detach($egg_id);
@@ -237,7 +225,7 @@ class MountController extends ApplicationApiController
*
* Deletes a node from the mount's many-to-many relation.
*/
public function deleteNode(Mount $mount, int $node_id): JsonResponse
public function deleteNode(DeleteMountRelationRequest $request, Mount $mount, int $node_id): JsonResponse
{
$mount->nodes()->detach($node_id);
@@ -249,7 +237,7 @@ class MountController extends ApplicationApiController
*
* Deletes a server from the mount's many-to-many relation.
*/
public function deleteServer(Mount $mount, int $server_id): JsonResponse
public function deleteServer(DeleteMountRelationRequest $request, Mount $mount, int $server_id): JsonResponse
{
$mount->servers()->detach($server_id);

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\NodeJwtScope;
use App\Facades\Activity;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Files\ChmodFilesRequest;
@@ -93,6 +94,7 @@ class FileController extends ClientApiController
{
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setScopes(NodeJwtScope::FileDownload)
->setUser($request->user())
->setClaims([
'file_path' => rawurldecode($request->get('file')),

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\NodeJwtScope;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Files\UploadFileRequest;
use App\Models\Server;
@@ -45,6 +46,7 @@ class FileUploadController extends ClientApiController
{
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setScopes(NodeJwtScope::FileUpload)
->setUser($user)
->setClaims(['server_uuid' => $server->uuid])
->handle($server->node, $user->id . $server->uuid);

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\NodeJwtScope;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Http\Controllers\Api\Client\ClientApiController;
@@ -58,6 +59,7 @@ class WebsocketController extends ClientApiController
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(10))
->setScopes(NodeJwtScope::Websocket)
->setUser($request->user())
->setClaims([
'server_uuid' => $server->uuid,

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Remote;
use App\Events\ActivityLogged;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ActivityEventRequest;
use App\Models\ActivityLog;
@@ -11,6 +12,7 @@ use App\Models\User;
use Carbon\Carbon;
use DateTimeInterface;
use Exception;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
class ActivityProcessingController extends Controller
@@ -77,6 +79,7 @@ class ActivityProcessingController extends Controller
'subject_id' => $server->id,
'subject_type' => $server->getMorphClass(),
]);
Event::dispatch(new ActivityLogged($activityLog));
}
}
}

View File

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

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Api\Application\Mounts;
class UpdateMountEggsRequest extends StoreMountRequest
{
/**
* @param array<string, string|string[]>|null $rules
* @return array<string, string|string[]>
*/
public function rules(?array $rules = null): array
{
return [
'eggs' => 'required|array|exists:eggs,id',
'eggs.*' => 'integer',
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Api\Application\Mounts;
class UpdateMountNodesRequest extends StoreMountRequest
{
/**
* @param array<string, string|string[]>|null $rules
* @return array<string, string|string[]>
*/
public function rules(?array $rules = null): array
{
return [
'nodes' => 'required|array|exists:nodes,id',
'nodes.*' => 'integer',
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Api\Application\Mounts;
class UpdateMountServersRequest extends StoreMountRequest
{
/**
* @param array<string, string|string[]>|null $rules
* @return array<string, string|string[]>
*/
public function rules(?array $rules = null): array
{
return [
'servers' => 'required|array|exists:servers,id',
'servers.*' => 'integer',
];
}
}

View File

@@ -34,13 +34,7 @@ class ProcessWebhook implements ShouldQueue
$data = reset($data);
}
if (is_object($data)) {
$data = get_object_vars($data);
}
if (is_string($data)) {
$data = Arr::wrap(json_decode($data, true) ?? []);
}
$data = $this->normalizeData($data);
$data['event'] = $this->webhookConfiguration->transformClassName($this->eventName);
if ($this->webhookConfiguration->type === WebhookType::Discord) {
@@ -82,4 +76,18 @@ class ProcessWebhook implements ShouldQueue
'endpoint' => $this->webhookConfiguration->endpoint,
]);
}
/** @return array<mixed> */
private function normalizeData(mixed $data): array
{
if (is_string($data)) {
return Arr::wrap(json_decode($data, true) ?? []);
}
if (is_object($data)) {
return Arr::wrap($data->toArray());
}
return Arr::wrap($data);
}
}

View File

@@ -2,35 +2,207 @@
namespace App\Listeners;
use App\Enums\WebhookScope;
use App\Events\ActivityLogged;
use App\Models\Server;
use App\Models\User;
use App\Models\WebhookConfiguration;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
class DispatchWebhooks
{
/**
* @param array<mixed> $data
*/
public function handle(string $eventName, array $data): void
/** @param array<mixed>|string|null $action */
public function handle(mixed $event, array|string|null $action = null): void
{
if (is_string($event) && is_array($action)) {
if (str_starts_with($event, 'eloquent.')) {
$this->handleEloquentEvent($action[0], str($event)->between('eloquent.', ':'));
return;
}
if ($event !== ActivityLogged::class) {
$this->handleGenericClassEvent($event, $action);
}
return;
}
if ($event instanceof ActivityLogged) {
$this->handleActivityLogged($event);
$this->handleGlobalWebhooks($event);
}
}
protected function handleEloquentEvent(Model $model, string $action): void
{
$modelClass = $model::class;
$eventName = "eloquent.$action: $modelClass";
$webhooks = WebhookConfiguration::query()
->where('scope', WebhookScope::Global)
->whereJsonContains('events', $eventName)
->get();
if ($webhooks->isEmpty()) {
return;
}
$webhookData = [
'event' => $eventName,
'data' => $model->toArray(),
'timestamp' => now()->toIso8601String(),
];
if (!$this->hasPayloadContent($webhookData)) {
return;
}
/** @var WebhookConfiguration $webhookConfig */
foreach ($webhooks as $webhookConfig) {
$webhookConfig->run($eventName, [$webhookData]);
}
}
/** @param array<mixed> $payload */
protected function handleGenericClassEvent(string $eventName, array $payload): void
{
if (!$this->eventIsWatched($eventName)) {
return;
}
$matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) {
return WebhookConfiguration::query()->whereJsonContains('events', $eventName)->get();
return WebhookConfiguration::query()
->where('scope', WebhookScope::Global)
->whereJsonContains('events', $eventName)
->get();
});
/** @var WebhookConfiguration $webhookConfig */
if ($matchingHooks->isEmpty()) {
return;
}
$obj = $payload[0] ?? null;
$webhookData = ['event' => $eventName, 'timestamp' => now()->toIso8601String()];
if (is_object($obj)) {
$webhookData['data'] = $obj->toArray();
} elseif (is_array($obj)) {
$webhookData['data'] = $obj;
}
foreach ($matchingHooks as $webhookConfig) {
if (in_array($eventName, $webhookConfig->events)) {
$webhookConfig->run($eventName, $data);
$webhookConfig->run($eventName, [$webhookData]);
}
}
protected function handleActivityLogged(ActivityLogged $activityLogged): void
{
$eventName = $activityLogged->model->event;
if (!$activityLogged->isServerEvent()) {
return;
}
$server = null;
$morphClass = (new Server())->getMorphClass();
foreach ($activityLogged->model->subjects as $subject) {
if ($subject->subject_type === $morphClass && $subject->subject instanceof Server) {
$server = $subject->subject;
break;
}
}
if (!$server && isset($activityLogged->model->properties['server'])) {
$server = Server::find($activityLogged->model->properties['server']['id'] ?? null);
}
if (!$server) {
return;
}
$webhooks = $server->webhookConfigurations()
->whereJsonContains('events', $eventName)
->get();
if ($webhooks->isEmpty()) {
return;
}
$webhookData = $this->buildActivityPayload($activityLogged);
if (!$this->hasPayloadContent($webhookData)) {
return;
}
foreach ($webhooks as $webhookConfig) {
$webhookConfig->run($eventName, [$webhookData]);
}
}
/** @return array<string, mixed> */
protected function buildActivityPayload(ActivityLogged $activityLogged): array
{
$webhookData = [
'event' => $activityLogged->model->event,
'description' => $activityLogged->model->description,
'ip' => $activityLogged->model->ip,
'timestamp' => $activityLogged->model->timestamp->toIso8601String(),
];
if ($activityLogged->model->actor_id) {
$actor = $activityLogged->model->actor;
$webhookData['actor'] = [
'id' => $activityLogged->model->actor_id,
'type' => $activityLogged->model->actor_type,
'username' => $actor instanceof User ? $actor->username : null,
];
}
if ($activityLogged->model->properties->isNotEmpty()) {
$webhookData['properties'] = $activityLogged->model->properties->toArray();
}
if ($activityLogged->model->subjects->isNotEmpty()) {
$webhookData['subjects'] = $activityLogged->model->subjects->map(fn ($subject) => [
'id' => $subject->subject_id,
'type' => $subject->subject_type,
])->toArray();
}
return $webhookData;
}
protected function handleGlobalWebhooks(ActivityLogged $activityLogged): void
{
$eventName = $activityLogged->model->event;
if (!$this->eventIsWatched($eventName)) {
return;
}
$matchingHooks = cache()->rememberForever("webhooks.$eventName", function () use ($eventName) {
return WebhookConfiguration::query()
->where('scope', WebhookScope::Global)
->whereJsonContains('events', $eventName)
->get();
});
$webhookData = $this->buildActivityPayload($activityLogged);
if (!$this->hasPayloadContent($webhookData)) {
return;
}
foreach ($matchingHooks as $webhookConfig) {
$webhookConfig->run($eventName, [$webhookData]);
}
}
protected function eventIsWatched(string $eventName): bool
{
$watchedEvents = cache()->rememberForever('watchedWebhooks', function () {
return WebhookConfiguration::pluck('events')
return WebhookConfiguration::where('scope', WebhookScope::Global)
->pluck('events')
->flatten()
->unique()
->values()
@@ -39,4 +211,17 @@ class DispatchWebhooks
return in_array($eventName, $watchedEvents);
}
/** @param array<mixed> $webhookData */
protected function hasPayloadContent(array $webhookData): bool
{
return collect($webhookData)
->except('event')
->reject(fn (mixed $value) => match (true) {
is_array($value) => empty($value),
$value instanceof Collection => $value->isEmpty(),
default => $value === null || $value === '',
})
->isNotEmpty();
}
}

View File

@@ -36,6 +36,7 @@ class AlertBannerContainer extends Component
}
}
#[On('removeAlertBanner')]
public function remove(string $id): void
{
if ($this->alertBanners->has($id)) {

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Livewire;
use App\Enums\WebhookScope;
use App\Models\WebhookConfiguration;
use Filament\Schemas\Components\Concerns\CanPoll;
use Filament\Support\Concerns\EvaluatesClosures;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Livewire\Attributes\On;
use Livewire\Component;
class DiscordPreview extends Component
{
use CanPoll;
use EvaluatesClosures;
public ?WebhookConfiguration $record = null;
/** @var array<string, mixed>|null */
public ?array $formPayload = null;
#[On('discord-form-changed')]
public function onFormChanged(
string $content = '',
string $username = '',
string $avatar_url = '',
mixed $embeds = [],
): void {
$this->formPayload = [
'content' => $content,
'username' => $username,
'avatar_url' => $avatar_url,
'embeds' => is_array($embeds) ? $embeds : [],
];
}
private function safeUrl(?string $url): ?string
{
return ($url && preg_match('/^https?:\/\//i', $url)) ? $url : null;
}
/** @return array<int, array<string, mixed>> */
private function processEmbeds(mixed $embeds): array
{
return collect($embeds)
->filter(fn (mixed $embed) => is_array($embed))
->take(10)
->map(function (array $embed): array {
$color = $embed['color'] ?? null;
$embed['color'] = match (true) {
is_int($color) => '#' . str_pad(dechex($color), 6, '0', STR_PAD_LEFT),
is_string($color) => $color,
default => null,
};
if (!isset($embed['timestamp']) && !empty($embed['has_timestamp'])) {
$embed['timestamp'] = now()->toIso8601String();
}
if (isset($embed['timestamp'])) {
try {
$embed['timestamp'] = Carbon::parse($embed['timestamp'])->format('M j, Y H:i');
} catch (\Throwable) {
unset($embed['timestamp']);
}
}
$embed['view'] = [
'author_name' => $embed['author']['name'] ?? null,
'author_url' => $this->safeUrl($embed['author']['url'] ?? null),
'author_icon' => $this->safeUrl($embed['author']['icon_url'] ?? null),
'title' => $embed['title'] ?? null,
'title_url' => $this->safeUrl($embed['url'] ?? null),
'description' => $embed['description'] ?? null,
'fields' => $embed['fields'] ?? [],
'image' => $this->safeUrl($embed['image']['url'] ?? null),
'thumbnail' => $this->safeUrl($embed['thumbnail']['url'] ?? null),
'footer_text' => $embed['footer']['text'] ?? null,
'footer_icon' => $this->safeUrl($embed['footer']['icon_url'] ?? null),
'timestamp' => $embed['timestamp'] ?? null,
'color_style' => $embed['color']
? 'border-left-color: ' . $embed['color']
: 'border-left-color: #1e1f22',
];
return $embed;
})
->values()
->all();
}
public function render(): View
{
return view('livewire.discord-preview', $this->getViewData());
}
/**
* @return array{
* link: callable,
* content: mixed,
* sender: array{name: string, avatar: string},
* embeds: array<int, mixed>,
* getTime: mixed
* }
*/
public function getViewData(): array
{
$default = [
'link' => fn ($href, $child) => $href ? "<a href=\"$href\" target=\"_blank\" class=\"link\">$child</a>" : $child,
'content' => null,
'sender' => [
'name' => 'Pelican',
'avatar' => asset('pelican.svg'),
],
'embeds' => [],
'getTime' => fn () => now()->format('H:i'),
];
$payloadArray = $this->formPayload;
if ($payloadArray === null) {
if (!$this->record || !$this->record->payload) {
return $default;
}
$payloadArray = $this->record->payload;
}
$scope = $this->record !== null ? $this->record->scope : WebhookScope::Global;
$sampleData = $scope === WebhookScope::Server
? WebhookConfiguration::getServerWebhookSampleData()
: WebhookConfiguration::getWebhookSampleData();
$payloadJson = json_encode($payloadArray) ?: '{}';
$replacedPayload = (new WebhookConfiguration())->replaceVars($sampleData, $payloadJson);
$data = json_decode($replacedPayload, true) ?? [];
return [
'link' => fn ($href, $child) => $href ? "<a href=\"$href\" target=\"_blank\" class=\"link\">$child</a>" : $child,
'content' => data_get($data, 'content'),
'sender' => [
'name' => filled(data_get($data, 'username')) ? data_get($data, 'username') : 'Pelican',
'avatar' => filled(data_get($data, 'avatar_url')) ? data_get($data, 'avatar_url') : asset('pelican.svg'),
],
'embeds' => $this->processEmbeds(data_get($data, 'embeds', [])),
'getTime' => fn () => now()->format('H:i'),
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Enums\NodeJwtScope;
use App\Enums\TablerIcon;
use App\Models\Node;
use App\Services\Nodes\NodeJWTService;
@@ -47,6 +48,7 @@ class NodeClientConnectivity extends Component
$wsToken = $this->nodeJWTService
->setExpiresAt(now()->addMinute()->toImmutable())
->setScopes(NodeJwtScope::Websocket)
->setUser($user)
->setClaims([
'server_uuid' => $server->uuid,

View File

@@ -3,7 +3,6 @@
namespace App\Models;
use App\Enums\TablerIcon;
use App\Events\ActivityLogged;
use App\Traits\HasValidation;
use BackedEnum;
use Filament\Facades\Filament;
@@ -18,7 +17,6 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use LogicException;
@@ -140,9 +138,6 @@ class ActivityLog extends Model implements HasIcon, HasLabel
$model->timestamp = Carbon::now();
});
static::created(function (self $model) {
Event::dispatch(new ActivityLogged($model));
});
}
public function getIcon(): BackedEnum

View File

@@ -279,7 +279,7 @@ class Plugin extends Model implements HasPluginSettings
report($exception);
}
return null;
return [];
});
}

View File

@@ -6,6 +6,7 @@ use App\Contracts\Validatable;
use App\Enums\ContainerStatus;
use App\Enums\ServerResourceType;
use App\Enums\ServerState;
use App\Enums\WebhookScope;
use App\Exceptions\Http\Server\ServerStateConflictException;
use App\Models\Traits\HasIcon;
use App\Repositories\Daemon\DaemonServerRepository;
@@ -381,6 +382,15 @@ class Server extends Model implements HasAvatar, Validatable
return $this->morphToMany(Mount::class, 'mountable');
}
/**
* @return HasMany<WebhookConfiguration, $this>
*/
public function webhookConfigurations(): HasMany
{
return $this->hasMany(WebhookConfiguration::class, 'server_id', 'id')
->where('scope', WebhookScope::Server);
}
/**
* Returns all the activity log entries where the server is the subject.
*/
@@ -457,6 +467,10 @@ class Server extends Model implements HasAvatar, Validatable
public function retrieveStatus(): ContainerStatus
{
if ($this->node->isUnderMaintenance()) {
return ContainerStatus::Missing;
}
return cache()->remember("servers.$this->uuid.status", now()->addSeconds(15), function () {
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
$details = app(DaemonServerRepository::class)->setServer($this)->getDetails();
@@ -470,6 +484,10 @@ class Server extends Model implements HasAvatar, Validatable
*/
public function retrieveResources(): array
{
if ($this->node->isUnderMaintenance()) {
return [];
}
return cache()->remember("servers.$this->uuid.resources", now()->addSeconds(15), function () {
// @phpstan-ignore myCustomRules.forbiddenGlobalFunctions
$details = app(DaemonServerRepository::class)->setServer($this)->getDetails();

View File

@@ -2,14 +2,17 @@
namespace App\Models;
use App\Enums\WebhookScope;
use App\Enums\WebhookType;
use App\Jobs\ProcessWebhook;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\File;
use Livewire\Features\SupportEvents\HandlesEvents;
@@ -18,6 +21,7 @@ use Livewire\Features\SupportEvents\HandlesEvents;
* @property string $endpoint
* @property string $description
* @property string[] $events
* @property WebhookScope $scope
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $deleted_at
@@ -55,6 +59,9 @@ class WebhookConfiguration extends Model
];
protected $fillable = [
'name',
'scope',
'server_id',
'type',
'payload',
'endpoint',
@@ -67,6 +74,7 @@ class WebhookConfiguration extends Model
* Default values for specific fields in the database.
*/
protected $attributes = [
'scope' => WebhookScope::Global,
'type' => WebhookType::Regular,
'payload' => null,
];
@@ -74,6 +82,7 @@ class WebhookConfiguration extends Model
protected function casts(): array
{
return [
'scope' => WebhookScope::class,
'events' => 'array',
'payload' => 'array',
'type' => WebhookType::class,
@@ -100,10 +109,23 @@ class WebhookConfiguration extends Model
private static function updateCache(Collection $eventList): void
{
$eventList->each(function (string $event) {
cache()->forever("webhooks.$event", WebhookConfiguration::query()->whereJsonContains('events', $event)->get());
cache()->forget("webhooks.$event");
});
cache()->forever('watchedWebhooks', WebhookConfiguration::pluck('events')->flatten()->unique()->values()->all());
$eventList->each(function (string $event) {
cache()->forever("webhooks.$event", static::query()
->where('scope', WebhookScope::Global)
->whereJsonContains('events', $event)
->get());
});
cache()->forget('watchedWebhooks');
cache()->forever('watchedWebhooks', static::where('scope', WebhookScope::Global)
->pluck('events')
->flatten()
->unique()
->values()
->all());
}
public function webhooks(): HasMany
@@ -111,6 +133,11 @@ class WebhookConfiguration extends Model
return $this->hasMany(Webhook::class);
}
public function server(): BelongsTo
{
return $this->belongsTo(Server::class);
}
/** @return string[] */
public static function allPossibleEvents(): array
{
@@ -121,18 +148,211 @@ class WebhookConfiguration extends Model
->all();
}
/**
* @param array<string> $filterList
* @return array<string>
*/
public static function eventList(array $filterList): array
{
return collect(static::allPossibleEvents())
->filter(function ($event) use ($filterList) {
foreach ($filterList as $filter) {
$eventLower = strtolower($event);
$filterLower = strtolower($filter);
if ($eventLower === $filterLower) {
return true;
}
$pattern = '/(?:\\\\|\\.)' . preg_quote($filterLower) . '(?:\\\\|:|$)/i';
if (preg_match($pattern, $eventLower)) {
return true;
}
}
return false;
})
->values()
->all();
}
/** @return array<string> */
public static function adminEvents(): array
{
return static::eventList([
'User',
'Role',
'SSHKey',
'ApiKey',
'Token',
'HasAccessTokens',
'Node',
'Allocation',
'DatabaseHost',
'Mount',
'NodeRole',
'Egg',
'EggVariable',
'Plugin',
'WebhookConfiguration',
'Webhook',
'Captcha',
'Authentication',
'ActivityLogged',
]);
}
/** @return array<string> */
public static function globalServerEvents(): array
{
return static::eventList([
'Server',
'ServerTransfer',
'ServerVariable',
'Allocation',
'Backup',
'Database',
'File',
'Schedule',
'Task',
'Subuser',
'Installed',
'SubUserAdded',
'SubUserRemoved',
]);
}
/** @return string[] */
public static function allPossibleServerEvents(): array
{
$events = [
'server:file.read',
'server:file.write',
'server:file.rename',
'server:file.copy',
'server:file.compress',
'server:file.decompress',
'server:file.delete',
'server:file.create-directory',
'server:file.uploaded',
'server:file.pull',
'server:file.download',
'server:power.start',
'server:power.stop',
'server:power.restart',
'server:power.kill',
'server:console.command',
'server:startup.edit',
'server:startup.image',
'server:settings.rename',
'server:settings.description',
'server:settings.reinstall',
'server:allocation.notes',
'server:allocation.primary',
'server:allocation.create',
'server:allocation.delete',
'server:schedule.create',
'server:schedule.update',
'server:schedule.execute',
'server:schedule.delete',
'server:task.create',
'server:task.update',
'server:task.delete',
'server:backup.start',
'server:backup.delete',
'server:backup.download',
'server:backup.rename',
'server:backup.restore',
'server:backup.restore-complete',
'server:backup.restore-failed',
'server:database.create',
'server:database.rotate-password',
'server:database.delete',
'server:subuser.create',
'server:subuser.update',
'server:subuser.delete',
'server:sftp.denied',
];
Event::dispatch('server:webhook.events', [&$events]);
return array_unique($events);
}
/** @return string[] */
public static function allPossibleAdminOnlyEvents(): array
{
$events = static::eventList([
'User',
'Role',
'SSHKey',
'ApiKey',
'Token',
'HasAccessTokens',
'Node',
'Allocation',
'DatabaseHost',
'Mount',
'NodeRole',
'Egg',
'EggVariable',
'Plugin',
'WebhookConfiguration',
'Webhook',
'Captcha',
'Authentication',
'ActivityLogged',
]);
$serverEvents = collect(static::discoverCustomEvents())
->merge(static::allModelEvents())
->unique()
->filter(fn ($event) => str($event)->contains('App\\Models\\Server') && !str($event)->contains('Subuser'))
->values()
->all();
return array_values(array_unique(array_merge($events, $serverEvents)));
}
/** @return array<string, string> */
public static function filamentCheckboxList(): array
public static function filamentCheckboxList(WebhookScope $scope): array
{
$list = [];
$events = static::allPossibleEvents();
foreach ($events as $event) {
$list[$event] = static::transformClassName($event);
if ($scope === WebhookScope::Server) {
$events = static::allPossibleServerEvents();
foreach ($events as $event) {
$list[$event] = static::transformServerEventName($event);
}
} else {
$events = static::allPossibleAdminOnlyEvents();
foreach ($events as $event) {
$list[$event] = static::transformClassName($event);
}
}
return $list;
}
public static function transformServerEventName(string $event): string
{
return str($event)
->after('server:')
->replace('.', ' → ')
->title()
->toString();
}
public static function transformClassName(string $event): string
{
return str($event)
@@ -214,10 +434,16 @@ class WebhookConfiguration extends Model
/** @param array<mixed, mixed> $eventData */
public function run(?string $eventName = null, ?array $eventData = null): void
{
if ($this->scope === WebhookScope::Server) {
$eventName ??= 'server:file.write';
$eventData ??= static::getServerWebhookSampleData();
}
$eventName ??= 'eloquent.created: '.Server::class;
$eventData ??= static::getWebhookSampleData();
ProcessWebhook::dispatch($this, $eventName, [$eventData]);
$payload = array_is_list($eventData) ? $eventData : [$eventData];
ProcessWebhook::dispatch($this, $eventName, $payload);
}
/**
@@ -420,4 +646,29 @@ class WebhookConfiguration extends Model
'event' => 'updated: Server',
];
}
/**
* @return array<string, mixed>
*/
public static function getServerWebhookSampleData(): array
{
return [
'user' => [
'uuid' => '12345678-1234-5678-9012-123456789012',
'username' => 'admin',
'email' => 'admin@example.com',
'image' => 'https://www.gravatar.com/avatar/default',
'admin' => true,
'language' => 'en',
'created_at' => '2025-06-01T12:31:50.000000Z',
'updated_at' => '2025-06-01T12:31:50.000000Z',
],
'server' => [
'uuid' => '87654321-4321-8765-2109-876543210987',
'name' => 'Example Server',
'node' => 'node1.example.com',
'description' => 'Sample Minecraft server',
],
];
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Providers;
use App\Events\ActivityLogged;
use App\Listeners\DispatchWebhooks;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
@@ -11,7 +12,8 @@ class EventServiceProvider extends ServiceProvider
* The event to listener mappings for the application.
*/
protected $listen = [
'App\\*' => [DispatchWebhooks::class],
ActivityLogged::class => [DispatchWebhooks::class],
'App\\Events\\*' => [DispatchWebhooks::class],
'eloquent.created*' => [DispatchWebhooks::class],
'eloquent.deleted*' => [DispatchWebhooks::class],
'eloquent.updated*' => [DispatchWebhooks::class],

View File

@@ -7,6 +7,7 @@ use App\Filament\Admin\Pages\ListLogs;
use App\Filament\Admin\Pages\ViewLogs;
use App\Services\Helpers\PluginService;
use Boquizo\FilamentLogViewer\FilamentLogViewerPlugin;
use CharrafiMed\GlobalSearchModal\GlobalSearchModalPlugin;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Navigation\NavigationGroup;
@@ -46,6 +47,7 @@ class AdminPanelProvider extends PanelProvider
->navigationLabel(fn () => trans('admin/log.navigation.panel_logs'))
->navigationGroup(fn () => trans('admin/dashboard.advanced'))
->navigationIcon(TablerIcon::FileInfo),
GlobalSearchModalPlugin::make(),
]);
/** @var PluginService $pluginService */

View File

@@ -59,9 +59,15 @@ class FilamentServiceProvider extends ServiceProvider
fn () => Blade::render('@livewire(\App\Livewire\AlertBannerContainer::class)'),
);
$appName = config('app.name', 'Pelican');
if (strtolower($appName) !== 'pelican') {
$appName = "{$appName} - Powered by Pelican";
}
FilamentView::registerRenderHook(
PanelsRenderHook::FOOTER,
fn () => Blade::render('filament.layouts.footer'),
fn () => Blade::render('filament.layouts.footer', ['appName' => $appName]),
);
FilamentView::registerRenderHook(

View File

@@ -67,12 +67,11 @@ class RouteServiceProvider extends ServiceProvider
// a limit of 10 requests per minute, for the forgot password endpoint apply a
// limit of two per minute for the requester so that there is less ability to
// trigger email spam.
// the ratelimits refrenced above are now configured by env vars (check config/http.php for more details)
RateLimiter::for('authentication', function (Request $request) {
if ($request->route()->named('auth.post.forgot-password')) {
return Limit::perMinute(2)->by($request->ip());
return Limit::perMinutes(config('http.rate_limit.password_reset_period'), config('http.rate_limit.password_reset'))->by($request->ip());
}
return Limit::perMinute(10);
});
// Configure the throttles for both the application and client APIs below.

View File

@@ -7,6 +7,7 @@ use App\Exceptions\Repository\FileExistsException;
use App\Exceptions\Repository\FileNotEditableException;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
class DaemonFileRepository extends DaemonRepository
@@ -22,23 +23,29 @@ class DaemonFileRepository extends DaemonRepository
*/
public function getContent(string $path, ?int $notLargerThan = null): string
{
$response = $this->getHttpClient()->get("/api/servers/{$this->server->uuid}/files/contents",
['file' => $path]
);
try {
$response = $this->getHttpClient()->get("/api/servers/{$this->server->uuid}/files/contents",
['file' => $path]
);
} catch (RequestException $exception) {
$status = $exception->response->status();
if ($status === 400) {
throw new FileNotEditableException();
}
if ($status === 404) {
throw new FileNotFoundException();
}
throw $exception;
}
$length = $response->header('Content-Length');
if ($notLargerThan && $length > $notLargerThan) {
throw new FileSizeTooLargeException();
}
if ($response->status() === 400) {
throw new FileNotEditableException();
}
if ($response->status() === 404) {
throw new FileNotFoundException();
}
return $response;
}
@@ -51,16 +58,18 @@ class DaemonFileRepository extends DaemonRepository
*/
public function putContent(string $path, string $content): Response
{
$response = $this->getHttpClient()
->withQueryParameters(['file' => $path])
->withBody($content)
->post("/api/servers/{$this->server->uuid}/files/write");
try {
return $this->getHttpClient()
->withQueryParameters(['file' => $path])
->withBody($content)
->post("/api/servers/{$this->server->uuid}/files/write");
} catch (RequestException $exception) {
if ($exception->response->status() === 400) {
throw new FileExistsException();
}
if ($response->status() === 400) {
throw new FileExistsException();
throw $exception;
}
return $response;
}
/**
@@ -85,18 +94,20 @@ class DaemonFileRepository extends DaemonRepository
*/
public function createDirectory(string $name, string $path): Response
{
$response = $this->getHttpClient()->post("/api/servers/{$this->server->uuid}/files/create-directory",
[
'name' => $name,
'path' => $path,
]
);
try {
return $this->getHttpClient()->post("/api/servers/{$this->server->uuid}/files/create-directory",
[
'name' => $name,
'path' => $path,
]
);
} catch (RequestException $exception) {
if ($exception->response->status() === 400) {
throw new FileExistsException();
}
if ($response->status() === 400) {
throw new FileExistsException();
throw $exception;
}
return $response;
}
/**

View File

@@ -57,9 +57,6 @@ abstract class DaemonRepository
if (is_bool($condition)) {
return $condition;
}
if ($condition->clientError()) {
return false;
}
$header = $condition->header('User-Agent');
if (

View File

@@ -2,6 +2,7 @@
namespace App\Services\Activity;
use App\Events\ActivityLogged;
use App\Models\ActivityLog;
use App\Models\Server;
use App\Models\User;
@@ -12,6 +13,7 @@ use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Request;
use Throwable;
use Webmozart\Assert\Assert;
@@ -244,6 +246,8 @@ class ActivityLogService
return $this->activity;
});
Event::dispatch(new ActivityLogged($response));
$this->activity = null;
$this->subjects = [];

View File

@@ -2,6 +2,7 @@
namespace App\Services\Backups;
use App\Enums\NodeJwtScope;
use App\Extensions\Backups\BackupManager;
use App\Extensions\Filesystem\S3Filesystem;
use App\Models\Backup;
@@ -28,6 +29,7 @@ class DownloadLinkService
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setScopes(NodeJwtScope::BackupDownload)
->setUser($user)
->setClaims([
'backup_uuid' => $backup->uuid,

View File

@@ -78,7 +78,7 @@ class EggExporterService
return null;
}
protected function yamlExport(mixed $data): mixed
public function yamlExport(mixed $data): mixed
{
if ($data instanceof Collection) {
$data = $data->all();

View File

@@ -79,8 +79,11 @@ class EggImporterService
*/
public function fromUrl(string $url, ?Egg $egg = null): Egg
{
$info = pathinfo($url);
$extension = strtolower($info['extension']);
$extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION));
if (empty($extension)) {
throw new InvalidFileUploadException('Unsupported file format.');
}
$format = match ($extension) {
'yaml', 'yml' => EggFormat::YAML,
@@ -110,6 +113,7 @@ class EggImporterService
'uuid' => $uuid,
'author' => Arr::get($parsed, 'author'),
'copy_script_from' => null,
'config_from' => null,
]);
for ($i = 0; $i < count($parsed['variables']); $i++) {
@@ -143,7 +147,7 @@ class EggImporterService
*
* @throws InvalidFileUploadException|JsonException
*/
protected function parse(string $content, EggFormat $format): array
public function parse(string $content, EggFormat $format): array
{
try {
$parsed = match ($format) {

View File

@@ -377,7 +377,7 @@ class PluginService
}
}
$pluginName = str($file->getClientOriginalName())->before('.zip')->toString();
$pluginName = str($file->getClientOriginalName())->basename()->before('.zip')->toString();
if ($cleanDownload) {
File::deleteDirectory(plugin_path($pluginName));
@@ -396,9 +396,9 @@ class PluginService
/** @throws Exception */
public function downloadPluginFromUrl(string $url, bool $cleanDownload = false): void
{
$info = pathinfo($url);
$basename = pathinfo($url, PATHINFO_BASENAME);
$tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed();
$tmpPath = $tmpDir->path($info['basename']);
$tmpPath = $tmpDir->path($basename);
$content = Http::timeout(60)->connectTimeout(5)->throw()->get($url)->body();
@@ -412,7 +412,7 @@ class PluginService
throw new InvalidFileUploadException('Could not write temporary file.');
}
$this->downloadPluginFromFile(new UploadedFile($tmpPath, $info['basename'], 'application/zip'), $cleanDownload);
$this->downloadPluginFromFile(new UploadedFile($tmpPath, $basename, 'application/zip'), $cleanDownload);
}
public function deletePlugin(Plugin $plugin): void

View File

@@ -2,6 +2,7 @@
namespace App\Services\Nodes;
use App\Enums\NodeJwtScope;
use App\Extensions\Lcobucci\JWT\Encoding\TimestampDates;
use App\Models\Node;
use App\Models\User;
@@ -12,6 +13,7 @@ use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\UnencryptedToken;
use Webmozart\Assert\Assert;
class NodeJWTService
{
@@ -24,6 +26,9 @@ class NodeJWTService
private ?string $subject = null;
/** @var NodeJwtScope[] */
private array $scopes;
/**
* Set the claims to include in this JWT.
*
@@ -64,9 +69,11 @@ class NodeJWTService
/**
* Generate a new JWT for a given node.
*/
public function handle(Node $node, ?string $identifiedBy, string $algo = 'sha256'): UnencryptedToken
public function handle(Node $node, ?string $identifiedBy): UnencryptedToken
{
$identifier = hash($algo, $identifiedBy);
Assert::notEmpty($this->scopes, 'Cannot generate a JWT without providing at least one scope.');
$identifier = hash('sha256', $identifiedBy);
$config = Configuration::forSymmetricSigner(new Sha256(), InMemory::plainText($node->daemon_token));
$builder = $config->builder(new TimestampDates())
@@ -93,8 +100,17 @@ class NodeJWTService
$builder = $builder->withClaim('user_uuid', $this->user->uuid);
}
$builder = $builder->withClaim('scope', implode(' ', array_map(fn ($scope) => $scope->value, $this->scopes)));
return $builder
->withClaim('unique_id', Str::random())
->getToken($config->signer(), $config->signingKey());
}
public function setScopes(NodeJwtScope ...$scopes): self
{
$this->scopes = $scopes;
return $this;
}
}

View File

@@ -67,9 +67,9 @@ class ScheduleImporterService
public function fromUrl(string $url, Server $server): Schedule
{
$info = pathinfo($url);
$basename = pathinfo($url, PATHINFO_BASENAME);
$tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed();
$tmpPath = $tmpDir->path($info['basename']);
$tmpPath = $tmpDir->path($basename);
$fileContents = Http::timeout(5)->connectTimeout(1)->get($url)->throw()->body();
@@ -77,6 +77,6 @@ class ScheduleImporterService
throw new InvalidFileUploadException('Could not write temporary file.');
}
return $this->fromFile(new UploadedFile($tmpPath, $info['basename'], 'application/json'), $server);
return $this->fromFile(new UploadedFile($tmpPath, $basename, 'application/json'), $server);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Services\Servers;
use App\Enums\NodeJwtScope;
use App\Models\Allocation;
use App\Models\Backup;
use App\Models\Node;
@@ -99,8 +100,9 @@ class TransferServerService
// Generate a token for the destination node that the source node can use to authenticate with.
$token = $this->nodeJWTService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setScopes(NodeJwtScope::ServerTransfer)
->setSubject($server->uuid)
->handle($transfer->newNode, $server->uuid, 'sha256');
->handle($transfer->newNode, $server->uuid);
// Notify the source node of the pending outgoing transfer.
$this->notify($transfer, $token, $backup_uuid);

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Services;
use App\Enums\WebhookScope;
use App\Jobs\ProcessWebhook;
use App\Models\Server;
use App\Models\WebhookConfiguration;
class WebhookService
{
/**
* @param array<string, mixed> $contextualData
*/
public function dispatch(string $eventName, array $contextualData, ?Server $server = null): void
{
if ($server) {
$webhooks = $server->webhookConfigurations()
->whereJsonContains('events', $eventName)
->get();
foreach ($webhooks as $webhook) {
ProcessWebhook::dispatch($webhook, $eventName, [$contextualData]);
}
}
$globalWebhooks = WebhookConfiguration::query()
->where('scope', WebhookScope::Global)
->whereJsonContains('events', $eventName)
->get();
foreach ($globalWebhooks as $webhook) {
ProcessWebhook::dispatch($webhook, $eventName, [$contextualData]);
}
}
/**
* @return array<string, string>
*/
public function getAllEvents(WebhookScope $scope = WebhookScope::Global): array
{
return WebhookConfiguration::filamentCheckboxList($scope);
}
}

52
compose-bind.yml Normal file
View File

@@ -0,0 +1,52 @@
x-common:
panel:
&panel-environment
APP_URL: "http://localhost"
LE_EMAIL: "USEYOUROWNEMAILHERE@example.com" # email to be used for let's encrypt certificates
APP_DEBUG: "false"
APP_ENV: "production"
# BEHIND_PROXY: true # uncomment to run behind a proxy
# TRUSTED_PROXIES: 127.0.0.1,172.17.0.1,172.20.0.1 # defaults are for local proxies
mail:
&mail-environment
MAIL_DRIVER: "log"
# MAIL_HOST: ""
# MAIL_PORT: ""
# MAIL_FROM: ""
# MAIL_USERNAME: ""
# MAIL_PASSWORD: ""
# MAIL_SCHEME: ""
#
# ------------------------------------------------------------------------------------------
# DANGER ZONE BELOW
#
# The remainder of this file likely does not need to be changed. Please only make modifications
# below if you understand what you are doing.
#
services:
panel:
user: "0:0"
image: ghcr.io/pelican-dev/panel:latest
build: .
restart: unless-stopped
networks:
- default
ports:
- "80:80"
- "443:443"
# - "81:80" # if you are behind a proxy uncomment this line and comment out 80 and 443
# - "9000:9000" # enable when not using caddy to be able to reach php-fpm
extra_hosts:
- "host.docker.internal:host-gateway" # shows the panel on the internal docker network as well. usually '172.17.0.1'
volumes:
- /opt/pelican/data:/pelican-data
- /opt/pelican/logs:/var/www/html/storage/logs
- /opt/pelican/plugins:/var/www/html/plugins
environment:
<<: [*panel-environment, *mail-environment]
XDG_DATA_HOME: /pelican-data
# SKIP_CADDY: true # enable when not using caddy.

View File

@@ -69,6 +69,11 @@ services:
volumes:
- pelican-data:/pelican-data
- pelican-logs:/var/www/html/storage/logs
- type: volume
source: pelican-data
target: /var/www/html/plugins
volume:
subpath: plugins
environment:
<<: [*panel-environment, *mail-environment]
XDG_DATA_HOME: /pelican-data

View File

@@ -44,6 +44,12 @@ services:
volumes:
- pelican-data:/pelican-data
- pelican-logs:/var/www/html/storage/logs
- type: volume
source: pelican-data
target: /var/www/html/plugins
volume:
subpath: plugins
environment:
<<: [*panel-environment, *mail-environment]
XDG_DATA_HOME: /pelican-data

View File

@@ -9,20 +9,21 @@
"ext-mbstring": "*",
"ext-pdo": "*",
"ext-zip": "*",
"aws/aws-sdk-php": "^3.379",
"aws/aws-sdk-php": "^3.384",
"calebporzio/sushi": "^2.5",
"charrafimed/global-search-modal": "^5.0",
"dedoc/scramble": "^0.13",
"filament/filament": "^5.6",
"gboquizosanchez/filament-log-viewer": "^2.3",
"guzzlehttp/guzzle": "^7.10",
"laravel/framework": "^13.7",
"guzzlehttp/guzzle": "^7.11",
"laravel/framework": "^13.15",
"laravel/helpers": "^1.8",
"laravel/sanctum": "^4.3",
"laravel/socialite": "^5.27",
"laravel/tinker": "^3.0",
"laravel/ui": "^4.6",
"lcobucci/jwt": "^5.6",
"league/flysystem-aws-s3-v3": "^3.32",
"league/flysystem-aws-s3-v3": "^3.34",
"league/flysystem-memory": "^3.31",
"phiki/phiki": "^2.2",
"phpseclib/phpseclib": "^3.0.52",
@@ -32,9 +33,9 @@
"socialiteproviders/authentik": "^5.3",
"socialiteproviders/discord": "^4.2",
"socialiteproviders/steam": "^4.3",
"spatie/laravel-data": "^4.22",
"spatie/laravel-data": "^4.23",
"spatie/laravel-fractal": "^6.4",
"spatie/laravel-health": "^1.39",
"spatie/laravel-health": "^1.40",
"spatie/laravel-permission": "^6.25",
"spatie/laravel-query-builder": "^6.4",
"spatie/temporary-directory": "^2.3",
@@ -97,4 +98,4 @@
},
"minimum-stability": "stable",
"prefer-stable": true
}
}

1292
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@
return [
'disable_password_login' => env('AUTH_DISABLE_PASSWORD_LOGIN', false),
'lockout' => [
'time' => 2,
'attempts' => 3,

View File

@@ -6,16 +6,36 @@ return [
| API Rate Limits
|--------------------------------------------------------------------------
|
| Defines the rate limit for the number of requests per minute that can be
| executed against both the client and internal (application) APIs over the
| defined period (by default, 1 minute).
|
| Defines the rate limit for the number of requests that can be
| executed against the client and internal (application) APIs along with
| certain other endpoints over a defined period (1 minute for most)
*/
'rate_limit' => [
'client_period' => 1,
'client_period' => env('APP_API_CLIENT_RATELIMIT_PERIOD', 1),
'client' => env('APP_API_CLIENT_RATELIMIT', 256),
'application_period' => 1,
'application_period' => env('APP_API_APPLICATION_RATELIMIT_PERIOD', 1),
'application' => env('APP_API_APPLICATION_RATELIMIT', 256),
'password_reset_period' => env('APP_API_PASSWORD_RESET_RATELIMIT_PERIOD', 1),
'password_reset' => env('APP_API_PASSWORD_RESET_RATELIMIT', 2),
'websocket_period' => env('APP_API_WEBSOCKET_RATELIMIT_PERIOD', 1),
'websocket' => env('APP_API_WEBSOCKET_RATELIMIT', 5),
'backup_restore_period' => env('APP_API_BACKUP_RESTORE_RATELIMIT_PERIOD', 15),
'backup_restore' => env('APP_API_BACKUP_RESTORE_RATELIMIT', 3),
'database_create_period' => env('APP_API_DATABASE_CREATE_RATELIMIT_PERIOD', 1),
'database_create' => env('APP_API_DATABASE_CREATE_RATELIMIT', 2),
'subuser_create_period' => env('APP_API_SUBUSER_CREATE_RATELIMIT_PERIOD', 15),
'subuser_create' => env('APP_API_SUBUSER_CREATE_RATELIMIT', 10),
'file_pull_period' => env('APP_API_FILE_PULL_RATELIMIT_PERIOD', 10),
'file_pull' => env('APP_API_FILE_PULL_RATELIMIT', 5),
'default_period' => env('APP_API_DEFAULT_RATELIMIT_PERIOD', 1),
'default' => env('APP_API_DEFAULT_RATELIMIT', 2),
],
];

View File

@@ -1,11 +1,13 @@
project_id_env: "CROWDIN_PROJECT_ID"
api_token_env: "CROWDIN_PERSONAL_TOKEN"
base_path: "."
preserve_hierarchy: true
files:
- source: /lang/en/*.php
translation: /lang/%two_letters_code%/%original_file_name%
- source: /lang/en/admin
translation: /lang/%two_letters_code%/admin/%original_file_name%
- source: /lang/en/command
translation: /lang/%two_letters_code%/command/%original_file_name%
- source: /lang/en/dashboard
translation: /lang/%two_letters_code%/dashboard/%original_file_name%
- source: /lang/en/server
translation: /lang/%two_letters_code%/server/%original_file_name%
- source: "/lang/en/**/*.php"
translation: "/lang/%two_letters_code%/**/%original_file_name%"
languages_mapping:
two_letters_code:
'zh-CN': 'zh_CN'
'zh-TW': 'zh_TW'
'pt-BR': 'pt_BR'

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('webhook_configurations', function (Blueprint $table) {
$table->string('scope')->default('global')->after('id');
$table->unsignedInteger('server_id')->nullable()->after('scope');
$table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade');
$table->index(['scope', 'server_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('webhook_configurations', function (Blueprint $table) {
$table->dropForeign(['server_id']);
$table->dropIndex(['scope', 'server_id']);
$table->dropColumn(['scope', 'server_id']);
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('webhook_configurations', function (Blueprint $table) {
$table->renameColumn('description', 'name');
$table->text('description')->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('webhook_configurations', function (Blueprint $table) {
$table->dropColumn('description');
$table->renameColumn('name', 'description');
});
}
};

View File

@@ -1 +1,2 @@
* * * * * php /var/www/html/artisan schedule:run
0 0 * * * php /var/www/html/artisan p:egg:check-updates

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