mirror of
https://github.com/pelican-dev/panel.git
synced 2026-07-16 04:03:50 +03:00
Compare commits
44 Commits
charles/ad
...
v1.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
483145aac8 | ||
|
|
5953a268c4 | ||
|
|
2486446e23 | ||
|
|
553c87597f | ||
|
|
0f1aa46aeb | ||
|
|
2771ccc421 | ||
|
|
fab5da496f | ||
|
|
ed7d4f5a6b | ||
|
|
d5df688638 | ||
|
|
0e75027c12 | ||
|
|
475a454806 | ||
|
|
3a015e8081 | ||
|
|
29d774eae4 | ||
|
|
2e7e4bb6d4 | ||
|
|
420ae022e9 | ||
|
|
666a409b66 | ||
|
|
2b9c08dc5d | ||
|
|
21ed58839c | ||
|
|
69e88a2ea7 | ||
|
|
cc2aa38ad4 | ||
|
|
1bd4492cdc | ||
|
|
90fbc87a8a | ||
|
|
ac3db5af71 | ||
|
|
a46443e5e4 | ||
|
|
f791a3a686 | ||
|
|
b3fe96f262 | ||
|
|
4d114571e0 | ||
|
|
136224224c | ||
|
|
28452e2820 | ||
|
|
be6abcefe7 | ||
|
|
2a328a24ac | ||
|
|
02e4527b84 | ||
|
|
f676b6c794 | ||
|
|
cb21f13059 | ||
|
|
13bb47e6eb | ||
|
|
447a8c1ff7 | ||
|
|
a99f18b189 | ||
|
|
8f01095f9d | ||
|
|
110fdd2bdb | ||
|
|
81bb09f4cf | ||
|
|
dcba4a91d0 | ||
|
|
8f7dbe5e16 | ||
|
|
92bfccb2a4 | ||
|
|
11a1581a1d |
4
.github/workflows/build.yaml
vendored
4
.github/workflows/build.yaml
vendored
@@ -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"
|
||||
|
||||
50
.github/workflows/ci.yaml
vendored
50
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
||||
67
.github/workflows/docker-publish.yml
vendored
67
.github/workflows/docker-publish.yml
vendored
@@ -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: .
|
||||
|
||||
54
.github/workflows/download-translations.yml
vendored
Normal file
54
.github/workflows/download-translations.yml
vendored
Normal 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'
|
||||
6
.github/workflows/lint.yaml
vendored
6
.github/workflows/lint.yaml
vendored
@@ -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') }}
|
||||
|
||||
4
.github/workflows/release.yaml
vendored
4
.github/workflows/release.yaml
vendored
@@ -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
32
.github/workflows/upload-sources.yml
vendored
Normal 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 }}
|
||||
10
Dockerfile
10
Dockerfile
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
152
app/Console/Commands/Egg/NormalizeEggCommand.php
Normal file
152
app/Console/Commands/Egg/NormalizeEggCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
12
app/Enums/NodeJwtScope.php
Normal file
12
app/Enums/NodeJwtScope.php
Normal 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';
|
||||
}
|
||||
@@ -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')),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
17
app/Enums/WebhookScope.php
Normal file
17
app/Enums/WebhookScope.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']),
|
||||
];
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
])
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => [
|
||||
|
||||
@@ -67,6 +67,7 @@ class ServersRelationManager extends RelationManager
|
||||
->label(trans('admin/node.backups'))
|
||||
->numeric()
|
||||
->sortable(),
|
||||
]);
|
||||
])
|
||||
->emptyStateHeading(trans('admin/server.no_servers'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', []);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
41
app/Filament/Server/Resources/Webhooks/Pages/EditWebhook.php
Normal file
41
app/Filament/Server/Resources/Webhooks/Pages/EditWebhook.php
Normal 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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
19
app/Filament/Server/Resources/Webhooks/Pages/ViewWebhook.php
Normal file
19
app/Filament/Server/Resources/Webhooks/Pages/ViewWebhook.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
358
app/Filament/Server/Resources/Webhooks/WebhookResource.php
Normal file
358
app/Filament/Server/Resources/Webhooks/WebhookResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ class AlertBannerContainer extends Component
|
||||
}
|
||||
}
|
||||
|
||||
#[On('removeAlertBanner')]
|
||||
public function remove(string $id): void
|
||||
{
|
||||
if ($this->alertBanners->has($id)) {
|
||||
|
||||
150
app/Livewire/DiscordPreview.php
Normal file
150
app/Livewire/DiscordPreview.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -279,7 +279,7 @@ class Plugin extends Model implements HasPluginSettings
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return null;
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,9 +57,6 @@ abstract class DaemonRepository
|
||||
if (is_bool($condition)) {
|
||||
return $condition;
|
||||
}
|
||||
if ($condition->clientError()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$header = $condition->header('User-Agent');
|
||||
if (
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
44
app/Services/WebhookService.php
Normal file
44
app/Services/WebhookService.php
Normal 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
52
compose-bind.yml
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
1292
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
|
||||
return [
|
||||
|
||||
'disable_password_login' => env('AUTH_DISABLE_PASSWORD_LOGIN', false),
|
||||
|
||||
'lockout' => [
|
||||
'time' => 2,
|
||||
'attempts' => 3,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
];
|
||||
|
||||
22
crowdin.yml
22
crowdin.yml
@@ -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'
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user