Compare commits

...

96 Commits

Author SHA1 Message Date
Lance Pioch
28ad3c9c0f Attach scope claim to JWTs issued to wings
Without a scope, a JWT minted for one purpose (e.g. a websocket token
any user can request) is interchangeable against unrelated wings
endpoints since the daemon only validates signature + server UUID.

Adds a JwtScope enum and a setScopes() method on NodeJWTService that
must be called before handle(); every existing call site (websocket,
file download/upload, backup download, server transfer) now declares
its scope. The hash algorithm parameter is dropped — sha256 always.

Wings must be updated to verify the scope claim for this to take
effect; the new claim is ignored by older daemons.
2026-06-11 14:59:56 -04:00
Quinten
21ed58839c Bump all github action runners (#2386) 2026-06-11 17:21:41 +02:00
Michael (Parker) Parker
69e88a2ea7 Fix docker issues (#2375) 2026-06-11 10:00:01 -04:00
stdpi
cc2aa38ad4 Skip daemon RPC calls for servers on maintenance-mode nodes (#2371) 2026-06-11 07:05:47 -04:00
Lance Pioch
1bd4492cdc Laravel 13.15.0 Shift (#2382)
Co-authored-by: Shift <shift@laravelshift.com>
2026-06-09 19:22:44 -04:00
stdpi
90fbc87a8a Fix sed command syntax in docker-publish.yml (#2372) 2026-06-09 14:22:16 -05:00
Boy132
ac3db5af71 Add back bg-white to small stat (#2381) 2026-06-09 15:59:39 +02:00
Noah Stefano
a46443e5e4 restore daemon error surface for wings 4xx (#2325) 2026-06-09 05:56:38 -04:00
O2theC
f791a3a686 new api settings ui stuff (#2350) 2026-06-09 05:56:24 -04:00
Quinten
b3fe96f262 docker: Update php to 8.5 and node to 22 (#2380) 2026-06-08 16:02:30 -04:00
Lance Pioch
4d114571e0 Laravel 13.14.0 Shift (#2374)
Co-authored-by: Shift <shift@laravelshift.com>
2026-06-08 13:59:59 -04:00
stdpi
136224224c Fix dark mode (#2373) 2026-06-03 12:19:57 -04:00
Boy132
28452e2820 Return empty array instead of null when plugin update checking fails (#2364) 2026-06-02 21:05:11 +02:00
Boy132
be6abcefe7 Add acceptedFileTypes to font upload and add can check to save settings method (#2366) 2026-06-02 21:04:38 +02:00
Charles
2a328a24ac Improve Global Searching in admin panel (#2327) 2026-05-28 05:50:28 -04:00
Boy132
02e4527b84 Replace hardcoded pelican with config value (#2362) 2026-05-28 09:18:20 +02:00
Boy132
f676b6c794 Add error handling when trying to get extension from url (#2361) 2026-05-28 08:59:47 +02:00
Charles
cb21f13059 composer upgrade (#2359) 2026-05-27 17:10:20 -04:00
Boy132
13bb47e6eb Make sure plugin name doesn't contain path when importing (#2356) 2026-05-26 13:54:30 +02:00
sangege
447a8c1ff7 ci: Fix Crowdin synchronization paths and optimize configs (#2354) 2026-05-26 06:03:55 -04:00
sangege
a99f18b189 chore(i18n): Automate Crowdin source sync and translation pull workflows (#2339)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-05-22 05:34:15 -04:00
Lance Pioch
8f01095f9d Update readme.md (#2351) 2026-05-21 09:15:24 -04:00
Lance Pioch
110fdd2bdb Laravel 13.11.2 Shift (#2348)
Co-authored-by: Shift <shift@laravelshift.com>
2026-05-20 16:19:39 -04:00
Nikolaos Karaolidis
81bb09f4cf Add option to disable password login (#2318)
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2026-05-20 06:33:18 -04:00
Charles
dcba4a91d0 Listen for install completed event (#2345) 2026-05-20 03:36:52 -04:00
O2theC
8f7dbe5e16 move some hardcoded ratelimits for the api to env vars (#2332) 2026-05-19 07:25:18 -04:00
Noah Stefano
92bfccb2a4 Fix multi file upload (#2331) 2026-05-19 05:56:55 -04:00
Charles
11a1581a1d Update Node.js package dependencies (#2329) 2026-05-11 04:14:44 -04:00
Boy132
38620a95f4 Update to Laravel 13 & Filament v5 (+ drop php 8.2 / node.js 20 support) (#2315) 2026-05-06 08:55:54 +02:00
Nikolaos Karaolidis
d520224bc4 Add missing database driver in CLI (#2317)
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2026-05-05 12:12:58 -04:00
Nikolaos Karaolidis
4464ccb469 Add toggles for account created, subuser added/removed email notifications (#2309)
Signed-off-by: Nikolaos Karaolidis <nick@karaolidis.com>
2026-05-05 07:31:06 -04:00
Boy132
4ad305c709 Replace hardcoded hex strings (#2314) 2026-05-05 13:25:33 +02:00
Boy132
4b22010576 Allow to reorder startup_commands and docker_images (#2316) 2026-05-05 13:25:25 +02:00
Boy132
eb4e947ddb Add "backup completed" notifications (#2313) 2026-05-05 09:00:49 +02:00
Boy132
2bbfb0eef9 Fix "undefined key" error for TagsFilter (#2312) 2026-05-04 10:48:34 +02:00
Boy132
2a64ea8536 Cleanup node fqdn/ip validation (#2307) 2026-05-04 09:01:03 +02:00
Boy132
4fdbbff74b Correctly store falsy startup variable values (#2305) 2026-04-28 09:35:19 +02:00
Boy132
2ed891633c Add subuser permission for changing server icon (#2304) 2026-04-28 09:34:38 +02:00
Michael (Parker) Parker
58dcbeac0b install plugin packages on start (#2298) 2026-04-23 08:41:44 -05:00
Boy132
91c5ddb2bd Catch icon exception when importing egg (#2299) 2026-04-22 13:52:31 +02:00
Lance Pioch
562be98b20 Add user_mountable functionality for Mounts (#2077) (#2181)
Co-authored-by: Boy132 <mail@boy132.de>
2026-04-21 22:03:10 -04:00
Lance Pioch
fd3b8a7ab3 Laravel 12.56.0 Shift (#2283)
Co-authored-by: Shift <shift@laravelshift.com>
2026-04-21 22:02:48 -04:00
Boy132
1817383bf5 Add changes from upstream (#2293)
Co-authored-by: DaneEveritt <dane@daneeveritt.com>
Co-authored-by: danny6167 <danielb@purpleflaghosting.com>
Co-authored-by: MrSoulPenguin <28676680+MrSoulPenguin@users.noreply.github.com>
2026-04-20 17:25:54 +02:00
Boy132
d39a0c4464 Allow to add translation prefix to custom subuser permissions (#2265) 2026-04-20 17:24:24 +02:00
Boy132
2e48095379 Refactor egg & server icon uploading (#2281) 2026-04-20 17:23:41 +02:00
Jørgen Vatle
06c662988a Feature: Add support for user-configurable egg/nests index file. (#2287)
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2026-04-20 05:06:39 -04:00
Neil Hanlon
98d7158dfc Fix PATCH /api/application/nodes returning 500 due to null timestamps (#2286) 2026-04-20 05:06:01 -04:00
JUBE
e01d9f2cf3 Add sortable feature to name column in ListServers (#2282) 2026-04-08 19:50:01 -04:00
Boy132
b693d0e728 Do not allow all file extensions for egg image in egg importer (#2279) 2026-03-22 19:18:46 +01:00
Boy132
612041e1f8 Run composer update and update model phpdocs (#2275) 2026-03-17 09:09:01 +01:00
Boy132
64bcdb514b Add jobs for plugin install, update and uninstall (#2267) 2026-03-17 09:08:28 +01:00
Lance Pioch
c1105db702 Fix encodings with new tested helper (#2238) 2026-03-16 16:48:13 -04:00
Lance Pioch
761492d09f Laravel 12.53.0 Shift (#2266)
Co-authored-by: Shift <shift@laravelshift.com>
2026-02-26 23:56:15 -05:00
Boy132
e35ce1e79d Use folder name as id when having id mismatch to allow deletion (#2263) 2026-02-23 20:25:19 +01:00
Charles
42c127c004 Fix invisible force delete button (#2262) 2026-02-23 13:41:15 -05:00
Boy132
593f209142 Fix stock egg UUID migration (#2259) 2026-02-22 17:23:51 +01:00
Boy132
9bf5b2cf0a Do not throw error when checking for egg updates (#2256) 2026-02-22 17:23:09 +01:00
Lance Pioch
f76e864a30 Laravel 12.52.0 Shift (#2247)
Co-authored-by: Shift <shift@laravelshift.com>
2026-02-22 11:11:18 -05:00
Boy132
01cfa31ee1 Limit activity logs on profile page (#2253) 2026-02-19 14:57:48 +01:00
Boy132
dead664e4d Make plugin id in artisan commands also case insensitive (#2252) 2026-02-19 14:57:19 +01:00
Charles
1bbbcd0e25 Update server/egg icon url supported file types (#2249) 2026-02-18 16:30:24 -05:00
Michael (Parker) Parker
677d2f742c docker env fixes (#2234)
Co-authored-by: Charles <charles@pelican.dev>
Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2026-02-18 14:40:46 -05:00
Charles
650fb16d2d exclude bulk actions in list eggs (#2239) 2026-02-17 06:17:28 -05:00
Boy132
58814ea782 Allow to delete errored plugins (#2246) 2026-02-17 11:52:25 +01:00
hallo123wert
0918ed308b exclude bulk actions (#2240) 2026-02-16 06:34:14 -05:00
Lance Pioch
85d5f2ec3f Fix alert banners (#1492) (#2177) 2026-02-15 15:34:09 -05:00
Lance Pioch
810f237547 Remove ignition, just use new default error handler (#2241) 2026-02-15 15:03:55 -05:00
Lance Pioch
8f191890a1 Fix null backup limit exception (#2242) 2026-02-15 15:03:37 -05:00
Boy132
160e0e54f5 More action fixes (#2237)
Co-authored-by: notCharles <charles@pelican.dev>
2026-02-15 17:31:50 +01:00
Charles
fdcfbb00ca Revert "Fix UTF double encoding in file editor" (#2236) 2026-02-15 11:08:01 -05:00
Lance Pioch
44f6cf8928 Fix UTF double encoding in file editor (#2199) 2026-02-14 10:33:35 -05:00
Boy132
cf2a26bbf0 Improve plugin loading (#2233) 2026-02-14 14:06:50 +01:00
gOOvER
adb6678eee Convert YAML boolean defaults to strings (#2232) 2026-02-13 18:12:44 -05:00
Lance Pioch
9539e21b39 Handle invalid svg icon in generic-oidc provider gracefully (#2216) 2026-02-13 10:06:07 -05:00
Charles
33660f635f Fix yet another action (#2230) 2026-02-12 21:00:26 -05:00
Lance Pioch
8c475ed95f Add “reachable” column for Client -> Wings connections for Nodes (#2200) 2026-02-12 17:06:38 -05:00
Lance Pioch
d43cb1d180 Fix empty egg config_files causing fatal 500 error (#2195) (#2197) 2026-02-12 17:06:19 -05:00
Lance Pioch
fe55dbd200 Ignore oauth redirects for spa (#2224) 2026-02-12 16:31:35 -05:00
Charles
0bb4503c2b fix unsuspend button (#2227) 2026-02-12 16:31:20 -05:00
Lance Pioch
3c1168beb5 Update job runners (#2225) 2026-02-12 10:29:33 -05:00
Quinten
1a092aedc8 Docker: chown composer.json and composer.lock for plugins (#2220) 2026-02-12 10:02:12 -05:00
Charles
8c99a8030f Fix more actions (#2208) 2026-02-12 09:56:56 -05:00
Lance Pioch
6e53b1cd7d Allow custom ips to still be entered (#2223) 2026-02-12 09:49:54 -05:00
Charles
4042e0416b Revert toolbar change (#2218) 2026-02-11 17:54:58 -05:00
Lance Pioch
cc8973cf00 Laravel 12.51.0 Shift (#2213)
Co-authored-by: Shift <shift@laravelshift.com>
2026-02-11 11:18:33 -05:00
Quinten
8ebe75b947 fix: composer not been installed in the docker image (#2211) 2026-02-10 16:00:18 -05:00
Hythera
f8144407d1 fix: composer content hash (#2209) 2026-02-09 22:26:01 -05:00
Charles
e431ccb66a add rounding to list-files header (#2207) 2026-02-09 22:19:10 -05:00
Lance Pioch
9291bb4477 Fix webhook processing for event objects (#2198) 2026-02-09 08:47:09 -05:00
Lance Pioch
e8c80ae420 Fix SFTP access denied for subuser when view role is assigned (#2196) 2026-02-09 08:46:47 -05:00
stdpi
f1be003276 Change file browser sticky header again... (#2203) 2026-02-08 15:17:48 -05:00
Charles
e532a9a180 composer upgrade (#2167)
Co-authored-by: Lance Pioch <git@lance.sh>
2026-02-07 14:33:51 -05:00
Charles
41fdd7bc8e Exclude create client api key button (#2169) 2026-02-07 14:33:32 -05:00
Lance Pioch
9b01203b7c Add per-user toggle to redirect admins to /admin after login (#2191) 2026-02-07 13:21:14 -05:00
Lance Pioch
ab4eadec32 Fix Exporting an egg in yaml format (#2172) 2026-02-07 08:10:00 -05:00
Lance Pioch
789c4c7284 Localize email notifications (#2043) (#2178) 2026-02-06 10:37:15 -05:00
292 changed files with 8140 additions and 5059 deletions

View File

@@ -13,15 +13,15 @@ jobs:
strategy:
fail-fast: true
matrix:
node-version: [20, 22]
node-version: [22, 24]
steps:
- name: Code Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
php-version: "8.5"
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
@@ -30,7 +30,7 @@ jobs:
run: composer install --no-interaction --no-suggest --no-progress --no-autoloader --no-scripts --no-dev
- name: Setup Node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: "yarn"

View File

@@ -26,13 +26,13 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4, 8.5]
php: [8.3, 8.4, 8.5]
env:
DB_CONNECTION: sqlite
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') }}
@@ -65,13 +65,13 @@ jobs:
run: php artisan migrate --force --seed
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel
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
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning
mysql:
name: MySQL
@@ -79,8 +79,8 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4, 8.5]
database: ["mysql:8"]
php: [8.5]
database: ["mysql:8.4", "mysql:9.6"]
services:
database:
image: ${{ matrix.database }}
@@ -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') }}
@@ -130,13 +130,13 @@ jobs:
DB_USERNAME: root
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel
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
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning
env:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
@@ -147,8 +147,8 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4, 8.5]
database: ["mariadb:10.6", "mariadb:10.11", "mariadb:11.4"]
php: [8.5]
database: ["mariadb:10.11", "mariadb:11.4"]
services:
database:
image: ${{ matrix.database }}
@@ -165,7 +165,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 +173,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') }}
@@ -198,13 +198,13 @@ jobs:
DB_USERNAME: root
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel
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
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning
env:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
@@ -215,8 +215,8 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4, 8.5]
database: ["postgres:14"]
php: [8.5]
database: ["postgres:17", "postgres:18"]
services:
database:
image: ${{ matrix.database }}
@@ -240,7 +240,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 +248,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') }}
@@ -270,10 +270,10 @@ jobs:
run: php artisan migrate --force --seed
- name: Unit tests
run: vendor/bin/pest tests/Unit --parallel
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
run: vendor/bin/pest tests/Integration --parallel --do-not-fail-on-phpunit-warning

View File

@@ -31,13 +31,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 +52,7 @@ jobs:
run: docker save -o base-php-${{ matrix.arch }}.tar base-php:${{ matrix.arch }}
- name: Push the docker build to the artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: base-php-${{ matrix.arch }}.tar
path: base-php-${{ matrix.arch }}.tar
@@ -76,11 +76,11 @@ jobs:
if: "!contains(github.ref, 'main') || (!contains(github.event.head_commit.message, 'skip docker') && !contains(github.event.head_commit.message, 'docker skip'))"
steps:
- name: Code checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Docker metadata
id: docker_meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
@@ -91,16 +91,16 @@ jobs:
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 }}
@@ -113,12 +113,12 @@ jobs:
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
@@ -135,10 +135,10 @@ jobs:
- name: Update version in config/app.php (tag)
if: "github.event_name == 'release' && github.event.action == 'published'"
run: |
sed -i "s/'version' => 'canary',/'version' => '${{ steps.build_info.outputs.version_tag }}',/" config/app.php
sed -i "s|'version' => 'canary',|'version' => '${{ steps.build_info.outputs.version_tag }}',|" config/app.php
- name: Build and Push (tag)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
if: "github.event_name == 'release' && github.event.action == 'published'"
with:
context: .
@@ -153,7 +153,7 @@ jobs:
cache-to: type=gha,scope=tagged${{ matrix.os }},mode=max
- name: Build and Push (main)
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
if: "github.event_name == 'push' && contains(github.ref, 'main')"
with:
context: .

View File

@@ -0,0 +1,36 @@
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
- name: Download Translations from Crowdin
uses: crowdin/github-action@v2
with:
upload_sources: false
upload_translations: false
download_translations: true
export_only_approved: true
localization_branch_name: i18n_crowdin_translations
create_pull_request: true
pull_request_title: 'New Crowdin Translations'
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'main'
crowdin_branch_name: main
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}

View File

@@ -11,12 +11,12 @@ 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
with:
php-version: "8.4"
php-version: "8.5"
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
@@ -35,10 +35,10 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4, 8.5]
php: [8.3, 8.4, 8.5]
steps:
- name: Code Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Get cache directory
id: composer-cache
@@ -46,7 +46,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}

View File

@@ -14,12 +14,12 @@ jobs:
steps:
- name: Code checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
php-version: "8.5"
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
@@ -28,9 +28,9 @@ 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: 20
node-version: 22
cache: "yarn"
- name: Install JS dependencies

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

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

View File

@@ -22,7 +22,7 @@ RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts
# ================================
# Stage 1-2: Yarn Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
FROM --platform=$TARGETOS/$TARGETARCH node:22-alpine AS yarn
WORKDIR /build
@@ -68,23 +68,22 @@ RUN apk add --no-cache \
# required for installing plugins. Pulled from https://github.com/pelican-dev/panel/pull/2034
zip unzip 7zip bzip2-dev yarn git
# Copy composer binary for runtime plugin dependency management
COPY --from=composer /usr/local/bin/composer /usr/local/bin/composer
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/
&& 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
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile

View File

@@ -1,7 +1,7 @@
# ================================
# Stage 0: Build PHP Base Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine
FROM --platform=$TARGETOS/$TARGETARCH php:8.5-fpm-alpine
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/

View File

@@ -1,7 +1,7 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Development Dockerfile
FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine AS base
FROM --platform=$TARGETOS/$TARGETARCH php:8.5-fpm-alpine AS base
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
@@ -26,7 +26,7 @@ RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts
# ================================
# Stage 1-2: Yarn Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
FROM --platform=$TARGETOS/$TARGETARCH node:22-alpine AS yarn
WORKDIR /build
@@ -73,22 +73,22 @@ RUN apk add --no-cache \
# required for installing plugins. Pulled from https://github.com/pelican-dev/panel/pull/2034
zip unzip 7zip bzip2-dev yarn git
# Copy composer binary for runtime plugin dependency management
COPY --from=composer /usr/local/bin/composer /usr/local/bin/composer
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/
&& 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
COPY docker/supervisord.conf /etc/supervisord.conf

View File

@@ -8,7 +8,6 @@ use App\Services\Eggs\Sharing\EggExporterService;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use JsonException;
use Symfony\Component\Yaml\Yaml;
class CheckEggUpdatesCommand extends Command
@@ -22,14 +21,12 @@ class CheckEggUpdatesCommand extends Command
try {
$this->check($egg, $exporterService);
} catch (Exception $exception) {
$this->error("{$egg->name}: Error ({$exception->getMessage()})");
$this->error("$egg->name: Error ({$exception->getMessage()})");
}
}
}
/**
* @throws JsonException
*/
/** @throws Exception */
private function check(Egg $egg, EggExporterService $exporterService): void
{
if (is_null($egg->update_url)) {
@@ -38,14 +35,25 @@ 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))
: json_decode($exporterService->handle($egg->id, EggFormat::JSON), true);
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url)->throw()->body();
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url);
if ($remote->failed()) {
throw new Exception("HTTP request returned status code {$remote->status()}");
}
$remote = $remote->body();
$remote = $isYaml ? Yaml::parse($remote) : json_decode($remote, true);
unset($local['exported_at'], $remote['exported_at']);

View File

@@ -13,7 +13,7 @@ class UpdateEggIndexCommand extends Command
public function handle(): int
{
try {
$data = Http::timeout(5)->connectTimeout(1)->get('https://raw.githubusercontent.com/pelican-eggs/pelican-eggs.github.io/refs/heads/main/content/pelican.json')->throw()->json();
$data = Http::timeout(5)->connectTimeout(1)->get(config('panel.cdn.egg_index_url'))->throw()->json();
} catch (Exception $exception) {
$this->error($exception->getMessage());

View File

@@ -16,6 +16,7 @@ class DatabaseSettingsCommand extends Command
'sqlite' => 'SQLite (recommended)',
'mariadb' => 'MariaDB',
'mysql' => 'MySQL',
'pgsql' => 'PostgreSQL',
];
protected $description = 'Configure database settings for the Panel.';
@@ -23,10 +24,10 @@ class DatabaseSettingsCommand extends Command
protected $signature = 'p:environment:database
{--driver= : The database driver backend to use.}
{--database= : The database to use.}
{--host= : The connection address for the MySQL/ MariaDB server.}
{--port= : The connection port for the MySQL/ MariaDB server.}
{--username= : Username to use when connecting to the MySQL/ MariaDB server.}
{--password= : Password to use for the MySQL/ MariaDB database.}';
{--host= : The connection address for the MySQL/ MariaDB/ PostgreSQL server.}
{--port= : The connection port for the MySQL/ MariaDB/ PostgreSQL server.}
{--username= : Username to use when connecting to the MySQL/ MariaDB/ PostgreSQL server.}
{--password= : Password to use for the MySQL/ MariaDB/ PostgreSQL database.}';
/** @var array<array-key, mixed> */
protected array $variables = [];
@@ -176,6 +177,68 @@ class DatabaseSettingsCommand extends Command
return $this->handle();
}
return 1;
}
} elseif ($this->variables['DB_CONNECTION'] === 'pgsql') {
$this->output->note(trans('commands.database_settings.DB_HOST_note'));
$this->variables['DB_HOST'] = $this->option('host') ?? $this->ask(
'Database Host',
config('database.connections.pgsql.host', '127.0.0.1')
);
$this->variables['DB_PORT'] = $this->option('port') ?? $this->ask(
'Database Port',
config('database.connections.pgsql.port', 5432)
);
$this->variables['DB_DATABASE'] = $this->option('database') ?? $this->ask(
'Database Name',
config('database.connections.pgsql.database', 'panel')
);
$this->output->note(trans('commands.database_settings.DB_USERNAME_note'));
$this->variables['DB_USERNAME'] = $this->option('username') ?? $this->ask(
'Database Username',
config('database.connections.pgsql.username', 'pelican')
);
$askForPgSQLPassword = true;
if (!empty(config('database.connections.pgsql.password')) && $this->input->isInteractive()) {
$this->variables['DB_PASSWORD'] = config('database.connections.pgsql.password');
$askForPgSQLPassword = $this->confirm(trans('commands.database_settings.DB_PASSWORD_note'));
}
if ($askForPgSQLPassword) {
$this->variables['DB_PASSWORD'] = $this->option('password') ?? $this->secret('Database Password');
}
try {
// Test connection
config()->set('database.connections._panel_command_test', [
'driver' => 'pgsql',
'host' => $this->variables['DB_HOST'],
'port' => $this->variables['DB_PORT'],
'database' => $this->variables['DB_DATABASE'],
'username' => $this->variables['DB_USERNAME'],
'password' => $this->variables['DB_PASSWORD'],
'charset' => 'utf8',
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
]);
$this->database->connection('_panel_command_test')->getPdo();
} catch (PDOException $exception) {
$this->output->error(sprintf('Unable to connect to the PostgreSQL server using the provided credentials. The error returned was "%s".', $exception->getMessage()));
$this->output->error(trans('commands.database_settings.DB_error_2'));
if ($this->confirm(trans('commands.database_settings.go_back'))) {
$this->database->disconnect('_panel_command_test');
return $this->handle();
}
return 1;
}
} elseif ($this->variables['DB_CONNECTION'] === 'sqlite') {

View File

@@ -16,7 +16,7 @@ class DisablePluginCommand extends Command
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
$plugin = Plugin::find(str($id)->lower()->toString());
if (!$plugin) {
$this->error('Plugin does not exist!');

View File

@@ -18,7 +18,7 @@ class InstallPluginCommand extends Command
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
$plugin = Plugin::find(str($id)->lower()->toString());
if (!$plugin) {
$this->error('Plugin does not exist!');

View File

@@ -18,7 +18,7 @@ class UninstallPluginCommand extends Command
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
$plugin = Plugin::find(str($id)->lower()->toString());
if (!$plugin) {
$this->error('Plugin does not exist!');

View File

@@ -17,7 +17,7 @@ class UpdatePluginCommand extends Command
{
$id = $this->argument('id') ?? $this->choice('Plugin', Plugin::pluck('name', 'id')->toArray());
$plugin = Plugin::find($id);
$plugin = Plugin::find(str($id)->lower()->toString());
if (!$plugin) {
$this->error('Plugin does not exist!');

View File

@@ -41,17 +41,8 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
};
}
public function getColor(bool $hex = false): string
public function getColor(): string
{
if ($hex) {
return match ($this) {
self::Created, self::Restarting => '#2563EB',
self::Starting, self::Paused, self::Removing, self::Stopping => '#D97706',
self::Running => '#22C55E',
self::Exited, self::Missing, self::Dead, self::Offline => '#EF4444',
};
}
return match ($this) {
self::Created => 'primary',
self::Starting => 'warning',

View File

@@ -12,6 +12,7 @@ enum CustomizationKey: string
case DashboardLayout = 'dashboard_layout';
case ButtonStyle = 'button_style';
case RedirectToAdmin = 'redirect_to_admin';
public function getDefaultValue(): string|int|bool
{
@@ -23,6 +24,7 @@ enum CustomizationKey: string
self::TopNavigation => config('panel.filament.default-navigation', 'sidebar'),
self::DashboardLayout => 'grid',
self::ButtonStyle => true,
self::RedirectToAdmin => false,
};
}

12
app/Enums/JwtScope.php Normal file
View File

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

View File

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

View File

@@ -25,16 +25,8 @@ enum ServerState: string implements HasColor, HasIcon, HasLabel
};
}
public function getColor(bool $hex = false): string
public function getColor(): string
{
if ($hex) {
return match ($this) {
self::Installing, self::RestoringBackup => '#2563EB',
self::Suspended => '#D97706',
self::InstallFailed, self::ReinstallFailed => '#EF4444',
};
}
return match ($this) {
self::Installing => 'primary',
self::InstallFailed => 'danger',

View File

@@ -50,6 +50,9 @@ enum SubuserPermission: string
case ActivityRead = 'activity.read';
case MountRead = 'mount.read';
case MountUpdate = 'mount.update';
case StartupRead = 'startup.read';
case StartupUpdate = 'startup.update';
case StartupDockerImage = 'startup.docker-image';
@@ -57,6 +60,7 @@ enum SubuserPermission: string
case SettingsRename = 'settings.rename';
case SettingsDescription = 'settings.description';
case SettingsReinstall = 'settings.reinstall';
case SettingsChangeIcon = 'settings.change-icon';
/** @return string[] */
public function split(): array
@@ -84,6 +88,7 @@ enum SubuserPermission: string
'schedule' => TablerIcon::Clock,
'settings' => TablerIcon::Settings,
'activity' => TablerIcon::Stack,
'mount' => TablerIcon::LayersLinked,
default => null,
};
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Events\Server;
use App\Events\Event;
use App\Models\Backup;
use Illuminate\Queue\SerializesModels;
class BackupCompleted extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public Backup $backup) {}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Events\User;
use App\Events\Event;
use App\Models\User;
use Illuminate\Queue\SerializesModels;
class Deleting extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public User $user) {}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Events\User;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
final class PasswordChanged
{
use Dispatchable;
public function __construct(public readonly User $user) {}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Exceptions;
use App\Exceptions\Solutions\ManifestDoesNotExistSolution;
use Exception;
use Spatie\Ignition\Contracts\ProvidesSolution;
use Spatie\Ignition\Contracts\Solution;
class ManifestDoesNotExistException extends Exception implements ProvidesSolution
{
public function getSolution(): Solution
{
return new ManifestDoesNotExistSolution();
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Exceptions;
use Exception;
class PluginIdMismatchException extends Exception {}

View File

@@ -1,25 +0,0 @@
<?php
namespace App\Exceptions\Solutions;
use Spatie\Ignition\Contracts\Solution;
class ManifestDoesNotExistSolution implements Solution
{
public function getSolutionTitle(): string
{
return "The manifest.json file hasn't been generated yet";
}
public function getSolutionDescription(): string
{
return 'Run yarn run build:production to build the frontend first.';
}
public function getDocumentationLinks(): array
{
return [
'Docs' => 'https://github.com/pelican/panel/blob/master/package.json',
];
}
}

View File

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

View File

@@ -14,13 +14,14 @@ use Boquizo\FilamentLogViewer\Utils\Level;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Tables\Table;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Http;
class ListLogs extends BaseListLogs
{
protected string $view = 'filament.components.list-logs';
public function getHeading(): string|null|\Illuminate\Contracts\Support\Htmlable
public function getHeading(): string|null|Htmlable
{
return trans('admin/log.navigation.panel_logs');
}

View File

@@ -13,6 +13,8 @@ use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use App\Traits\Filament\CanCustomizeTabs;
use BackedEnum;
use BladeUI\Icons\Exceptions\SvgNotFound;
use BladeUI\Icons\Factory as IconFactory;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
@@ -29,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;
@@ -68,6 +72,8 @@ class Settings extends Page implements HasSchemas
protected CaptchaService $captchaService;
protected IconFactory $iconFactory;
/** @var array<mixed>|null */
public ?array $data = [];
@@ -76,11 +82,12 @@ class Settings extends Page implements HasSchemas
$this->form->fill();
}
public function boot(OAuthService $oauthService, AvatarService $avatarService, CaptchaService $captchaService): void
public function boot(OAuthService $oauthService, AvatarService $avatarService, CaptchaService $captchaService, IconFactory $iconFactory): void
{
$this->oauthService = $oauthService;
$this->avatarService = $avatarService;
$this->captchaService = $captchaService;
$this->iconFactory = $iconFactory;
}
public static function canAccess(): bool
@@ -565,9 +572,18 @@ class Settings extends Page implements HasSchemas
foreach ($oauthSchemas as $schema) {
$key = $schema->getConfigKey();
$icon = $schema->getIcon();
if (is_string($icon)) {
try {
$this->iconFactory->svg($icon);
} catch (SvgNotFound) {
$icon = null;
}
}
$formFields[] = Section::make($schema->getName())
->columns(5)
->icon($schema->getIcon() ?? TablerIcon::BrandOauth)
->icon($icon ?? TablerIcon::BrandOauth)
->collapsed(fn () => !$schema->isEnabled())
->collapsible()
->schema([
@@ -669,6 +685,36 @@ class Settings extends Page implements HasSchemas
->collapsible()
->collapsed()
->schema([
Toggle::make('PANEL_SEND_ACCOUNT_CREATED_NOTIFICATION')
->label(trans('admin/setting.misc.mail_notifications.account_created'))
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_ACCOUNT_CREATED_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_ACCOUNT_CREATED_NOTIFICATION', config('panel.email.send_account_created_notification'))),
Toggle::make('PANEL_SEND_ADDED_TO_SERVER_NOTIFICATION')
->label(trans('admin/setting.misc.mail_notifications.added_to_server'))
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_ADDED_TO_SERVER_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_ADDED_TO_SERVER_NOTIFICATION', config('panel.email.send_added_to_server_notification'))),
Toggle::make('PANEL_SEND_REMOVED_FROM_SERVER_NOTIFICATION')
->label(trans('admin/setting.misc.mail_notifications.removed_from_server'))
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REMOVED_FROM_SERVER_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_REMOVED_FROM_SERVER_NOTIFICATION', config('panel.email.send_removed_from_server_notification'))),
Toggle::make('PANEL_SEND_INSTALL_NOTIFICATION')
->label(trans('admin/setting.misc.mail_notifications.server_installed'))
->onIcon(TablerIcon::Check)
@@ -689,6 +735,16 @@ class Settings extends Page implements HasSchemas
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_REINSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_REINSTALL_NOTIFICATION', config('panel.email.send_reinstall_notification'))),
Toggle::make('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION')
->label(trans('admin/setting.misc.mail_notifications.backup_completed'))
->onIcon(TablerIcon::Check)
->offIcon(TablerIcon::X)
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_BACKUP_COMPLETED_NOTIFICATION', config('panel.email.send_backup_completed_notification'))),
]),
Section::make(trans('admin/setting.misc.connections.title'))
->description(trans('admin/setting.misc.connections.helper'))
@@ -740,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'))
@@ -776,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'))
@@ -810,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
@@ -859,6 +1066,5 @@ class Settings extends Page implements HasSchemas
->authorize(fn () => user()?->can('update settings'))
->keyBindings(['mod+s']),
];
}
}

View File

@@ -101,7 +101,7 @@ class DatabaseHostResource extends Resource
->toolbarActions([
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
])
->emptyStateIcon(TablerIcon::Database)

View File

@@ -8,6 +8,7 @@ use App\Services\Databases\Hosts\HostCreationService;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
@@ -45,6 +46,17 @@ class CreateDatabaseHost extends CreateRecord
$this->service = $service;
}
protected function getCreateFormAction(): Action
{
$hasFormWrapper = $this->hasFormWrapper();
return Action::make('exclude_create')
->label(trans('filament-panels::resources/pages/create-record.form.actions.create.label'))
->submit($hasFormWrapper ? $this->getSubmitFormLivewireMethodName() : null)
->action($hasFormWrapper ? null : $this->getSubmitFormLivewireMethodName())
->keyBindings(['mod+s']);
}
/** @return Step[]
* @throws Exception
*/

View File

@@ -103,6 +103,7 @@ class CreateEgg extends CreateRecord
->live()
->columnSpanFull()
->required()
->reorderable()
->addActionLabel(trans('admin/egg.add_startup'))
->keyLabel(trans('admin/egg.startup_name'))
->keyPlaceholder('Default')
@@ -135,6 +136,7 @@ class CreateEgg extends CreateRecord
->live()
->columnSpanFull()
->required()
->reorderable()
->addActionLabel(trans('admin/egg.add_image'))
->keyLabel(trans('admin/egg.docker_name'))
->keyPlaceholder('Java 21')
@@ -175,7 +177,7 @@ class CreateEgg extends CreateRecord
->addActionLabel(trans('admin/egg.add_new_variable'))
->grid()
->relationship('variables')
->reorderable()->orderColumn()
->orderColumn()
->collapsible()->collapsed()
->columnSpan(2)
->defaultItems(0)

View File

@@ -5,8 +5,10 @@ namespace App\Filament\Admin\Resources\Eggs\Pages;
use App\Enums\EditorLanguages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Eggs\EggResource;
use App\Filament\Components\Actions\DeleteIcon;
use App\Filament\Components\Actions\ExportEggAction;
use App\Filament\Components\Actions\ImportEggAction;
use App\Filament\Components\Actions\UploadIcon;
use App\Filament\Components\Forms\Fields\CopyFrom;
use App\Filament\Components\Forms\Fields\MonacoEditor;
use App\Models\Egg;
@@ -14,12 +16,10 @@ use App\Models\EggVariable;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use App\Traits\Filament\CanCustomizeTabs;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
@@ -28,11 +28,8 @@ use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Flex;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Image;
use Filament\Schemas\Components\Tabs;
@@ -40,10 +37,7 @@ use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Support\Enums\IconSize;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\Unique;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class EditEgg extends EditRecord
{
@@ -74,163 +68,17 @@ class EditEgg extends EditRecord
->icon(TablerIcon::Egg)
->schema([
Grid::make(2)
->columnSpan(1)
->columnStart(1)
->schema([
Image::make('', '')
->hidden(fn ($record) => !$record->image)
->url(fn ($record) => $record->image)
->alt('')
->alignJustify()
Image::make('', 'icon')
->hidden(fn ($record) => !$record->icon)
->url(fn ($record) => $record->icon)
->imageSize(150)
->columnSpanFull(),
Flex::make([
Action::make('uploadImage')
->hiddenLabel()
->tooltip(trans('admin/egg.import.import_image'))
->iconSize(IconSize::Large)
->icon(TablerIcon::PhotoUp)
->modal()
->modalHeading('')
->modalSubmitActionLabel(trans('admin/egg.import.import_image'))
->schema([
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
Hidden::make('imageUrl'),
Hidden::make('imageExtension'),
TextInput::make('image_url')
->label(trans('admin/egg.import.image_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('image_url_error', null);
$set('imageUrl', null);
$set('imageExtension', null);
return;
}
try {
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!array_key_exists($extension, Egg::IMAGE_FORMATS)) {
throw new Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Egg::IMAGE_FORMATS))]));
}
$host = parse_url($state, PHP_URL_HOST);
$ip = gethostbyname($host);
if (
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
) {
throw new Exception(trans('admin/egg.import.no_local_ip'));
}
$set('imageUrl', $state);
$set('imageExtension', $extension);
$set('image_url_error', null);
} catch (Exception $e) {
$set('image_url_error', $e->getMessage());
$set('imageUrl', null);
$set('imageExtension', null);
}
}),
TextEntry::make('image_url_error')
->hiddenLabel()
->visible(fn ($get) => $get('image_url_error') !== null)
->afterStateHydrated(fn ($set, $get) => $get('image_url_error')),
Image::make(fn (Get $get) => $get('image_url'), '')
->imageSize(150)
->visible(fn ($get) => $get('image_url') && !$get('image_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('image')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->disk('public')
->directory(Egg::ICON_STORAGE_PATH)
->acceptedFileTypes([
'image/png',
'image/jpeg',
'image/webp',
'image/svg+xml',
])
->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) {
return $record->uuid . '.' . $file->getClientOriginalExtension();
}),
]),
]),
])
->action(function (array $data, $record): void {
if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) {
$this->saveImageFromUrl($data['imageUrl'], $data['imageExtension'], $record);
Notification::make()
->title(trans('admin/egg.import.image_updated'))
->success()
->send();
return;
}
if (!empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.image_updated'))
->success()
->send();
return;
}
if (empty($data['imageUrl']) && empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.no_image'))
->warning()
->send();
}
}),
Action::make('delete_image')
->visible(fn ($record) => $record->image)
->hiddenLabel()
->tooltip(trans('admin/egg.import.delete_image'))
->icon(TablerIcon::Trash)
->iconSize(IconSize::Large)
->color('danger')
->action(function ($record) {
foreach (array_keys(Egg::IMAGE_FORMATS) as $ext) {
$path = Egg::ICON_STORAGE_PATH . "/$record->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
Notification::make()
->title(trans('admin/egg.import.image_deleted'))
->success()
->send();
$record->refresh();
}),
]),
->columnSpanFull()
->alignJustify(),
UploadIcon::make(),
DeleteIcon::make()
->iconStoragePath(Egg::getIconStoragePath()),
]),
TextInput::make('name')
->label(trans('admin/egg.name'))
@@ -270,6 +118,7 @@ class EditEgg extends EditRecord
->live()
->columnSpanFull()
->required()
->reorderable()
->addActionLabel(trans('admin/egg.add_startup'))
->keyLabel(trans('admin/egg.startup_name'))
->valueLabel(trans('admin/egg.startup_command'))
@@ -297,6 +146,7 @@ class EditEgg extends EditRecord
->live()
->columnSpanFull()
->required()
->reorderable()
->addActionLabel(trans('admin/egg.add_image'))
->keyLabel(trans('admin/egg.docker_name'))
->valueLabel(trans('admin/egg.docker_uri'))
@@ -318,6 +168,7 @@ class EditEgg extends EditRecord
->helperText(trans('admin/egg.start_config_help')),
Textarea::make('config_files')->rows(10)->json()
->label(trans('admin/egg.config_files'))
->dehydrateStateUsing(fn ($state) => blank($state) ? '{}' : $state)
->helperText(trans('admin/egg.config_files_help')),
Textarea::make('config_logs')->rows(10)->json()
->label(trans('admin/egg.log_config'))
@@ -332,9 +183,8 @@ class EditEgg extends EditRecord
->hiddenLabel()
->grid()
->relationship('variables')
->reorderable()
->collapsible()->collapsed()
->orderColumn()
->collapsible()->collapsed()
->addActionLabel(trans('admin/egg.add_new_variable'))
->itemLabel(fn (array $state) => $state['name'])
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
@@ -468,37 +318,6 @@ class EditEgg extends EditRecord
$this->fillForm();
}
/**
* Save an image from URL download to a file.
*
* @throws Exception
*/
private function saveImageFromUrl(string $imageUrl, string $extension, Egg $egg): void
{
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$data = @file_get_contents($imageUrl, false, $context, 0, 1048576); // 1024KB
if (empty($data)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$normalizedExtension = match ($extension) {
'svg+xml' => 'svg',
'jpeg' => 'jpg',
default => $extension,
};
Storage::disk('public')->put(Egg::ICON_STORAGE_PATH . "/$egg->uuid.$normalizedExtension", $data);
}
protected function getFormActions(): array
{
return [];

View File

@@ -38,6 +38,10 @@ class ListEggs extends ListRecords
*/
public function table(Table $table): Table
{
$defaultEggIcon = config('app.logo');
$defaultEggIcon = empty($defaultEggIcon) || !is_file(public_path($defaultEggIcon)) ? 'pelican.svg' : $defaultEggIcon;
$defaultEggIcon = 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path($defaultEggIcon)));
return $table
->searchable(true)
->defaultPaginationPageOption(25)
@@ -45,13 +49,11 @@ class ListEggs extends ListRecords
TextColumn::make('id')
->label('Id')
->hidden(),
ImageColumn::make('image')
ImageColumn::make('icon')
->label('')
->alignCenter()
->circular()
->getStateUsing(fn ($record) => $record->image
? $record->image
: 'data:image/svg+xml;base64,' . base64_encode(file_get_contents(public_path('pelican.svg')))),
->getStateUsing(fn (Egg $record) => $record->icon ?: $defaultEggIcon),
TextColumn::make('name')
->label(trans('admin/egg.name'))
->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)
@@ -86,7 +88,7 @@ class ListEggs extends ListRecords
->multiple(),
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make()
DeleteBulkAction::make('exclude_bulk_delete')
->before(function (Collection &$records) {
$eggsWithServers = $records->filter(fn (Egg $egg) => $egg->servers_count > 0);
@@ -106,7 +108,7 @@ class ListEggs extends ListRecords
$this->halt();
}
}),
UpdateEggBulkAction::make()
UpdateEggBulkAction::make('exclude_bulk_update')
->before(function (Collection &$records) {
$eggsWithoutUpdateUrl = $records->filter(fn (Egg $egg) => $egg->update_url === null);

View File

@@ -95,6 +95,12 @@ class MountResource extends Resource
->icon(fn ($state) => $state ? TablerIcon::WritingOff : TablerIcon::Writing)
->color(fn ($state) => $state ? 'success' : 'warning')
->formatStateUsing(fn ($state) => $state ? trans('admin/mount.toggles.read_only') : trans('admin/mount.toggles.writable')),
TextColumn::make('user_mountable')
->label(trans('admin/mount.table.user_mountable'))
->badge()
->icon(fn ($state) => $state ? TablerIcon::User : TablerIcon::UserOff)
->color(fn ($state) => $state ? 'success' : 'warning')
->formatStateUsing(fn ($state) => $state ? trans('admin/mount.toggles.user_mountable') : trans('admin/mount.toggles.not_user_mountable')),
])
->recordActions([
ViewAction::make()
@@ -104,7 +110,7 @@ class MountResource extends Resource
->toolbarActions([
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
])
->emptyStateIcon(TablerIcon::LayersLinked)
@@ -124,7 +130,8 @@ class MountResource extends Resource
->label(trans('admin/mount.name'))
->required()
->helperText(trans('admin/mount.name_help'))
->maxLength(64),
->maxLength(64)
->columnSpanFull(),
ToggleButtons::make('read_only')
->label(trans('admin/mount.read_only'))
->helperText(trans('admin/mount.read_only_help'))
@@ -143,6 +150,24 @@ class MountResource extends Resource
])
->inline()
->default(false),
ToggleButtons::make('user_mountable')
->label(trans('admin/mount.user_mountable'))
->helperText(trans('admin/mount.user_mountable_help'))
->stateCast(new BooleanStateCast(false, true))
->options([
false => trans('admin/mount.toggles.not_user_mountable'),
true => trans('admin/mount.toggles.user_mountable'),
])
->icons([
false => TablerIcon::UserOff,
true => TablerIcon::User,
])
->colors([
false => 'warning',
true => 'success',
])
->inline()
->default(true),
TextInput::make('source')
->label(trans('admin/mount.source'))
->required()

View File

@@ -42,7 +42,6 @@ class CreateMount extends CreateRecord
protected function handleRecordCreation(array $data): Model
{
$data['uuid'] ??= Str::uuid()->toString();
$data['user_mountable'] = 1;
return parent::handleRecordCreation($data);
}

View File

@@ -64,9 +64,8 @@ class CreateNode extends CreateRecord
->icon(TablerIcon::Server)
->columnSpanFull()
->columns([
'default' => 2,
'sm' => 3,
'md' => 3,
'default' => 1,
'md' => 2,
'lg' => 4,
])
->schema([
@@ -76,81 +75,83 @@ class CreateNode extends CreateRecord
->autofocus()
->live(debounce: 1500)
->rules(Node::getRulesForField('fqdn'))
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null)
->validationMessages([
'prohibited' => trans('admin/node.dns_error'),
])
->prohibited(function ($state, Get $get) {
if (!$state) {
return true;
}
if (is_ip($state)) {
return false;
}
$ip = $get('ip');
return !is_ip($ip);
})
->hintColor(function ($state, Get $get) {
if (!$state) {
return null;
}
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.fqdn_help');
return 'warning';
}
} else {
$ip = $get('ip');
return '';
return is_ip($ip) ? 'success' : 'danger';
}
return trans('admin/node.error');
return null;
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return trans('admin/node.ssl_ip');
->hint(function ($state, Get $get) {
if (!$state) {
return null;
}
return '';
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.ssl_ip');
}
} else {
$ip = $get('ip');
return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid');
}
return null;
})
->afterStateUpdated(function (Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
if (!$state) {
return;
}
[$subdomain] = str($state)->explode('.', 2);
if (!is_numeric($subdomain)) {
$set('name', $subdomain);
}
if (!$state || is_ip($state)) {
$set('dns', null);
return;
}
$ip = get_ip_from_hostname($state);
if ($ip) {
$set('dns', true);
$set('ip', $ip);
} else {
$set('dns', false);
if (!is_ip($state)) {
$ip = get_ip_from_hostname($state);
if (is_ip($ip)) {
$set('ip', $ip);
} else {
$set('ip', null);
}
}
})
->maxLength(255),
TextInput::make('ip')
->disabled()
->hidden(),
ToggleButtons::make('dns')
->label(trans('admin/node.dns'))
->helperText(trans('admin/node.dns_help'))
->disabled()
->inline()
->default(null)
->hint(fn (Get $get) => $get('ip'))
->hintColor('success')
->options([
true => trans('admin/node.valid'),
false => trans('admin/node.invalid'),
])
->colors([
true => 'success',
false => 'danger',
])
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
]),
Hidden::make('ip')
->saved(false),
TextInput::make('daemon_connect')
->columnSpan(1)
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
@@ -160,7 +161,16 @@ class CreateNode extends CreateRecord
->default(8080)
->required()
->integer(),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
TextInput::make('name')
->label(trans('admin/node.display_name'))
->columnSpan([
@@ -171,27 +181,16 @@ class CreateNode extends CreateRecord
])
->required()
->maxLength(100),
Hidden::make('scheme')
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Hidden::make('behind_proxy')
->default(false),
ToggleButtons::make('connection')
->label(trans('admin/node.ssl'))
->columnSpan(1)
->columnSpan(2)
->inline()
->helperText(function (Get $get) {
->helperText(function () {
if (request()->isSecure()) {
return new HtmlString(trans('admin/node.panel_on_ssl'));
return trans('admin/node.panel_on_ssl');
}
if (is_ip($get('fqdn'))) {
return trans('admin/node.ssl_help');
}
return '';
return null;
})
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([
@@ -219,17 +218,10 @@ class CreateNode extends CreateRecord
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
$set('daemon_listen', 8080);
}),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
Hidden::make('scheme')
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Hidden::make('behind_proxy')
->default(false),
]),
Step::make('advanced')
->label(trans('admin/node.tabs.advanced_settings'))

View File

@@ -137,74 +137,83 @@ class EditNode extends EditRecord
->autofocus()
->live(debounce: 1500)
->rules(Node::getRulesForField('fqdn'))
->prohibited(fn ($state) => is_ip($state) && request()->isSecure())
->label(fn ($state) => is_ip($state) ? trans('admin/node.ip_address') : trans('admin/node.domain'))
->placeholder(fn ($state) => is_ip($state) ? '192.168.1.1' : 'node.example.com')
->helperText(function ($state) {
->helperText(fn () => request()->isSecure() ? trans('admin/node.fqdn_ssl') : null)
->validationMessages([
'prohibited' => trans('admin/node.dns_error'),
])
->prohibited(function ($state, Get $get) {
if (!$state) {
return true;
}
if (is_ip($state)) {
return false;
}
$ip = $get('ip');
return !is_ip($ip);
})
->hintColor(function ($state, Get $get) {
if (!$state) {
return null;
}
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.fqdn_help');
return 'warning';
}
} else {
$ip = $get('ip');
return '';
return is_ip($ip) ? 'success' : 'danger';
}
return trans('admin/node.error');
return null;
})
->hintColor('danger')
->hint(function ($state) {
if (is_ip($state) && request()->isSecure()) {
return trans('admin/node.ssl_ip');
->hint(function ($state, Get $get) {
if (!$state) {
return null;
}
return '';
if (is_ip($state)) {
if (request()->isSecure()) {
return trans('admin/node.ssl_ip');
}
} else {
$ip = $get('ip');
return is_ip($ip) ? trans('admin/node.valid') . ': ' . $ip : trans('admin/node.invalid');
}
return null;
})
->afterStateUpdated(function (Set $set, ?string $state) {
$set('dns', null);
$set('ip', null);
if (!$state) {
return;
}
[$subdomain] = str($state)->explode('.', 2);
if (!is_numeric($subdomain)) {
$set('name', $subdomain);
}
if (!$state || is_ip($state)) {
$set('dns', null);
return;
}
$ip = get_ip_from_hostname($state);
if ($ip) {
$set('dns', true);
$set('ip', $ip);
} else {
$set('dns', false);
if (!is_ip($state)) {
$ip = get_ip_from_hostname($state);
if (is_ip($ip)) {
$set('ip', $ip);
} else {
$set('ip', null);
}
}
})
->maxLength(255),
TextInput::make('ip')
->disabled()
->hidden(),
ToggleButtons::make('dns')
->label(trans('admin/node.dns'))
->helperText(trans('admin/node.dns_help'))
->disabled()
->inline()
->default(null)
->hint(fn (Get $get) => $get('ip'))
->hintColor('success')
->stateCast(new BooleanStateCast(false, true))
->options([
1 => trans('admin/node.valid'),
0 => trans('admin/node.invalid'),
])
->colors([
1 => 'success',
0 => 'danger',
])
->columnSpan(1),
Hidden::make('ip')
->saved(false),
TextInput::make('daemon_connect')
->columnSpan(1)
->label(fn (Get $get) => $get('connection') === 'https_proxy' ? trans('admin/node.connect_port') : trans('admin/node.port'))
@@ -214,6 +223,16 @@ class EditNode extends EditRecord
->default(8080)
->required()
->integer(),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
TextInput::make('name')
->label(trans('admin/node.display_name'))
->columnSpan([
@@ -224,22 +243,16 @@ class EditNode extends EditRecord
])
->required()
->maxLength(100),
Hidden::make('scheme'),
Hidden::make('behind_proxy'),
ToggleButtons::make('connection')
->label(trans('admin/node.ssl'))
->columnSpan(1)
->columnSpan(2)
->inline()
->helperText(function (Get $get) {
->helperText(function () {
if (request()->isSecure()) {
return new HtmlString(trans('admin/node.panel_on_ssl'));
return trans('admin/node.panel_on_ssl');
}
if (is_ip($get('fqdn'))) {
return trans('admin/node.ssl_help');
}
return '';
return null;
})
->disableOptionWhen(fn (string $value) => $value === 'http' && request()->isSecure())
->options([
@@ -267,16 +280,10 @@ class EditNode extends EditRecord
$set('daemon_connect', $state === 'https_proxy' ? 443 : 8080);
$set('daemon_listen', 8080);
}),
TextInput::make('daemon_listen')
->columnSpan(1)
->label(trans('admin/node.listen_port'))
->helperText(trans('admin/node.listen_port_help'))
->minValue(1)
->maxValue(65535)
->default(8080)
->required()
->integer()
->visible(fn (Get $get) => $get('connection') === 'https_proxy'),
Hidden::make('scheme')
->default(fn () => request()->isSecure() ? 'https' : 'http'),
Hidden::make('behind_proxy')
->default(false),
]),
Tab::make('advanced_settings')
->label(trans('admin/node.tabs.advanced_settings'))
@@ -735,7 +742,7 @@ class EditNode extends EditRecord
$set('pulled', false);
$set('uploaded', true);
} catch (\Exception $e) {
} catch (Exception $e) {
Notification::make()
->title(trans('admin/node.diagnostics.upload_failed'))
->body($e->getMessage())

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Admin\Resources\Nodes\Pages;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Nodes\NodeResource;
use App\Filament\Components\Tables\Columns\NodeClientHealthColumn;
use App\Filament\Components\Tables\Columns\NodeHealthColumn;
use App\Filament\Components\Tables\Filters\TagsFilter;
use App\Models\Node;
@@ -34,6 +35,7 @@ class ListNodes extends ListRecords
->searchable()
->hidden(),
NodeHealthColumn::make('health'),
NodeClientHealthColumn::make('reachable'),
TextColumn::make('name')
->label(trans('admin/node.table.name'))
->sortable()

View File

@@ -91,7 +91,10 @@ class AllocationsRelationManager extends RelationManager
->icon(TablerIcon::WorldPlus)
->schema(fn () => [
Select::make('allocation_ip')
->options(fn () => collect($this->getOwnerRecord()->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->options(fn (Get $get) => collect($this->getOwnerRecord()->ipAddresses())
->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current))
->unique()
->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/node.ip_address'))
->inlineLabel()
->ip()
@@ -100,12 +103,25 @@ class AllocationsRelationManager extends RelationManager
->live()
->hintAction(
Action::make('hint_refresh')
->hiddenLabel()
->icon(TablerIcon::Refresh)
->tooltip(trans('admin/node.refresh'))
->action(function () {
cache()->forget("nodes.{$this->getOwnerRecord()->id}.ips");
})
)
->suffixAction(
Action::make('custom_ip')
->icon(TablerIcon::Keyboard)
->tooltip(trans('admin/node.custom_ip'))
->schema([
TextInput::make('custom_ip')
->label(trans('admin/node.ip_address'))
->ip()
->required(),
])
->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip']))
)
->required(),
TextInput::make('allocation_alias')
->label(trans('admin/node.table.alias'))

View File

@@ -5,6 +5,9 @@ namespace App\Filament\Admin\Resources\Plugins;
use App\Enums\PluginStatus;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
use App\Jobs\Plugin\InstallPlugin;
use App\Jobs\Plugin\UninstallPlugin;
use App\Jobs\Plugin\UpdatePlugin;
use App\Models\Plugin;
use App\Services\Helpers\PluginService;
use BackedEnum;
@@ -119,15 +122,14 @@ class PluginResource extends Resource
->icon(TablerIcon::Terminal)
->color('success')
->hidden(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
->action(function (Plugin $plugin) {
try {
$pluginService->installPlugin($plugin, !$plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
InstallPlugin::dispatch(user(), $plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.installed'))
->title(trans('admin/plugin.notifications.install_started'))
->body(trans('admin/plugin.notifications.background_info'))
->send();
} catch (Exception $exception) {
Notification::make()
@@ -143,15 +145,14 @@ class PluginResource extends Resource
->icon(TablerIcon::Download)
->color('success')
->visible(fn (Plugin $plugin) => $plugin->status !== PluginStatus::NotInstalled && $plugin->isUpdateAvailable())
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
->action(function (Plugin $plugin) {
try {
$pluginService->updatePlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
UpdatePlugin::dispatch(user(), $plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.updated'))
->title(trans('admin/plugin.notifications.update_started'))
->body(trans('admin/plugin.notifications.background_info'))
->send();
} catch (Exception $exception) {
Notification::make()
@@ -202,7 +203,7 @@ class PluginResource extends Resource
->icon(TablerIcon::Trash)
->color('danger')
->requiresConfirmation()
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled)
->visible(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled || $plugin->status === PluginStatus::Errored)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
$pluginService->deletePlugin($plugin);
@@ -220,15 +221,14 @@ class PluginResource extends Resource
->color('danger')
->requiresConfirmation()
->hidden(fn (Plugin $plugin) => $plugin->status === PluginStatus::NotInstalled || $plugin->status === PluginStatus::Errored)
->action(function (Plugin $plugin, $livewire, PluginService $pluginService) {
->action(function (Plugin $plugin) {
try {
$pluginService->uninstallPlugin($plugin);
redirect(ListPlugins::getUrl(['tab' => $livewire->activeTab]));
UninstallPlugin::dispatch(user(), $plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.uninstalled'))
->title(trans('admin/plugin.notifications.uninstall_started'))
->body(trans('admin/plugin.notifications.background_info'))
->send();
} catch (Exception $exception) {
Notification::make()
@@ -260,7 +260,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'));

View File

@@ -106,7 +106,7 @@ class RoleResource extends Resource
->toolbarActions([
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
])
->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0);

View File

@@ -251,7 +251,10 @@ class CreateServer extends CreateRecord
return [
Select::make('allocation_ip')
->options(fn () => collect(Node::find($get('node_id'))?->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->options(fn (Get $get) => collect(Node::find($getPage('node_id'))?->ipAddresses())
->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current))
->unique()
->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/server.ip_address'))->inlineLabel()
->helperText(trans('admin/server.ip_address_helper'))
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
@@ -266,6 +269,18 @@ class CreateServer extends CreateRecord
cache()->forget("nodes.{$get('node_id')}.ips");
})
)
->suffixAction(
Action::make('custom_ip')
->icon(TablerIcon::Keyboard)
->tooltip(trans('admin/node.custom_ip'))
->schema([
TextInput::make('custom_ip')
->label(trans('admin/node.ip_address'))
->ip()
->required(),
])
->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip']))
)
->required(),
TextInput::make('allocation_alias')
->label(trans('admin/server.alias'))->inlineLabel()

View File

@@ -5,8 +5,9 @@ namespace App\Filament\Admin\Resources\Servers\Pages;
use App\Enums\SuspendAction;
use App\Enums\TablerIcon;
use App\Filament\Admin\Resources\Servers\ServerResource;
use App\Filament\Components\Actions\DeleteServerIcon;
use App\Filament\Components\Actions\DeleteIcon;
use App\Filament\Components\Actions\PreviewStartupAction;
use App\Filament\Components\Actions\UploadIcon;
use App\Filament\Components\Forms\Fields\MonacoEditor;
use App\Filament\Components\Forms\Fields\StartupVariable;
use App\Filament\Components\StateCasts\ServerConditionStateCast;
@@ -31,7 +32,6 @@ use Exception;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Repeater;
@@ -41,7 +41,6 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Schemas\Components\Actions;
@@ -60,9 +59,7 @@ use Filament\Support\Enums\Alignment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\HtmlString;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use LogicException;
use Random\RandomException;
@@ -111,140 +108,18 @@ class EditServer extends EditRecord
->icon(TablerIcon::InfoCircle)
->schema([
Grid::make()
->columns(2)
->columnStart(1)
->schema([
Image::make('', 'icon')
->hidden(fn ($record) => !$record->icon && !$record->egg->image)
->url(fn ($record) => $record->icon ?: $record->egg->image)
->hidden(fn ($record) => !$record->icon && !$record->egg->icon)
->url(fn ($record) => $record->icon ?: $record->egg->icon)
->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip'))
->columnSpan(2)
->imageSize(150)
->columnSpanFull()
->alignJustify(),
Action::make('uploadIcon')
->icon(TablerIcon::PhotoUp)
->tooltip(trans('admin/server.import_image'))
->modal()
->modalSubmitActionLabel(trans('server/setting.server_info.icon.upload'))
->schema([
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
Hidden::make('imageUrl'),
Hidden::make('imageExtension'),
TextInput::make('image_url')
->label(trans('admin/egg.import.image_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('image_url_error', null);
$set('imageUrl', null);
$set('imageExtension', null);
return;
}
try {
if (!in_array(parse_url($state, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!array_key_exists($extension, Server::IMAGE_FORMATS)) {
throw new \Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Server::IMAGE_FORMATS))]));
}
$host = parse_url($state, PHP_URL_HOST);
$ip = gethostbyname($host);
if (
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
) {
throw new \Exception(trans('admin/egg.import.no_local_ip'));
}
$set('imageUrl', $state);
$set('imageExtension', $extension);
$set('image_url_error', null);
} catch (\Exception $e) {
$set('image_url_error', $e->getMessage());
$set('imageUrl', null);
$set('imageExtension', null);
}
}),
TextEntry::make('image_url_error')
->hiddenLabel()
->visible(fn (Get $get) => $get('image_url_error') !== null)
->afterStateHydrated(fn (Get $get) => $get('image_url_error')),
Image::make(fn (Get $get) => $get('image_url'), '')
->imageSize(150)
->visible(fn (Get $get) => $get('image_url') && !$get('image_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('image')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->disk('public')
->directory(Server::ICON_STORAGE_PATH)
->acceptedFileTypes([
'image/png',
'image/jpeg',
'image/webp',
'image/svg+xml',
])
->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) {
return $record->uuid . '.' . $file->getClientOriginalExtension();
}),
]),
]),
])
->action(function (array $data, $record): void {
if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) {
$this->saveIconFromUrl($data['imageUrl'], $data['imageExtension'], $record);
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
return;
}
if (!empty($data['image'])) {
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
return;
}
if (empty($data['imageUrl']) && empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.no_image'))
->warning()
->send();
}
}),
DeleteServerIcon::make(),
UploadIcon::make(),
DeleteIcon::make()
->iconStoragePath(Server::getIconStoragePath()),
]),
Grid::make()
->columns(3)
@@ -320,7 +195,7 @@ class EditServer extends EditRecord
try {
$logs = $serverRepository->setServer($server)->getInstallLogs();
return mb_convert_encoding($logs, 'UTF-8', ['UTF-8', 'UTF-16', 'ISO-8859-1', 'ASCII']);
return convert_to_utf8($logs);
} catch (ConnectionException) {
Notification::make()
->title(trans('admin/server.notifications.error_connecting', ['node' => $server->node->name]))
@@ -935,7 +810,7 @@ class EditServer extends EditRecord
->send();
}
}),
Action::make('toggleUnsuspend')
Action::make('exclude_toggle_unsuspend')
->label(trans('admin/server.unsuspend'))
->color('success')
->hidden(fn (Server $server) => !$server->isSuspended())
@@ -1126,7 +1001,7 @@ class EditServer extends EditRecord
->hidden(fn () => $canForceDelete)
->authorize(fn (Server $server) => user()?->can('delete server', $server))
->icon(TablerIcon::Trash),
Action::make('ForceDelete')
Action::make('exclude_force_delete')
->color('danger')
->label(trans('filament-actions::force-delete.single.label'))
->modalHeading(trans('filament-actions::force-delete.single.modal.heading', ['label' => $this->getRecordTitle()]))
@@ -1201,35 +1076,4 @@ class EditServer extends EditRecord
{
return null;
}
/**
* Save an icon from URL download to a file.
*
* @throws Exception
*/
private function saveIconFromUrl(string $imageUrl, string $extension, Server $server): void
{
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB
if (empty($data)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
$normalizedExtension = match ($extension) {
'svg+xml' => 'svg',
'jpeg' => 'jpg',
default => $extension,
};
Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data);
}
}

View File

@@ -113,7 +113,10 @@ class AllocationsRelationManager extends RelationManager
->createAnother(false)
->schema(fn () => [
Select::make('allocation_ip')
->options(fn () => collect($this->getOwnerRecord()->node->ipAddresses())->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->options(fn (Get $get) => collect($this->getOwnerRecord()->node->ipAddresses())
->when($get('allocation_ip'), fn ($ips, $current) => $ips->push($current))
->unique()
->mapWithKeys(fn (string $ip) => [$ip => $ip]))
->label(trans('admin/server.ip_address'))
->inlineLabel()
->ip()
@@ -126,6 +129,18 @@ class AllocationsRelationManager extends RelationManager
cache()->forget("nodes.{$this->getOwnerRecord()->node->id}.ips");
})
)
->suffixAction(
Action::make('custom_ip')
->icon(TablerIcon::Keyboard)
->tooltip(trans('admin/node.custom_ip'))
->schema([
TextInput::make('custom_ip')
->label(trans('admin/node.ip_address'))
->ip()
->required(),
])
->action(fn (array $data, Set $set) => $set('allocation_ip', $data['custom_ip']))
)
->afterStateUpdated(fn (Set $set) => $set('allocation_ports', []))
->required(),
TextInput::make('allocation_alias')

View File

@@ -142,7 +142,7 @@ class UserResource extends Resource
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
CreateAction::make()
->hiddenLabel()

View File

@@ -114,7 +114,7 @@ class WebhookResource extends Resource
->toolbarActions([
CreateAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make('exclude_bulk_delete'),
]),
])
->emptyStateIcon(TablerIcon::Webhook)

View File

@@ -70,7 +70,7 @@ class ListServers extends ListRecords
ImageColumn::make('icon')
->label('')
->imageSize(46)
->state(fn (Server $server) => $server->icon ?: $server->egg->image),
->state(fn (Server $server) => $server->icon ?: $server->egg->icon),
TextColumn::make('condition')
->label(trans('server/dashboard.status'))
->badge()
@@ -81,7 +81,8 @@ class ListServers extends ListRecords
->label(trans('server/dashboard.title'))
->description(fn (Server $server) => $server->description)
->grow()
->searchable(),
->searchable()
->sortable(),
TextColumn::make('allocation.address')
->label('')
->badge()

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Filament\Components\Actions;
use App\Enums\TablerIcon;
use App\Models\Traits\HasIcon;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
class DeleteIcon extends Action
{
/** @var string[] */
protected ?array $iconFormats = null;
protected ?string $iconStoragePath = null;
public static function getDefaultName(): ?string
{
return 'delete_icon';
}
protected function setUp(): void
{
parent::setUp();
$this->visible(fn ($record) => $record->icon);
$this->hiddenLabel();
$this->tooltip(trans('admin/egg.import.delete_icon'));
$this->icon(TablerIcon::Trash);
$this->color('danger');
$this->action(function ($record) {
foreach ($this->getIconFormats() as $ext) {
$path = $this->getIconStoragePath() . "/$record->uuid.$ext";
if (Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
}
Notification::make()
->title(trans('admin/egg.import.icon_deleted'))
->success()
->send();
$record->refresh();
});
}
/** @param string[] $iconFormats */
public function iconFormats(?array $iconFormats): static
{
$this->iconFormats = $iconFormats;
return $this;
}
public function iconStoragePath(?string $iconStoragePath): static
{
$this->iconStoragePath = $iconStoragePath;
return $this;
}
/** @return string[] */
public function getIconFormats(): array
{
return $this->iconFormats ?? array_keys(HasIcon::$iconFormats);
}
public function getIconStoragePath(): ?string
{
return $this->iconStoragePath;
}
}

View File

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

View File

@@ -16,13 +16,15 @@ class RotateDatabasePasswordAction extends Action
{
public static function getDefaultName(): ?string
{
return 'hint_rotate';
return 'exclude_hint_rotate';
}
protected function setUp(): void
{
parent::setUp();
$this->hiddenLabel();
$this->tooltip(trans('admin/databasehost.rotate'));
$this->icon(TablerIcon::Refresh);

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Filament\Components\Actions;
use App\Enums\TablerIcon;
use App\Models\Traits\HasIcon;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Image;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\Facades\Http;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class UploadIcon extends Action
{
/** @var string[] */
protected ?array $iconFormats = null;
public static function getDefaultName(): ?string
{
return 'upload_icon';
}
protected function setUp(): void
{
parent::setUp();
$this->hiddenLabel();
$this->tooltip(trans('admin/egg.import.import_icon'));
$this->icon(TablerIcon::PhotoUp);
$this->modal();
$this->modalHeading('');
$this->modalSubmitActionLabel(trans('admin/egg.import.import_icon'));
$this->schema([
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
TextInput::make('icon_url')
->label(trans('admin/egg.import.icon_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('icon_url_error', null);
return;
}
try {
$this->validateIconUrl($state);
$set('icon_url_error', null);
} catch (Exception $exception) {
$set('icon_url_error', $exception->getMessage());
}
}),
TextEntry::make('icon_url_error')
->hiddenLabel()
->visible(fn (Get $get) => $get('icon_url_error') !== null)
->afterStateHydrated(fn (Get $get) => $get('icon_url_error')),
Image::make(fn (Get $get) => $get('icon_url'), '')
->imageSize(150)
->visible(fn (Get $get) => $get('icon_url') && !$get('icon_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('icon')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->acceptedFileTypes(fn () => $this->getIconFormats())
->saveUploadedFileUsing(fn (TemporaryUploadedFile $file, $record) => $record->writeIcon($file->getClientOriginalExtension(), $file->getContent())),
]),
]),
]);
$this->action(function (array $data, $record) {
if (!empty($data['icon_url'])) {
$this->validateIconUrl($data['icon_url']);
$content = Http::timeout(5)->connectTimeout(1)->withoutRedirecting()->get($data['icon_url'])->body();
if (empty($content)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($data['icon_url'], PHP_URL_PATH), PATHINFO_EXTENSION));
if (empty($extension)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$record->writeIcon($extension, $content);
Notification::make()
->title(trans('admin/egg.import.icon_updated'))
->success()
->send();
} elseif (!empty($data['icon'])) {
Notification::make()
->title(trans('admin/egg.import.icon_updated'))
->success()
->send();
} else {
Notification::make()
->title(trans('admin/egg.import.no_icon'))
->warning()
->send();
}
});
}
protected function validateIconUrl(string $url): void
{
if (!in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
if (!filter_var($url, FILTER_VALIDATE_URL)) {
throw new Exception(trans('admin/egg.import.invalid_url'));
}
$host = parse_url($url, PHP_URL_HOST);
$ip = gethostbyname($host);
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new Exception(trans('admin/egg.import.no_local_ip'));
}
}
/** @param string[] $iconFormats */
public function iconFormats(?array $iconFormats): static
{
$this->iconFormats = $iconFormats;
return $this;
}
/** @return string[] */
public function getIconFormats(): array
{
return $this->iconFormats ?? array_values(HasIcon::$iconFormats);
}
}

View File

@@ -49,7 +49,7 @@ class StartupVariable extends Field
$this->hintIcon(TablerIcon::Code, fn (StartupVariable $component) => implode('|', $component->getVariableRules()));
$this->helperText(fn (StartupVariable $component) => !$component->getVariableDesc() ? '—' : $component->getVariableDesc());
$this->helperText(fn (StartupVariable $component) => $component->getVariableDesc());
$this->rules(fn (StartupVariable $component) => $component->getVariableRules());
@@ -70,7 +70,7 @@ class StartupVariable extends Field
],
StartupVariableType::Toggle => [
...parent::getDefaultStateCasts(),
new BooleanStateCast(false),
new BooleanStateCast(false, true),
],
default => parent::getDefaultStateCasts()
};

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Components\Tables\Columns;
use Filament\Support\Enums\Alignment;
use Filament\Tables\Columns\IconColumn;
use Illuminate\Support\Facades\Blade;
class NodeClientHealthColumn extends IconColumn
{
protected function setUp(): void
{
parent::setUp();
$this->label(trans('admin/node.table.reachable'));
$this->alignCenter();
}
public function toEmbeddedHtml(): string
{
$alignment = $this->getAlignment();
$attributes = $this->getExtraAttributeBag()
->class([
'fi-ta-icon',
'fi-inline' => $this->isInline(),
'fi-ta-icon-has-line-breaks' => $this->isListWithLineBreaks(),
'fi-wrapped' => $this->canWrap(),
($alignment instanceof Alignment) ? "fi-align-{$alignment->value}" : (is_string($alignment) ? $alignment : ''),
])
->toHtml();
return Blade::render(<<<'BLADE'
<div <?= $attributes ?>>
@livewire('node-client-connectivity', ['node' => $record, 'lazy' => true])
</div>
BLADE, [
'attributes' => $attributes,
'record' => $this->getRecord(),
]);
}
}

View File

@@ -21,9 +21,9 @@ class TagsFilter extends BaseFilter
{
parent::setUp();
$this->query(fn (Builder $query, array $data) => $query->when($data['tag'], fn (Builder $query, $tag) => $query->whereJsonContains('tags', $tag)));
$this->query(fn (Builder $query, array $data) => $query->when($data['tag'] ?? null, fn (Builder $query, $tag) => $query->whereJsonContains('tags', $tag)));
$this->indicateUsing(fn (array $data) => $data['tag'] ? 'Tag: ' . $data['tag'] : null);
$this->indicateUsing(fn (array $data) => ($data['tag'] ?? null) ? 'Tag: ' . $data['tag'] : null);
$this->resetState(['tag' => null]);

View File

@@ -254,7 +254,7 @@ class EditProfile extends BaseEditProfile
->columnSpanFull(),
])
->headerActions([
Action::make('create_api_key')
Action::make('exclude_create_api_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('description')))
->successRedirectUrl(self::getUrl(['tab' => 'api-keys::data::tab'], panel: 'app'))
@@ -343,7 +343,7 @@ class EditProfile extends BaseEditProfile
->live(),
])
->headerActions([
Action::make('create_ssh_key')
Action::make('exclude_create_ssh_key')
->label(trans('filament-actions::create.single.modal.actions.create.label'))
->disabled(fn (Get $get) => empty($get('name')) || empty($get('public_key')))
->successRedirectUrl(self::getUrl(['tab' => 'ssh-keys::data::tab'], panel: 'app'))
@@ -426,13 +426,13 @@ class EditProfile extends BaseEditProfile
->label(trans('profile.tabs.activity'))
->icon(TablerIcon::History)
->schema([
Repeater::make('activity')
->hiddenLabel()
Repeater::make('activity') // TODO: move to a table
->label(trans('profile.activity_info'))
->inlineLabel(false)
->deletable(false)
->addable(false)
->relationship(null, function (Builder $query) {
$query->orderBy('timestamp', 'desc');
$query->orderBy('timestamp', 'desc')->limit(50);
})
->schema([
TextEntry::make('log')
@@ -474,6 +474,17 @@ class EditProfile extends BaseEditProfile
false => 'Icon Button',
]),
]),
Section::make(trans('profile.admin'))
->collapsible()
->icon(TablerIcon::Shield)
->visible(fn (User $user) => $user->isAdmin())
->schema([
ToggleButtons::make('redirect_to_admin')
->label(trans('profile.redirect_to_admin'))
->helperText(trans('profile.redirect_to_admin_help'))
->inline()
->boolean(),
]),
Section::make(trans('profile.console'))
->collapsible()
->icon(TablerIcon::Terminal2)
@@ -599,6 +610,7 @@ class EditProfile extends BaseEditProfile
'dashboard_layout' => $data['dashboard_layout'],
'top_navigation' => $data['top_navigation'],
'button_style' => $data['button_style'],
'redirect_to_admin' => $data['redirect_to_admin'] ?? $this->getUser()->getCustomization(CustomizationKey::RedirectToAdmin),
];
unset(
@@ -608,6 +620,7 @@ class EditProfile extends BaseEditProfile
$data['dashboard_layout'],
$data['top_navigation'],
$data['button_style'],
$data['redirect_to_admin'],
);
$data['customization'] = json_encode($customization);
@@ -623,6 +636,7 @@ class EditProfile extends BaseEditProfile
$data['console_graph_period'] = (int) $this->getUser()->getCustomization(CustomizationKey::ConsoleGraphPeriod);
$data['dashboard_layout'] = $this->getUser()->getCustomization(CustomizationKey::DashboardLayout);
$data['button_style'] = $this->getUser()->getCustomization(CustomizationKey::ButtonStyle);
$data['redirect_to_admin'] = $this->getUser()->getCustomization(CustomizationKey::RedirectToAdmin);
// Handle migration from boolean to string navigation types
$topNavigation = $this->getUser()->getCustomization(CustomizationKey::TopNavigation);

View File

@@ -4,6 +4,8 @@ namespace App\Filament\Pages\Auth;
use App\Extensions\Captcha\CaptchaService;
use App\Extensions\OAuth\OAuthService;
use BladeUI\Icons\Exceptions\SvgNotFound;
use BladeUI\Icons\Factory as IconFactory;
use Filament\Actions\Action;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Forms\Components\TextInput;
@@ -11,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
@@ -19,14 +22,23 @@ class Login extends BaseLogin
protected CaptchaService $captchaService;
public function boot(OAuthService $oauthService, CaptchaService $captchaService): void
protected IconFactory $iconFactory;
public function boot(OAuthService $oauthService, CaptchaService $captchaService, IconFactory $iconFactory): void
{
$this->oauthService = $oauthService;
$this->captchaService = $captchaService;
$this->iconFactory = $iconFactory;
}
public function form(Schema $schema): Schema
{
if (config('auth.disable_password_login', false)) {
return $schema->components([
$this->getOAuthFormComponent(),
]);
}
$components = [
$this->getLoginFormComponent(),
$this->getPasswordFormComponent(),
@@ -87,18 +99,38 @@ class Login extends BaseLogin
$color = $schema->getHexColor();
$color = is_string($color) ? Color::hex($color) : null;
$icon = $schema->getIcon();
if (is_string($icon)) {
try {
$this->iconFactory->svg($icon);
} catch (SvgNotFound) {
$icon = null;
}
}
$actions[] = Action::make("oauth_$id")
->label($schema->getName())
->icon($schema->getIcon())
->icon($icon)
->color($color)
->url(route('auth.oauth.redirect', ['driver' => $id], false));
}
return Actions::make($actions);
return Actions::make($actions)->alignment(fn () => config('auth.disable_password_login', false) ? Alignment::Center : null);
}
protected function getFormActions(): array
{
return config('auth.disable_password_login', false) ? [] : parent::getFormActions();
}
protected function getCredentialsFromFormData(array $data): array
{
if (config('auth.disable_password_login', false)) {
throw ValidationException::withMessages([
'data.login' => trans('auth.password_login_disabled'),
]);
}
$loginType = filter_var($data['login'], FILTER_VALIDATE_EMAIL) ? 'email' : 'username';
return [

View File

@@ -0,0 +1,114 @@
<?php
namespace App\Filament\Server\Pages;
use App\Enums\SubuserPermission;
use App\Enums\TablerIcon;
use App\Facades\Activity;
use App\Models\Mount;
use App\Models\Server;
use BackedEnum;
use Exception;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Notifications\Notification;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\HtmlString;
class Mounts extends ServerFormPage
{
protected static string|BackedEnum|null $navigationIcon = TablerIcon::LayersLinked;
protected static ?int $navigationSort = 9;
public static function canAccess(): bool
{
return parent::canAccess() && user()?->can(SubuserPermission::MountRead, Filament::getTenant());
}
protected function authorizeAccess(): void
{
abort_unless(user()?->can(SubuserPermission::MountRead, Filament::getTenant()), 403);
}
protected function fillForm(): void
{
$this->form->fill([
'mounts' => $this->getRecord()->mounts->pluck('id')->toArray(),
]);
}
public function form(Schema $schema): Schema
{
$server = $this->getRecord();
$allowedMounts = Mount::query()
->where('user_mountable', true)
->where(function ($query) use ($server) {
$query->whereDoesntHave('nodes')
->orWhereHas('nodes', fn ($q) => $q->where('nodes.id', $server->node_id));
})
->where(function ($query) use ($server) {
$query->whereDoesntHave('eggs')
->orWhereHas('eggs', fn ($q) => $q->where('eggs.id', $server->egg_id));
})
->get();
return parent::form($schema)
->components([
Section::make([
CheckboxList::make('mounts')
->label(trans('server/mount.description'))
->relationship('mounts')
->options(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => $mount->name]))
->descriptions(fn () => $allowedMounts->mapWithKeys(fn (Mount $mount) => [$mount->id => new HtmlString(str("$mount->source -> $mount->target")->stripTags() . ($mount->description ? '<br>' . str($mount->description)->stripTags() : ''))]))
->helperText(fn () => $allowedMounts->isEmpty() ? trans('server/mount.no_mounts') : null)
->disabled(fn (Server $server) => !user()?->can(SubuserPermission::MountUpdate, $server))
->bulkToggleable()
->live()
->afterStateUpdated(function ($state) {
$this->save();
})
->columnSpanFull(),
]),
]);
}
public function save(): void
{
abort_unless(user()?->can(SubuserPermission::MountUpdate, $this->getRecord()), 403);
try {
$this->form->getState();
$this->form->saveRelationships();
Activity::event('server:mount.update')
->log();
Notification::make()
->title(trans('server/mount.notification_updated'))
->body(trans('server/mount.notification_updated_body'))
->success()
->send();
} catch (Exception $exception) {
report($exception);
Notification::make()
->title(trans('server/mount.notification_failed'))
->body($exception->getMessage())
->danger()
->send();
}
}
public function getTitle(): string
{
return trans('server/mount.title');
}
public static function getNavigationLabel(): string
{
return trans('server/mount.title');
}
}

View File

@@ -5,14 +5,13 @@ namespace App\Filament\Server\Pages;
use App\Enums\SubuserPermission;
use App\Enums\TablerIcon;
use App\Facades\Activity;
use App\Filament\Components\Actions\DeleteServerIcon;
use App\Filament\Components\Actions\DeleteIcon;
use App\Filament\Components\Actions\UploadIcon;
use App\Models\Server;
use App\Services\Servers\ReinstallServerService;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
@@ -21,20 +20,14 @@ use Filament\Schemas\Components\Fieldset;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Image;
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;
use Filament\Support\Enums\Alignment;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class Settings extends ServerFormPage
{
protected static string|BackedEnum|null $navigationIcon = TablerIcon::Settings;
protected static ?int $navigationSort = 10;
protected static ?int $navigationSort = 11;
/**
* @throws Exception
@@ -79,140 +72,20 @@ class Settings extends ServerFormPage
->afterStateUpdated(fn ($state, Server $server) => $this->updateDescription($state ?? '', $server)),
]),
Grid::make()
->columns(2)
->columnStart(6)
->schema([
Image::make('', 'icon')
->hidden(fn ($record) => !$record->icon && !$record->egg->image)
->url(fn ($record) => $record->icon ?: $record->egg->image)
->hidden(fn ($record) => !$record->icon && !$record->egg->icon)
->url(fn ($record) => $record->icon ?: $record->egg->icon)
->tooltip(fn ($record) => $record->icon ? '' : trans('server/setting.server_info.icon.tooltip'))
->columnSpan(2)
->imageSize(150)
->columnSpanFull()
->alignJustify(),
Action::make('uploadIcon')
->hiddenLabel()
->tooltip(trans('admin/server.import_image'))
->icon(TablerIcon::PhotoUp)
->modal()
->modalSubmitActionLabel(trans('server/setting.server_info.icon.upload'))
->schema([
Tabs::make()
->contained(false)
->tabs([
Tab::make(trans('admin/egg.import.url'))
->schema([
Hidden::make('imageUrl'),
Hidden::make('imageExtension'),
TextInput::make('image_url')
->label(trans('admin/egg.import.image_url'))
->reactive()
->autocomplete(false)
->debounce(500)
->afterStateUpdated(function ($state, Set $set) {
if (!$state) {
$set('image_url_error', null);
$set('imageUrl', null);
$set('imageExtension', null);
return;
}
try {
if (!in_array(parse_url($state, PHP_URL_SCHEME), ['http', 'https'], true)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
if (!filter_var($state, FILTER_VALIDATE_URL)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
$extension = strtolower(pathinfo(parse_url($state, PHP_URL_PATH), PATHINFO_EXTENSION));
if (!array_key_exists($extension, Server::IMAGE_FORMATS)) {
throw new \Exception(trans('admin/egg.import.unsupported_format', ['format' => implode(', ', array_keys(Server::IMAGE_FORMATS))]));
}
$host = parse_url($state, PHP_URL_HOST);
$ip = gethostbyname($host);
if (
filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false
) {
throw new \Exception(trans('admin/egg.import.no_local_ip'));
}
$set('imageUrl', $state);
$set('imageExtension', $extension);
$set('image_url_error', null);
} catch (\Exception $e) {
$set('image_url_error', $e->getMessage());
$set('imageUrl', null);
$set('imageExtension', null);
}
}),
TextEntry::make('image_url_error')
->hiddenLabel()
->visible(fn (Get $get) => $get('image_url_error') !== null)
->afterStateHydrated(fn (Get $get) => $get('image_url_error')),
Image::make(fn (Get $get) => $get('image_url'), '')
->imageSize(150)
->visible(fn (Get $get) => $get('image_url') && !$get('image_url_error'))
->alignCenter(),
]),
Tab::make(trans('admin/egg.import.file'))
->schema([
FileUpload::make('image')
->hiddenLabel()
->previewable()
->openable(false)
->downloadable(false)
->maxSize(256)
->maxFiles(1)
->columnSpanFull()
->alignCenter()
->imageEditor()
->image()
->disk('public')
->directory(Server::ICON_STORAGE_PATH)
->acceptedFileTypes([
'image/png',
'image/jpeg',
'image/webp',
'image/svg+xml',
])
->getUploadedFileNameForStorageUsing(function (TemporaryUploadedFile $file, $record) {
return $record->uuid . '.' . $file->getClientOriginalExtension();
}),
]),
]),
])
->action(function (array $data, $record): void {
if (!empty($data['imageUrl']) && !empty($data['imageExtension'])) {
$this->saveIconFromUrl($data['imageUrl'], $data['imageExtension'], $record);
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
return;
}
if (!empty($data['image'])) {
Notification::make()
->title(trans('server/setting.server_info.icon.updated'))
->success()
->send();
}
if (empty($data['imageUrl']) && empty($data['image'])) {
Notification::make()
->title(trans('admin/egg.import.no_image'))
->warning()
->send();
}
}),
DeleteServerIcon::make(),
UploadIcon::make()
->authorize(fn (Server $server) => user()?->can(SubuserPermission::SettingsChangeIcon, $server)),
DeleteIcon::make()
->iconStoragePath(Server::getIconStoragePath())
->authorize(fn (Server $server) => user()?->can(SubuserPermission::SettingsChangeIcon, $server)),
]),
TextInput::make('uuid')
->label(trans('server/setting.server_info.uuid'))
@@ -446,37 +319,6 @@ class Settings extends ServerFormPage
}
}
/**
* Save an icon from URL download to a file.
*
* @throws Exception
*/
private function saveIconFromUrl(string $imageUrl, string $extension, Server $server): void
{
$context = stream_context_create([
'http' => ['timeout' => 3],
'https' => [
'timeout' => 3,
'verify_peer' => true,
'verify_peer_name' => true,
],
]);
$data = @file_get_contents($imageUrl, false, $context, 0, 262144); //256KB
if (empty($data)) {
throw new \Exception(trans('admin/egg.import.invalid_url'));
}
$normalizedExtension = match ($extension) {
'svg+xml' => 'svg',
'jpeg' => 'jpg',
default => $extension,
};
Storage::disk('public')->put(Server::ICON_STORAGE_PATH . "/$server->uuid.$normalizedExtension", $data);
}
public function getTitle(): string
{
return trans('server/setting.title');

View File

@@ -28,7 +28,7 @@ class Startup extends ServerFormPage
{
protected static string|BackedEnum|null $navigationIcon = TablerIcon::PlayerPlay;
protected static ?int $navigationSort = 9;
protected static ?int $navigationSort = 10;
/**
* @throws Exception
@@ -149,12 +149,16 @@ class Startup extends ServerFormPage
return parent::canAccess() && user()?->can(SubuserPermission::StartupRead, Filament::getTenant());
}
public function update(?string $state, ServerVariable $serverVariable): void
public function update(null|string|bool $state, ServerVariable $serverVariable): void
{
if (!$serverVariable->variable->user_editable) {
return;
}
if (is_bool($state)) {
$state = $state ? '1' : '0';
}
$original = $serverVariable->variable_value;
try {

View File

@@ -139,6 +139,7 @@ class ActivityResource extends Resource
]);
}
/** @return Builder<ActivityLog> */
public static function getEloquentQuery(): Builder
{
/** @var Server $server */

View File

@@ -27,7 +27,6 @@ use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\ActionGroup;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Textarea;
@@ -77,7 +76,7 @@ class BackupResource extends Resource
/** @var Server $server */
$server = Filament::getTenant();
return $server->backup_limit;
return $server->backup_limit ?? 0;
}
public static function defaultForm(Schema $schema): Schema
@@ -128,7 +127,7 @@ class BackupResource extends Resource
])
->recordActions([
ActionGroup::make([
Action::make('rename')
Action::make('exclude_rename')
->icon(TablerIcon::Pencil)
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
->label(trans('server/backup.actions.rename.title'))
@@ -158,14 +157,14 @@ class BackupResource extends Resource
->send();
})
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('lock')
Action::make('exclude_lock')
->iconSize(IconSize::Large)
->icon(fn (Backup $backup) => !$backup->is_locked ? TablerIcon::Lock : TablerIcon::LockOpen)
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
->label(fn (Backup $backup) => !$backup->is_locked ? trans('server/backup.actions.lock.lock') : trans('server/backup.actions.lock.unlock'))
->action(fn (BackupController $backupController, Backup $backup, Request $request) => $backupController->toggleLock($request, $server, $backup))
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('download')
Action::make('exclude_download')
->label(trans('server/backup.actions.download'))
->iconSize(IconSize::Large)
->color('primary')
@@ -173,7 +172,7 @@ class BackupResource extends Resource
->authorize(fn () => user()?->can(SubuserPermission::BackupDownload, $server))
->url(fn (DownloadLinkService $downloadLinkService, Backup $backup, Request $request) => $downloadLinkService->handle($backup, $request->user()), true)
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
Action::make('restore')
Action::make('exclude_restore')
->label(trans('server/backup.actions.restore.title'))
->iconSize(IconSize::Large)
->color('success')
@@ -226,7 +225,12 @@ class BackupResource extends Resource
->send();
})
->visible(fn (Backup $backup) => $backup->status === BackupStatus::Successful),
DeleteAction::make('delete')
Action::make('exclude_delete')
->icon(TablerIcon::Trash)
->color('danger')
->requiresConfirmation()
->authorize(fn () => user()?->can(SubuserPermission::BackupDelete, $server))
->label(trans('filament-actions::delete.single.label'))
->iconSize(IconSize::Large)
->disabled(fn (Backup $backup) => $backup->is_locked && $backup->status !== BackupStatus::Failed)
->modalDescription(fn (Backup $backup) => trans('server/backup.actions.delete.description', ['backup' => $backup->name]))

View File

@@ -62,7 +62,7 @@ class DatabaseResource extends Resource
/** @var Server $server */
$server = Filament::getTenant();
return $server->database_limit;
return $server->database_limit ?? 0;
}
/**

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Server\Resources\Files\Pages;
use App\Enums\JwtScope;
use App\Enums\SubuserPermission;
use App\Facades\Activity;
use App\Filament\Server\Resources\Files\FileResource;
@@ -44,6 +45,7 @@ class DownloadFiles extends Page
'file_path' => rawurldecode($path),
'server_uuid' => $server->uuid,
])
->setScopes(JwtScope::FileDownload)
->handle($server->node, user()?->id . $server->uuid);
Activity::event('server:file.download')

View File

@@ -149,7 +149,7 @@ class EditFiles extends Page
try {
$contents = $this->getDaemonFileRepository()->getContent($this->path, config('panel.files.max_edit_size'));
return mb_convert_encoding($contents, 'UTF-8', ['UTF-8', 'UTF-16', 'ISO-8859-1', 'ASCII']);
return convert_to_utf8($contents);
} catch (FileSizeTooLargeException) {
AlertBanner::make('file_too_large')
->title(trans('server/file.alerts.file_too_large.title', ['name' => basename($this->path)]))
@@ -259,9 +259,9 @@ class EditFiles extends Page
return $this->fileRepository;
}
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false): string
public static function getUrl(array $parameters = [], bool $isAbsolute = true, ?string $panel = null, ?Model $tenant = null, bool $shouldGuessMissingParameters = false, ?string $configuration = null): string
{
return parent::getUrl($parameters, $isAbsolute, $panel, $tenant) . '/';
return parent::getUrl($parameters, $isAbsolute, $panel, $tenant, $shouldGuessMissingParameters, $configuration) . '/';
}
public static function route(string $path): PageRegistration

View File

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

View File

@@ -8,6 +8,7 @@ use App\Models\Schedule;
use App\Models\Server;
use App\Traits\Filament\CanCustomizeHeaderActions;
use App\Traits\Filament\CanCustomizeHeaderWidgets;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Resources\Pages\CreateRecord;
@@ -20,6 +21,17 @@ class CreateSchedule extends CreateRecord
protected static bool $canCreateAnother = false;
protected function getCreateFormAction(): Action
{
$hasFormWrapper = $this->hasFormWrapper();
return Action::make('exclude_create')
->label(__('filament-panels::resources/pages/create-record.form.actions.create.label'))
->submit($hasFormWrapper ? $this->getSubmitFormLivewireMethodName() : null)
->action($hasFormWrapper ? null : $this->getSubmitFormLivewireMethodName())
->keyBindings(['mod+s']);
}
protected function afterCreate(): void
{
/** @var Schedule $schedule */

View File

@@ -7,6 +7,7 @@ use App\Facades\Activity;
use App\Models\Schedule;
use App\Models\Task;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
@@ -73,6 +74,7 @@ class TasksRelationManager extends RelationManager
return $table
->reorderable('sequence_id')
->defaultSort('sequence_id')
->reorderRecordsTriggerAction(fn (Action $action, bool $isReordering) => $action->hiddenLabel()->tooltip(fn () => $action->getLabel()))
->columns([
TextColumn::make('action')
->label(trans('server/schedule.tasks.actions.title'))

View File

@@ -97,8 +97,7 @@ class ScheduleResource extends Resource
->formatStateUsing(fn (?Schedule $schedule) => $schedule?->status->value ?? 'new')
->options(fn (?Schedule $schedule) => [$schedule?->status->value ?? 'new' => $schedule?->status->getLabel() ?? 'New'])
->visibleOn('view'),
Section::make('Cron')
->label(trans('server/schedule.cron'))
Section::make(trans('server/schedule.cron'))
->description(function (Get $get) {
try {
$nextRun = Utilities::getScheduleNextRunDate($get('cron_minute'), $get('cron_hour'), $get('cron_day_of_month'), $get('cron_month'), $get('cron_day_of_week'))->timezone(user()->timezone ?? 'UTC');
@@ -110,22 +109,22 @@ class ScheduleResource extends Resource
})
->schema([
Actions::make([
CronPresetAction::make('hourly')
CronPresetAction::make('exclude_hourly')
->label(trans('server/schedule.time.hourly'))
->cron('0', '*', '*', '*', '*'),
CronPresetAction::make('daily')
CronPresetAction::make('exclude_daily')
->label(trans('server/schedule.time.daily'))
->cron('0', '0', '*', '*', '*'),
CronPresetAction::make('weekly_monday')
CronPresetAction::make('exclude_weekly_monday')
->label(trans('server/schedule.time.weekly_mon'))
->cron('0', '0', '*', '*', '1'),
CronPresetAction::make('weekly_sunday')
CronPresetAction::make('exclude_weekly_sunday')
->label(trans('server/schedule.time.weekly_sun'))
->cron('0', '0', '*', '*', '0'),
CronPresetAction::make('monthly')
CronPresetAction::make('exclude_monthly')
->label(trans('server/schedule.time.monthly'))
->cron('0', '0', '1', '*', '*'),
CronPresetAction::make('every_x_minutes')
CronPresetAction::make('exclude_every_x_minutes')
->label(trans('server/schedule.time.every_min'))
->color(fn (Get $get) => str($get('cron_minute'))->startsWith('*/')
&& $get('cron_hour') == '*'
@@ -148,7 +147,7 @@ class ScheduleResource extends Resource
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
CronPresetAction::make('every_x_hours')
CronPresetAction::make('exclude_every_x_hours')
->color(fn (Get $get) => $get('cron_minute') == '0'
&& str($get('cron_hour'))->startsWith('*/')
&& $get('cron_day_of_month') == '*'
@@ -170,7 +169,7 @@ class ScheduleResource extends Resource
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
CronPresetAction::make('every_x_days')
CronPresetAction::make('exclude_every_x_days')
->color(fn (Get $get) => $get('cron_minute') == '0'
&& $get('cron_hour') == '0'
&& str($get('cron_day_of_month'))->startsWith('*/')
@@ -192,7 +191,7 @@ class ScheduleResource extends Resource
$set('cron_month', '*');
$set('cron_day_of_week', '*');
}),
CronPresetAction::make('every_x_months')
CronPresetAction::make('exclude_every_x_months')
->color(fn (Get $get) => $get('cron_minute') == '0'
&& $get('cron_hour') == '0'
&& $get('cron_day_of_month') == '1'
@@ -214,7 +213,7 @@ class ScheduleResource extends Resource
$set('cron_month', '*/' . $data['x']);
$set('cron_day_of_week', '*');
}),
CronPresetAction::make('every_x_day_of_week')
CronPresetAction::make('exclude_every_x_day_of_week')
->color(fn (Get $get) => $get('cron_minute') == '0'
&& $get('cron_hour') == '0'
&& $get('cron_day_of_month') == '*'

View File

@@ -79,15 +79,15 @@ class SubuserResource extends Resource
foreach ($data['permissions'] as $permission) {
$options[$permission] = str($permission)->headline();
$descriptions[$permission] = trans('server/user.permissions.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$descriptions[$permission] = trans($data['translation_prefix']. '.' . $data['name'] . '_' . str($permission)->replace('-', '_'));
$permissionsArray[$data['name']][] = $permission;
}
$tabs[] = Tab::make($data['name'])
->label(str($data['name'])->headline())
->label(trans($data['translation_prefix']. '.' . $data['name'] . '_title'))
->schema([
Section::make()
->description(trans('server/user.permissions.' . $data['name'] . '_desc'))
->description(trans($data['translation_prefix']. '.' . $data['name'] . '_desc'))
->icon($data['icon'])
->contained(false)
->schema([
@@ -123,18 +123,6 @@ class SubuserResource extends Resource
),
])
->recordActions([
DeleteAction::make()
->label(trans('server/user.delete'))
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
->successNotificationTitle(null)
->action(function (Subuser $subuser, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuserDeletionService->handle($subuser, $server);
Notification::make()
->title(trans('server/user.notification_delete'))
->success()
->send();
}),
EditAction::make()
->label(trans('server/user.edit'))
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
@@ -179,7 +167,7 @@ class SubuserResource extends Resource
])
->formatStateUsing(fn (Subuser $subuser) => $subuser->user->email),
Actions::make([
Action::make('assignAll')
Action::make('exclude_assignAll')
->label(trans('server/user.assign_all'))
->action(function (Set $set) use ($permissionsArray) {
$permissions = $permissionsArray;
@@ -214,6 +202,19 @@ class SubuserResource extends Resource
return $data;
}),
DeleteAction::make()
->label(trans('server/user.delete'))
->hidden(fn (Subuser $subuser) => user()?->id === $subuser->user->id)
->authorize(fn () => user()?->can(SubuserPermission::UserDelete, $server))
->successNotificationTitle(null)
->action(function (Subuser $subuser, SubuserDeletionService $subuserDeletionService) use ($server) {
$subuserDeletionService->handle($subuser, $server);
Notification::make()
->title(trans('server/user.notification_delete'))
->success()
->send();
}),
])
->toolbarActions([
CreateAction::make('invite')
@@ -243,7 +244,7 @@ class SubuserResource extends Resource
])
->required(),
Actions::make([
Action::make('assignAll')
Action::make('exclude_assignAll')
->label(trans('server/user.assign_all'))
->action(function (Set $set, Get $get) use ($permissionsArray) {
$permissions = $permissionsArray;

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Server\Widgets;
use App\Enums\JwtScope;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Livewire\AlertBanner;
@@ -59,6 +60,7 @@ class ServerConsole extends Widget
'server_uuid' => $this->server->uuid,
'permissions' => $permissions,
])
->setScopes(JwtScope::Websocket)
->handle($this->server->node, $this->user->id . $this->server->uuid)->toString();
}

View File

@@ -13,10 +13,17 @@ use Illuminate\Auth\SessionGuard;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Throwable;
class AccountController extends ClientApiController
{
/**
* The number of seconds that must elapse before the email change throttle resets.
*/
private const EMAIL_UPDATE_THROTTLE = 60 * 60 * 24;
/**
* AccountController constructor.
*/
@@ -63,10 +70,22 @@ class AccountController extends ClientApiController
*/
public function updateEmail(UpdateEmailRequest $request): JsonResponse
{
$original = $request->user()->email;
$this->updateService->handle($request->user(), $request->validated());
$user = $request->user();
// Only allow a user to change their email three times in the span
// of 24 hours. This prevents malicious users from trying to find
// existing accounts in the system by constantly changing their email.
if (RateLimiter::tooManyAttempts($key = "user:update-email:{$user->uuid}", 3)) {
throw new TooManyRequestsHttpException(message: 'Your email address has been changed too many times today. Please try again later.');
}
$original = $user->email;
if (mb_strtolower($original) !== mb_strtolower($request->validated('email'))) {
RateLimiter::hit($key, self::EMAIL_UPDATE_THROTTLE);
$this->updateService->handle($user, $request->validated());
if ($original !== $request->input('email')) {
Activity::event('user:account.email-changed')
->property(['old' => $original, 'new' => $request->input('email')])
->log();
@@ -85,7 +104,9 @@ class AccountController extends ClientApiController
*/
public function updatePassword(UpdatePasswordRequest $request): JsonResponse
{
$user = $this->updateService->handle($request->user(), $request->validated());
$user = Activity::event('user:account.password-changed')->transaction(function () use ($request) {
return $this->updateService->handle($request->user(), $request->validated());
});
$guard = $this->manager->guard();
// If you do not update the user in the session you'll end up working with a
@@ -98,8 +119,6 @@ class AccountController extends ClientApiController
$guard->logoutOtherDevices($request->input('password'));
}
Activity::event('user:account.password-changed')->log();
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
}

View File

@@ -87,7 +87,7 @@ class BackupController extends ClientApiController
}
$backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) {
$server->backups()->lockForUpdate();
$server->backups()->lockForUpdate()->count();
$backup = $action->handle($server, $request->input('name'));

View File

@@ -60,7 +60,7 @@ class DatabaseController extends ClientApiController
public function store(StoreDatabaseRequest $request, Server $server): array
{
$database = Activity::event('server:database.create')->transaction(function ($log) use ($request, $server) {
$server->databases()->lockForUpdate();
$server->databases()->lockForUpdate()->count();
$database = $this->deployDatabaseService->handle($server, $request->validated());
@@ -87,15 +87,12 @@ class DatabaseController extends ClientApiController
*/
public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array
{
$this->managementService->rotatePassword($database);
$database->refresh();
Activity::event('server:database.rotate-password')
->subject($database)
->property('name', $database->database)
->log();
->transaction(fn () => $this->managementService->rotatePassword($database));
return $this->fractal->item($database)
return $this->fractal->item($database->refresh())
->parseIncludes(['password'])
->transformWith($this->getTransformer(DatabaseTransformer::class))
->toArray();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\JwtScope;
use App\Facades\Activity;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Http\Requests\Api\Client\Servers\Files\ChmodFilesRequest;
@@ -77,7 +78,7 @@ class FileController extends ClientApiController
->property('file', $request->get('file'))
->log();
return new Response($response, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
return new Response(convert_to_utf8($response), Response::HTTP_OK, ['Content-Type' => 'text/plain; charset=utf-8']);
}
/**
@@ -98,6 +99,7 @@ class FileController extends ClientApiController
'file_path' => rawurldecode($request->get('file')),
'server_uuid' => $server->uuid,
])
->setScopes(JwtScope::FileDownload)
->handle($server->node, $request->user()->id . $server->uuid);
Activity::event('server:file.download')->property('file', $request->get('file'))->log();

View File

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

View File

@@ -87,13 +87,13 @@ class StartupController extends ClientApiController
$startup = $this->startupCommandService->handle($server);
if ($variable->env_variable !== $request->input('value')) {
if ($original !== $request->input('value')) {
Activity::event('server:startup.edit')
->subject($variable)
->property([
'variable' => $variable->env_variable,
'old' => $original,
'new' => $request->input('value'),
'new' => $request->input('value') ?? '',
])
->log();
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Client\Servers;
use App\Enums\JwtScope;
use App\Enums\SubuserPermission;
use App\Exceptions\Http\HttpForbiddenException;
use App\Http\Controllers\Api\Client\ClientApiController;
@@ -63,6 +64,7 @@ class WebsocketController extends ClientApiController
'server_uuid' => $server->uuid,
'permissions' => $permissions,
])
->setScopes(JwtScope::Websocket)
->handle($node, $user->id . $server->uuid);
$socket = str_replace(['https://', 'http://'], ['wss://', 'ws://'], $node->getConnectionAddress());

View File

@@ -56,7 +56,7 @@ class BackupRemoteUploadController extends Controller
/** @var Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
// Prevent backups that have already been completed from trying to

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Remote\Backups;
use App\Events\Server\BackupCompleted;
use App\Exceptions\DisplayException;
use App\Exceptions\Http\HttpForbiddenException;
use App\Extensions\Backups\BackupManager;
@@ -47,7 +48,7 @@ class BackupStatusController extends Controller
/** @var Server $server */
$server = $model->server;
if ($server->node_id !== $node->id) {
throw new HttpForbiddenException('You do not have permission to access that backup.');
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
if ($model->is_successful) {
@@ -79,6 +80,8 @@ class BackupStatusController extends Controller
}
});
event(new BackupCompleted($model));
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
@@ -97,6 +100,11 @@ class BackupStatusController extends Controller
/** @var Backup $model */
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$node = $request->attributes->get('node');
if (!$model->server->node->is($node)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$model->server->update(['status' => null]);
Activity::event($request->boolean('successful') ? 'server:backup.restore-complete' : 'server.backup.restore-failed')

View File

@@ -3,18 +3,23 @@
namespace App\Http\Controllers\Api\Remote\Servers;
use App\Enums\ContainerStatus;
use App\Exceptions\Http\HttpForbiddenException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServerContainersController extends Controller
{
/**
* Updates the server container's status on the Panel
*/
public function status(ServerRequest $request, Server $server): JsonResponse
public function status(Request $request, Server $server): JsonResponse
{
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$status = ContainerStatus::tryFrom($request->json('data.new_state')) ?? ContainerStatus::Missing;
cache()->put("servers.$server->uuid.status", $status, now()->addHour());

View File

@@ -3,9 +3,9 @@
namespace App\Http\Controllers\Api\Remote\Servers;
use App\Enums\ServerState;
use App\Exceptions\Http\HttpForbiddenException;
use App\Facades\Activity;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Http\Resources\Daemon\ServerConfigurationCollection;
use App\Models\ActivityLog;
use App\Models\Backup;
@@ -17,6 +17,7 @@ use Illuminate\Database\ConnectionInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Throwable;
use Webmozart\Assert\Assert;
class ServerDetailsController extends Controller
{
@@ -33,8 +34,21 @@ class ServerDetailsController extends Controller
* Returns details about the server that allows daemon to self-recover and ensure
* that the state of the server matches the Panel at all times.
*/
public function __invoke(ServerRequest $request, Server $server): JsonResponse
public function __invoke(Request $request, Server $server): JsonResponse
{
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
$transfer = $server->transfer;
// If the server is being transferred allow either node to request information about
// the server. If the server is not being transferred only the target node is allowed
// to fetch these details.
$valid = $transfer ? $node->id === $transfer->old_node || $node->id === $transfer->new_node : $node->id === $server->node_id;
if (!$valid) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
return new JsonResponse([
'settings' => $this->configurationStructureService->handle($server),
'process_configuration' => $this->eggConfigurationService->handle($server),

View File

@@ -4,12 +4,13 @@ namespace App\Http\Controllers\Api\Remote\Servers;
use App\Enums\ServerState;
use App\Events\Server\Installed as ServerInstalled;
use App\Exceptions\Http\HttpForbiddenException;
use App\Exceptions\Model\DataValidationException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\InstallationDataRequest;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Models\Server;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class ServerInstallController extends Controller
@@ -17,12 +18,18 @@ class ServerInstallController extends Controller
/**
* Returns installation information for a server.
*/
public function index(ServerRequest $request, Server $server): JsonResponse
public function index(Request $request, Server $server): JsonResponse
{
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$egg = $server->egg;
return new JsonResponse([
'container_image' => $server->egg->copy_script_container,
'entrypoint' => $server->egg->copy_script_entry,
'script' => $server->egg->copy_script_install,
'container_image' => $egg->copy_script_container,
'entrypoint' => $egg->copy_script_entry,
'script' => $egg->copy_script_install,
]);
}
@@ -35,6 +42,10 @@ class ServerInstallController extends Controller
{
$status = null;
if (!$server->node->is($request->attributes->get('node'))) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$successful = $request->boolean('successful');
// Make sure the type of failure is accurate

View File

@@ -2,17 +2,20 @@
namespace App\Http\Controllers\Api\Remote\Servers;
use App\Exceptions\Http\HttpForbiddenException;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Remote\ServerRequest;
use App\Models\Allocation;
use App\Models\Node;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Throwable;
use Webmozart\Assert\Assert;
class ServerTransferController extends Controller
{
@@ -29,13 +32,22 @@ class ServerTransferController extends Controller
*
* @throws Throwable
*/
public function failure(ServerRequest $request, Server $server): JsonResponse
public function failure(Request $request, Server $server): JsonResponse
{
$transfer = $server->transfer;
if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.');
}
/* @var Node $node */
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
// Either node can tell the panel that the transfer has failed. Only the new node
// can tell the panel that it was successful.
if (!$node->is($transfer->newNode) && !$node->is($transfer->oldNode)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
$this->connection->transaction(function () use ($transfer) {
$transfer->forceFill(['successful' => false])->saveOrFail();
@@ -53,13 +65,22 @@ class ServerTransferController extends Controller
*
* @throws Throwable
*/
public function success(ServerRequest $request, Server $server): JsonResponse
public function success(Request $request, Server $server): JsonResponse
{
$transfer = $server->transfer;
if (is_null($transfer)) {
throw new ConflictHttpException('Server is not being transferred.');
}
/* @var Node $node */
Assert::isInstanceOf($node = $request->attributes->get('node'), Node::class);
// Only the new node communicates a successful state to the panel, so we should
// not allow the old node to hit this endpoint.
if (!$node->is($transfer->newNode)) {
throw new HttpForbiddenException('Requesting node does not have permission to access this server.');
}
/** @var Server $server */
$server = $this->connection->transaction(function () use ($server, $transfer) {
$data = [];

View File

@@ -2,9 +2,9 @@
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as BaseVerifier;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery as BaseMiddleware;
class VerifyCsrfToken extends BaseVerifier
class PreventRequestForgery extends BaseMiddleware
{
/**
* The URIs that should be excluded from CSRF verification. These are

View File

@@ -23,7 +23,7 @@ class RequireTwoFactorAuthentication
* order to perform actions. If so, we check the level at which it is required (all users
* or just admins) and then check if the user has enabled it for their account.
*
* @throws \App\Exceptions\Http\TwoFactorAuthRequiredException
* @throws TwoFactorAuthRequiredException
*/
public function handle(Request $request, \Closure $next): mixed
{

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class SetSecurityHeaders
{
/**
* Ideally we move away from X-Frame-Options/X-XSS-Protection and implement a
* proper standard CSP, but I can guarantee that will break for a lot of folks
* using custom plugins and who knows what image embeds.
*
* We'll circle back to that at a later date when it can be more fully controlled
* by the admin to support those cases without too much trouble.
*
* @var array<string, string>
*/
protected static array $headers = [
'X-Frame-Options' => 'DENY',
'X-Content-Type-Options' => 'nosniff',
'X-XSS-Protection' => '1; mode=block',
'Referrer-Policy' => 'no-referrer-when-downgrade',
];
/**
* Enforces some basic security headers on all responses returned by the software.
* If a header has already been set in another location within the code it will be
* skipped over here.
*
* @param (\Closure(mixed): Response) $next
*/
public function handle(Request $request, \Closure $next): mixed
{
$response = $next($request);
foreach (static::$headers as $key => $value) {
if (!$response->headers->has($key)) {
$response->headers->set($key, $value);
}
}
return $response;
}
}

View File

@@ -87,7 +87,7 @@ abstract class ApplicationApiRequest extends FormRequest
$value = $this->route()->parameter($key);
Assert::isInstanceOf($value, $expect);
Assert::isInstanceOf($value, Model::class);
Assert::isInstanceOf($value, Model::class); // @phpstan-ignore staticMethod.alreadyNarrowedType
Assert::true($value->exists);
/* @var T $value */

View File

@@ -2,8 +2,15 @@
namespace App\Http\Requests\Api\Remote;
class InstallationDataRequest extends ServerRequest
use Illuminate\Foundation\Http\FormRequest;
class InstallationDataRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string|string[]>
*/

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Http\Requests\Api\Remote;
use App\Models\Node;
use App\Models\Server;
use Illuminate\Foundation\Http\FormRequest;
class ServerRequest extends FormRequest
{
public function authorize(): bool
{
/** @var Node $node */
$node = $this->attributes->get('node');
/** @var ?Server $server */
$server = $this->route()->parameter('server');
if ($server) {
if ($server->transfer) {
return $server->transfer->old_node === $node->id || $server->transfer->new_node === $node->id;
}
return $server->node_id === $node->id;
}
return false;
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Responses;
use App\Enums\CustomizationKey;
use App\Models\User;
use Filament\Auth\Http\Responses\Contracts\LoginResponse as LoginResponseContract;
use Filament\Facades\Filament;
use Illuminate\Http\RedirectResponse;
use Livewire\Features\SupportRedirects\Redirector;
class LoginResponse implements LoginResponseContract
{
public function toResponse($request): RedirectResponse|Redirector
{
/** @var User|null $user */
$user = Filament::auth()->user();
if ($user?->getCustomization(CustomizationKey::RedirectToAdmin) && $user->canAccessPanel(Filament::getPanel('admin'))) {
return redirect()->intended(Filament::getPanel('admin')->getUrl());
}
return redirect()->intended(Filament::getUrl());
}
}

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
abstract class Job
{
/*
|--------------------------------------------------------------------------
| Queueable Jobs
|--------------------------------------------------------------------------
|
| This job base class provides a central location to place any logic that
| is shared across all of your jobs. The trait included with the class
| provides access to the "onQueue" and "delay" queue helper methods.
|
*/
use Queueable;
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Jobs\Plugin;
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
use App\Models\Plugin;
use App\Models\User;
use App\Services\Helpers\PluginService;
use Exception;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class InstallPlugin implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public Plugin $plugin) {}
public function handle(PluginService $pluginService): void
{
try {
$pluginService->installPlugin($this->plugin, !$this->plugin->isTheme() || !$pluginService->hasThemePluginEnabled());
Notification::make()
->success()
->title(trans('admin/plugin.notifications.installed'))
->body($this->plugin->name)
->actions([
Action::make('goto_plugins')
->label(trans('admin/plugin.notifications.goto_plugins'))
->url(ListPlugins::getUrl(panel: 'admin')),
])
->sendToDatabase($this->user);
} catch (Exception $exception) {
report($exception);
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.install_error'))
->body($exception->getMessage())
->sendToDatabase($this->user);
}
}
public function uniqueId(): string
{
return 'plugin:install:' . $this->plugin->id;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Jobs\Plugin;
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
use App\Models\Plugin;
use App\Models\User;
use App\Services\Helpers\PluginService;
use Exception;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class UninstallPlugin implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public Plugin $plugin) {}
public function handle(PluginService $pluginService): void
{
try {
$pluginService->uninstallPlugin($this->plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.uninstalled'))
->body($this->plugin->name)
->actions([
Action::make('goto_plugins')
->label(trans('admin/plugin.notifications.goto_plugins'))
->url(ListPlugins::getUrl(panel: 'admin')),
])
->sendToDatabase($this->user);
} catch (Exception $exception) {
report($exception);
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.uninstall_error'))
->body($exception->getMessage())
->sendToDatabase($this->user);
}
}
public function uniqueId(): string
{
return 'plugin:uninstall:' . $this->plugin->id;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Jobs\Plugin;
use App\Filament\Admin\Resources\Plugins\Pages\ListPlugins;
use App\Models\Plugin;
use App\Models\User;
use App\Services\Helpers\PluginService;
use Exception;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class UpdatePlugin implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public User $user, public Plugin $plugin) {}
public function handle(PluginService $pluginService): void
{
try {
$pluginService->updatePlugin($this->plugin);
Notification::make()
->success()
->title(trans('admin/plugin.notifications.updated'))
->body($this->plugin->name)
->actions([
Action::make('goto_plugins')
->label(trans('admin/plugin.notifications.goto_plugins'))
->url(ListPlugins::getUrl(panel: 'admin')),
])
->sendToDatabase($this->user);
} catch (Exception $exception) {
report($exception);
Notification::make()
->danger()
->title(trans('admin/plugin.notifications.update_error'))
->body($exception->getMessage())
->sendToDatabase($this->user);
}
}
public function uniqueId(): string
{
return 'plugin:update:' . $this->plugin->id;
}
}

View File

@@ -34,6 +34,10 @@ 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) ?? []);
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Jobs;
use App\Models\Node;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\Attributes\DeleteWhenMissingModels;
use Illuminate\Queue\Attributes\WithoutRelations;
/**
* Revokes all SFTP access for a user on a given node or for a specific server.
*/
#[DeleteWhenMissingModels]
class RevokeSftpAccessJob implements ShouldBeUnique, ShouldQueue
{
use Queueable;
public int $tries = 3;
public int $maxExceptions = 1;
public function __construct(
public readonly string $user,
#[WithoutRelations]
public readonly Server|Node $target,
) {}
public function uniqueId(): string
{
$target = $this->target instanceof Node ? "node:{$this->target->uuid}" : "server:{$this->target->uuid}";
return "revoke-sftp:{$this->user}:{$target}";
}
public function handle(DaemonServerRepository $repository): void
{
try {
if ($this->target instanceof Server) {
$repository->setServer($this->target)->deauthorize($this->user);
} else {
$repository->setNode($this->target)->deauthorize($this->user);
}
} catch (ConnectionException) {
// Keep retrying this job with a longer and longer backoff until we hit three
// attempts at which point we stop and will assume the node is fully offline
// and we are just wasting time.
$this->release($this->attempts() * 10);
}
}
}

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