Compare commits

..

104 Commits

Author SHA1 Message Date
Lance Pioch
aafe17174f Do not show installer by default 2024-09-27 18:44:56 -04:00
Lance Pioch
a067419d6e Merge pull request #313 from pelican-dev/issue/311
Docker
2024-09-27 18:01:00 -04:00
Michael (Parker) Parker
6117282909 update to use supervisord
Update the dockerfile to use supervisord
Update supervisord config to use start caddy unless configured not to.
Updated entrypoint to handle caddy skip for supervisord.
2024-09-27 17:36:45 -04:00
Lance Pioch
967d02612d Add cron and queue 2024-09-27 16:50:34 -04:00
Lance Pioch
0cd20eb444 We don’t do this yet 2024-09-27 16:50:18 -04:00
Lance Pioch
4dba73163b Switch this back 2024-09-27 16:50:10 -04:00
Boy132
aab3817244 Fix role permissions model name (#591) 2024-09-27 22:49:01 +02:00
Lance Pioch
1785883c55 Don’t need this anymore 2024-09-27 15:54:00 -04:00
Lance Pioch
4c19144640 Don’t need separate file 2024-09-27 15:53:44 -04:00
Lance Pioch
a8a2668754 Revert the composer lock 2024-09-27 15:43:04 -04:00
Boy132
6734fe3be6 Replace fonts.googleapis.com with fonts.bunny.net (#586) 2024-09-27 21:38:30 +02:00
Charles
ff0cde5152 Auto Login After Install (#585)
* Auto Login After Install

* pint
2024-09-27 15:34:26 -04:00
Lance Pioch
b098d20afb Make this work 2024-09-27 15:34:22 -04:00
Lance Pioch
3ca77765e6 Small installer updates 2024-09-27 15:33:51 -04:00
Lance Pioch
476eccca53 Add mysql 2024-09-27 15:33:18 -04:00
Lance Pioch
f686eda718 Allow absolute path in database file 2024-09-27 15:32:41 -04:00
Lance Pioch
0f58643cf2 Fix order of params 2024-09-27 15:32:31 -04:00
Lance Pioch
83ba05d7fb Update installer 2024-09-27 15:32:22 -04:00
Lance Pioch
66841f5fab Merge pull request #590 from pelican-dev/filament-update
Update Filament to 115
2024-09-27 14:29:20 -04:00
notCharles
c03ef43767 Fix server deletion with databases 2024-09-27 14:28:32 -04:00
notCharles
805461aaf0 Update Filament to 115 2024-09-27 14:04:20 -04:00
Michael Parker
6f15537d77 add ability to skip starting caddy
dockerfile
  cmd updated to just start php-fpm

entrypoint
  now starts caddy unless SKIP_CADDY has been set.

compose file
  updated ports to work properly.
  updated networks to use the correct network.
  added commented port and variable to disable caddy
  added further notes.
2024-09-26 18:29:46 -04:00
Lance Pioch
4fc8d98a0f Revert "wip"
This reverts commit 649e82d0c06f068f08b024a1f8fc4837b488cb3d.
2024-09-26 18:29:46 -04:00
Lance Pioch
9779365432 wip 2024-09-26 18:29:46 -04:00
Lance Pioch
6e998498e3 Update composer 2024-09-26 18:29:45 -04:00
Lance Pioch
7d0b9af21a Add logs volume 2024-09-26 18:29:05 -04:00
Lance Pioch
116175ba60 Store caddy config and certs in a volume 2024-09-26 18:29:05 -04:00
Lance Pioch
1e841ac40d Update variables 2024-09-26 18:29:05 -04:00
Lance Pioch
3401703ccd Use this one primarily 2024-09-26 18:29:05 -04:00
Lance Pioch
f7cb42e008 Remove old one 2024-09-26 18:29:05 -04:00
Lance Pioch
b6e55795c1 Docker 2024-09-26 18:29:05 -04:00
Lance Pioch
17c0041bfd Already have defaults 2024-09-26 18:29:05 -04:00
Lance Pioch
478948c81b Use variables 2024-09-26 18:28:11 -04:00
Lance Pioch
6b706de23d Don’t include this 2024-09-26 18:28:11 -04:00
Lance Pioch
508e1c9645 Add some docker 2024-09-26 18:28:11 -04:00
Boy132
3e7c29d264 Add artisan command for setting up redis (#580) 2024-09-26 21:53:34 +02:00
Boy132
fc643f57f9 Admin Roles (#502)
* add spatie/permissions

* add policies

* add role resource

* add root admin role handling

* replace some "root_admin" with function

* add model specific permissions

* make permission selection nicer

* fix user creation

* fix tests

* add back subuser checks in server policy

* add custom model for role

* assign new users to role if root_admin is set

* add api for roles

* fix phpstan

* add permissions for settings page

* remove "restore" and "forceDelete" permissions

* add user count to list

* prevent deletion if role has users

* update user list

* fix server policy

* remove old `root_admin` column

* small refactor

* fix tests

* forgot can checks here

* forgot use

* disable editing own roles & disable assigning root admin

* don't allow to rename root admin role

* remove php bombing exception handler

* fix role assignment when creating a user

* fix disableOptionWhen

* fix missing `root_admin` attribute on react frontend

* add permission check for bulk delete

* rename viewAny to viewList

* improve canAccessPanel check

* fix admin not displaying for non-root admins

* make sure non root admins can't edit root admins

* fix import

* fix settings page permission check

* fix server permissions for non-subusers

* fix settings page permission check v2

* small cleanup

* cleanup config file

* move consts from resouce into enum & model

* Update database/migrations/2024_08_01_114538_remove_root_admin_column.php

Co-authored-by: Lance Pioch <lancepioch@gmail.com>

* fix config

* fix phpstan

* fix phpstan 2.0

---------

Co-authored-by: Lance Pioch <lancepioch@gmail.com>
2024-09-21 12:27:41 +02:00
ash
68a0cbbf10 Update placeholders & panel error command (#576) 2024-09-16 10:16:25 -04:00
Charles
8497e8b009 Update egg-bungeecord.json (#571) 2024-09-07 12:45:25 -04:00
notCharles
8c64a4ad55 Make MySQL Happy
MySQL complains when we try to change the rules column to json before we change the data... If we change the data, then change the column its happy. :)
2024-09-07 09:47:39 -04:00
notCharles
49e93c1379 Fix Migration
Fix rule migration reversal.
2024-09-06 15:50:42 -04:00
Boy132
d7b5966e1b Remove required from smtp username (#565) 2024-09-01 17:42:15 +02:00
notCharles
e152efc5f9 Add toggle for starting server after install 2024-08-24 21:05:43 -04:00
notCharles
58307c15a3 App Name AlphaNum
Closes https://github.com/pelican-dev/panel/issues/562
2024-08-24 19:16:33 -04:00
Boy132
40810877e0 Add redis connection check to installer (#556) 2024-08-22 22:20:11 +02:00
Boy132
818781ca66 Fix isViable for Nodes with "unlimited" resources (#559) 2024-08-22 22:19:56 +02:00
Boy132
05477c711f Create missing server variables on EditServer page (#560)
* create missing server variables on editserver page

* remove count check
2024-08-22 22:19:38 +02:00
Boy132
20b06b7b39 Fix variables on CreateServer page (#558) 2024-08-20 22:45:41 +02:00
Boy132
c2b1a98d29 Convert variable "rules" to array (#507)
* convert variable "rules" to array

* allow importing eggs with string rules

* fix tests

* update stock eggs to rules array
2024-08-19 08:33:53 +02:00
notCharles
0ff429215d Revert "Test Runners"
This reverts commit d1ca21de9f.
2024-08-18 11:54:12 -04:00
notCharles
d1ca21de9f Test Runners 2024-08-18 11:41:36 -04:00
Boy132
d0c89b0729 ix installer cache (#554) 2024-08-18 17:23:02 +02:00
Boy132
ffadf9ac16 Clear cache before running migrations (#553) 2024-08-18 16:43:40 +02:00
Boy132
bf23389dba Fix default value for mailgun secret (#552) 2024-08-18 16:11:40 +02:00
MartinOscar
68e24896ae Patch for node 18 (#539) 2024-08-16 16:50:09 -04:00
Boy132
1864fff04f Update default image for new eggs (#540) 2024-08-16 22:44:12 +02:00
Boy132
155f2d6476 Add migration to fix allocations server_id foreign key (#542)
* add migration to fix allocations server_id foreign key

* fix the fix...
2024-08-13 19:43:16 +02:00
notCharles
bad5409d9c Fix saving SMTP without encryption 2024-08-10 19:39:41 -04:00
notCharles
3158bdfef8 Fix Single Egg Import 2024-08-10 18:20:21 -04:00
Boy132
1fba700096 Improve error handling for Installer (#532)
* make sure migrations ran

* add loading indicator to finish button

* make error notification persistent

* fix migration checker

* cleanup traits
2024-08-09 08:23:03 +02:00
MartinOscar
7f8fb3f650 Patch Env CLI (#528)
* Remove unused option

* Add redis user

* Adapt lang

* Change default redis username

* Cleanup

* Update app/Traits/Commands/RequestRedisSettingsTrait.php

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2024-08-08 17:59:28 -04:00
MartinOscar
d6e0421aaf Update StoreNodeRequest.php (#531) 2024-08-08 17:59:16 -04:00
MartinOscar
e8e1958969 Make default favicon path absolute to avoid 404 on admin (#529) 2024-08-06 06:31:52 -04:00
Boy132
2e094605e9 Round memory, swap and disk limits for wings (#523) 2024-08-04 22:21:23 +02:00
Boy132
953ee940aa Installer followup (#519)
* remove queue worker service creation from installer

* auto check redis
2024-08-04 18:53:54 +02:00
Boy132
496eaaaf83 Web Installer (#504)
* simplify setup command

* add installer page

* add route for installer

* adjust gitignore

* set colors globally

* add "unsaved data changes" alert

* add helper method to check if panel is installed

* make nicer

* redis username isn't required

* bring back db settings command

* store current date in "installed" file

* only redirect if install was successfull

* remove fpm requirement

* change "installed" marker to env variable

* improve requirements step

* add commands to change cache, queue or session drivers respectively

* removed `grouped` for better mobile view
2024-08-03 21:13:17 +02:00
MartinOscar
18cf6e9338 Update SetupTOTPDialog.tsx (#518) 2024-07-31 15:10:58 -04:00
Charles
525a106e81 Change TextArea -> Textarea...
Makes no sense as we have TextInput, TagsInput and KeyValue... But TextArea is an issue...
2024-07-30 14:12:29 -04:00
Charles
d22f975684 More Mobile UI
Closes https://github.com/pelican-dev/panel/issues/512
2024-07-30 12:58:16 -04:00
Charles
c4864feaa5 Whoops 2024-07-30 10:45:12 -04:00
Charles
b7b72d7336 Merge branch 'main' of https://github.com/pelican-dev/panel 2024-07-30 10:43:30 -04:00
Charles
686c4375bc Layout fix for mobile 2024-07-30 10:43:24 -04:00
Boy132
3f40256f8b Settings page followup (#514)
* remove group for toggle buttons

* fix default for APP_DEBUG

* correctly handle bool values

* fix pint

* small cleanup for example .env
2024-07-30 16:07:20 +02:00
Boy132
a58e159478 Settings page (#486)
* remove old settings stuff

* add basic settings page

* add some settings

* add "test mail" button

* fix mail fields not updating

* fix phpstan

* fix default for "top navigation"

* force toggle buttons to be bool

* force toggle to be bool

* add class to view to allow customization

* add mailgun settings

* add notification settings

* add timeout settings

* organize tabs into sub-functions

* add more settings

* add backup settings

* add sections to mail settings

* add setting for trusted_proxies

* fix unsaved data alert not showing

* fix clear action

* Fix clear action v2

TagsInput expects an array, not a string, fails on saving when using `''`

* Add App favicon

* Remove defaults, collapse misc sections

* Move Save btn, Add API rate limit

* small cleanup

---------

Co-authored-by: notCharles <charles@pelican.dev>
2024-07-29 12:14:24 +02:00
Boy132
d89af243a8 Fix user search on "create server" (#508) 2024-07-29 12:13:29 +02:00
Boy132
bddd6af8af Fix user deletion in no interactive mode (#506) 2024-07-29 12:13:08 +02:00
MartinOscar
e1bdf95971 Update SetupTOTPDialog.tsx (#476) 2024-07-29 05:58:20 -04:00
Lance Pioch
465a03bf0e Update readme.md 2024-07-24 20:10:45 -04:00
Boy132
2c2e52b18a fix phpstan (#503) 2024-07-23 11:32:32 +02:00
notCharles
fcef8d69ae Remove breadcrumbs 2024-07-20 19:15:01 -04:00
notCharles
8662806dfd Fix 500 if update url is blank 2024-07-20 18:51:38 -04:00
MartinOscar
acf43f2826 Ability to create allocations on EditServer page (#494)
* Ability to create allocation on edit page + Ability to assign allocation to server on creation

* Disable dehydrate for readonly

* set these to false

---------

Co-authored-by: notCharles <charles@pelican.dev>
2024-07-20 11:38:34 -04:00
Boy132
dfba8e3993 Command to cleanup docker images (#495)
* add command to cleanup docker images

* automatically cleanup images daily

* fix request

* fix empty check

* run pint
2024-07-20 17:23:03 +02:00
Boy132
56484a2282 Increase guzzle timeout when running tests (#485)
* increase guzzle timeout when running tests

* catch correct exception
2024-07-20 17:18:45 +02:00
MartinOscar
56b4938dc2 Fix #489 (#490)
* Fix #489

* Update app/Filament/Resources/NodeResource/Pages/EditNode.php

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

* Update app/Filament/Resources/NodeResource/Pages/EditNode.php

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

* Update app/Filament/Resources/NodeResource/Pages/EditNode.php

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>

---------

Co-authored-by: Boy132 <Boy132@users.noreply.github.com>
2024-07-17 16:22:12 -04:00
Boy132
10806d6d6b Fix SQLite foreign keys (#478)
* start migration to fix sqlite foreign keys

* add remaining foreign keys

* add ".sqlite.backup" files to gitignore
2024-07-17 14:43:04 +02:00
Boy132
a04937d698 Fix PORT_FLOOR check and CIDR_MAX_BITS in AssignmentService (#491)
* fix max cidr

* fix port floor
2024-07-17 13:01:13 +02:00
Boy132
8a3d67ada0 Fix update egg from url (#492) 2024-07-17 13:00:54 +02:00
Charles
833ae30e59 Add timeouts (#483)
* Add timeouts

Add Timeouts to github call.

* use config value
2024-07-15 19:09:52 -04:00
Charles
1fdff43ae7 Add Node CPU/Memory Graphs (#459)
* Update Node Stats

Soon TM

* Update

* Make these smaller

* Change graphs

* Remove this.

Didn't work anyways.

* Update Graphs

* Use User TZ and config var

* Fix math

* Change to per thread.
2024-07-14 16:48:14 -04:00
Boy132
bb7c0e0e66 Add "Delete files" task (#470)
* started "delete files" task

* add logic to DeleteFilesService

* add frontend

* make nicer

* move description to right place
2024-07-10 09:25:15 +02:00
Boy132
447e889a4f Fix default timestamp for activity logs (#468)
* fix default timestamp for activity logs

* fix phpstan
2024-07-10 08:36:24 +02:00
Exotical
1c1c8c0cc6 Fix client Activity tab issues; fixes #465 (#466)
* Remove deploy.locations from validator

* Change location data to optional for backwards compat

* Better styling

* Add back comma to follow coding style

* Remove EventServiceProvider from providers file

Fixes duplicated auth messages in the client Activity tab.

* Add null check on $model->actor

Prevents the client Activity tab page from breaking when an authentication attempt has failed.

* Proper type checking on $model->actor

Chose instanceof as it seems to be the best in terms of type safety.

Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com>

* Revert removal of EventServiceProvider

* Remove subscription of AuthenticationListener

* Remove subscriptions for auth events

* Remove unused import Dispatcher

* Remove unused import AuthenticationListener

---------

Co-authored-by: MartinOscar <40749467+RMartinOscar@users.noreply.github.com>
2024-07-09 21:30:12 -07:00
notCharles
7dad2d0e42 Fix #464 2024-07-07 19:33:25 -04:00
notCharles
212c93c2ba Fix #462 2024-07-05 18:24:07 -04:00
Boy132
7557dc1c8d Restart queue worker when changing email settings (#457) 2024-07-05 16:17:35 +02:00
Boy132
07735464c7 Add contributing guide (#460) 2024-07-05 01:15:45 +02:00
notCharles
8ba15538a9 Fix ToolTip 2024-07-03 16:33:32 -04:00
Charles
c115c6ddf5 Add Update URL to stock eggs 2024-07-03 10:36:34 -04:00
Charles
160ea1ed50 Enable Update URL
Since importing an egg via url was added, we can enable this.
2024-07-03 10:27:57 -04:00
MartinOscar
7164951085 Update EditServer.php (#455) 2024-07-02 13:31:35 -04:00
Charles
40721a2cb8 Fix #452
Prob not the best solution, but it works

Closes: https://github.com/pelican-dev/panel/issues/452
2024-07-02 08:01:17 -04:00
MartinOscar
c464b321dd Update EditProfile.php (#454) 2024-07-02 07:05:00 -04:00
MartinOscar
0f8c27a297 Update ContainerStatus add Starting|Stopping|Default (#449)
* Update ContainerStatus add Starting

* Update ContainerStatus add Stopping

* Update ContainerStatus add Default

* Update Icons, PHPStan

---------

Co-authored-by: notCharles <charles@pelican.dev>
2024-06-30 10:13:08 -04:00
212 changed files with 4922 additions and 2141 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
node_modules
vendor
database/database.sqlite
storage/debugbar/*.json
storage/logs/*.log
storage/framework/cache/data/*
storage/framework/sessions/*
storage/framework/testing
storage/framework/views/*.php

View File

@@ -4,7 +4,6 @@ APP_KEY=
APP_TIMEZONE=UTC
APP_URL=http://panel.test
APP_LOCALE=en
APP_ENVIRONMENT_ONLY=true
LOG_CHANNEL=daily
LOG_STACK=single
@@ -27,11 +26,7 @@ MAIL_FROM_ADDRESS=no-reply@example.com
MAIL_FROM_NAME="Pelican Admin"
# Set this to your domain to prevent it defaulting to 'localhost', causing mail servers such as Gmail to reject your mail
# MAIL_EHLO_DOMAIN=panel.example.com
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# Set this to true, and set start & end ports to auto create allocations.
PANEL_CLIENT_ALLOCATIONS_ENABLED=false
PANEL_CLIENT_ALLOCATIONS_RANGE_START=
PANEL_CLIENT_ALLOCATIONS_RANGE_END=

View File

@@ -33,7 +33,6 @@ body:
attributes:
label: Panel Version
description: Version number of your Panel (latest is not a version)
placeholder: 1.4.0
validations:
required: true
@@ -42,7 +41,6 @@ body:
attributes:
label: Wings Version
description: Version number of your Wings (latest is not a version)
placeholder: 1.4.2
validations:
required: true
@@ -68,7 +66,7 @@ body:
Run the following command to collect logs on your system.
Wings: `sudo wings diagnostics`
Panel: `tail -n 150 /var/www/pelican/storage/logs/laravel-$(date +%F).log | nc pelipaste.com 99`
Panel: `tail -n 150 /var/www/pelican/storage/logs/laravel-$(date +%F).log | curl -X POST -F 'c=@-' paste.pelistuff.com`
placeholder: "https://pelipaste.com/a1h6z"
render: bash
validations:

View File

@@ -1,81 +1,58 @@
#!/bin/ash -e
cd /app
mkdir -p /var/log/panel/logs/ /var/log/supervisord/ /var/log/nginx/ /var/log/php8/ \
&& chmod 777 /var/log/panel/logs/ \
&& ln -s /app/storage/logs/ /var/log/panel/
#mkdir -p /var/log/supervisord/ /var/log/php8/ \
## check for .env file and generate app keys if missing
if [ -f /app/var/.env ]; then
if [ -f /pelican-data/.env ]; then
echo "external vars exist."
rm -rf /app/.env
ln -s /app/var/.env /app/
rm -rf /var/www/html/.env
else
echo "external vars don't exist."
rm -rf /app/.env
touch /app/var/.env
rm -rf /var/www/html/.env
touch /pelican-data/.env
## manually generate a key because key generate --force fails
if [ -z $APP_KEY ]; then
echo -e "Generating key."
APP_KEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
echo -e "Generated app key: $APP_KEY"
echo -e "APP_KEY=$APP_KEY" > /app/var/.env
echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
else
echo -e "APP_KEY exists in environment, using that."
echo -e "APP_KEY=$APP_KEY" > /app/var/.env
echo -e "APP_KEY=$APP_KEY" > /pelican-data/.env
fi
ln -s /app/var/.env /app/
fi
echo "Checking if https is required."
if [ -f /etc/nginx/http.d/panel.conf ]; then
echo "Using nginx config already in place."
if [ $LE_EMAIL ]; then
echo "Checking for cert update"
certbot certonly -d $(echo $APP_URL | sed 's~http[s]*://~~g') --standalone -m $LE_EMAIL --agree-tos -n
else
echo "No letsencrypt email is set"
fi
mkdir /pelican-data/database
ln -s /pelican-data/.env /var/www/html/
ln -s /pelican-data/database/database.sqlite /var/www/html/database/
if ! grep -q "APP_KEY=" .env || grep -q "APP_KEY=$" .env; then
echo "Generating APP_KEY..."
php artisan key:generate --force
else
echo "Checking if letsencrypt email is set."
if [ -z $LE_EMAIL ]; then
echo "No letsencrypt email is set using http config."
cp .github/docker/default.conf /etc/nginx/http.d/panel.conf
else
echo "writing ssl config"
cp .github/docker/default_ssl.conf /etc/nginx/http.d/panel.conf
echo "updating ssl config for domain"
sed -i "s|<domain>|$(echo $APP_URL | sed 's~http[s]*://~~g')|g" /etc/nginx/http.d/panel.conf
echo "generating certs"
certbot certonly -d $(echo $APP_URL | sed 's~http[s]*://~~g') --standalone -m $LE_EMAIL --agree-tos -n
fi
echo "Removing the default nginx config"
rm -rf /etc/nginx/http.d/default.conf
echo "APP_KEY is already set."
fi
if [[ -z $DB_PORT ]]; then
echo -e "DB_PORT not specified, defaulting to 3306"
DB_PORT=3306
fi
## check for DB up before starting the panel
echo "Checking database status."
until nc -z -v -w30 $DB_HOST $DB_PORT
do
echo "Waiting for database connection..."
# wait for 1 seconds before check again
sleep 1
done
## make sure the db is set up
echo -e "Migrating and Seeding D.B"
php artisan migrate --seed --force
echo -e "Migrating Database"
php artisan migrate --force
## start cronjobs for the queue
echo -e "Starting cron jobs."
crond -L /var/log/crond -l 5
echo -e "Starting supervisord."
export SUPERVISORD_CADDY=false
## disable caddy if SKIP_CADDY is set
if [[ -z $SKIP_CADDY ]]; then
echo "Starting PHP-FPM and Caddy"
export SUPERVISORD_CADDY=true
else
echo "Starting PHP-FPM only"
fi
chown -R www-data:www-data . /pelican-data/.env /pelican-data/database
echo "Starting Supervisord"
exec "$@"

View File

@@ -25,15 +25,15 @@ autostart=true
autorestart=true
[program:queue-worker]
command=/usr/local/bin/php /app/artisan queue:work --queue=high,standard,low --sleep=3 --tries=3
user=nginx
command=/usr/local/bin/php /var/www/html/artisan queue:work --queue=high,standard,low --sleep=3 --tries=3
user=www-data
autostart=true
autorestart=true
[program:nginx]
command=/usr/sbin/nginx -g 'daemon off;'
autostart=true
autorestart=true
[program:caddy]
command=caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
autostart=%(ENV_SUPERVISORD_CADDY)s
autorestart=%(ENV_SUPERVISORD_CADDY)s
priority=10
stdout_events_enabled=true
stderr_events_enabled=true

View File

@@ -32,7 +32,6 @@ jobs:
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
APP_ENVIRONMENT_ONLY: "true"
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
@@ -41,6 +40,8 @@ jobs:
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: root
GUZZLE_TIMEOUT: 60
GUZZLE_CONNECT_TIMEOUT: 60
steps:
- name: Code Checkout
uses: actions/checkout@v4
@@ -104,7 +105,6 @@ jobs:
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
APP_ENVIRONMENT_ONLY: "true"
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
@@ -113,6 +113,8 @@ jobs:
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: root
GUZZLE_TIMEOUT: 60
GUZZLE_CONNECT_TIMEOUT: 60
steps:
- name: Code Checkout
uses: actions/checkout@v4
@@ -166,13 +168,14 @@ jobs:
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
APP_ENVIRONMENT_ONLY: "true"
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: sqlite
DB_DATABASE: testing.sqlite
GUZZLE_TIMEOUT: 60
GUZZLE_CONNECT_TIMEOUT: 60
steps:
- name: Code Checkout
uses: actions/checkout@v4

11
Caddyfile Normal file
View File

@@ -0,0 +1,11 @@
{
email {$ADMIN_EMAIL}
}
{$APP_URL} {
root * /var/www/html/public
encode gzip
php_fastcgi 127.0.0.1:9000
file_server
}

View File

@@ -1,41 +1,58 @@
# Stage 0:
# Build the assets that are needed for the frontend. This build stage is then discarded
# since we won't need NodeJS anymore in the future. This Docker image ships a final production
# level distribution
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine
WORKDIR /app
# Pelican Production Dockerfile
FROM node:20-alpine AS yarn
#FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
WORKDIR /build
COPY . ./
RUN yarn install --frozen-lockfile \
&& yarn run build:production
# Stage 1:
# Build the actual container with all of the needed PHP dependencies that will run the application.
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
WORKDIR /app
COPY . ./
COPY --from=0 /app/public/assets ./public/assets
RUN apk add --no-cache --update ca-certificates dcron curl git supervisor tar unzip nginx libpng-dev libxml2-dev libzip-dev icu-dev certbot certbot-nginx \
&& docker-php-ext-configure zip \
&& docker-php-ext-install bcmath gd intl pdo_mysql zip \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& cp .env.example .env \
&& mkdir -p bootstrap/cache/ storage/logs storage/framework/sessions storage/framework/views storage/framework/cache \
&& chmod 777 -R bootstrap storage \
&& composer install --no-dev --optimize-autoloader \
&& rm -rf .env bootstrap/cache/*.php \
&& mkdir -p /app/storage/logs/ \
&& chown -R nginx:nginx .
RUN yarn install --frozen-lockfile && yarn run build:production
RUN rm /usr/local/etc/php-fpm.conf \
&& echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \
&& echo "0 23 * * * certbot renew --nginx --quiet" >> /var/spool/cron/crontabs/root \
&& sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \
&& mkdir -p /var/run/php /var/run/nginx
FROM php:8.3-fpm-alpine
# FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
COPY .github/docker/default.conf /etc/nginx/http.d/default.conf
COPY .github/docker/www.conf /usr/local/etc/php-fpm.conf
COPY .github/docker/supervisord.conf /etc/supervisord.conf
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www/html
# Install dependencies
RUN apk update && apk add --no-cache \
libpng-dev libjpeg-turbo-dev freetype-dev libzip-dev icu-dev \
zip unzip curl \
caddy ca-certificates supervisor \
&& docker-php-ext-install bcmath gd intl zip opcache pcntl posix pdo_mysql
# Copy the Caddyfile to the container
COPY Caddyfile /etc/caddy/Caddyfile
# Copy the application code to the container
COPY . .
COPY --from=yarn /build/public/assets ./public/assets
RUN touch .env
RUN composer install --no-dev --optimize-autoloader
# Set file permissions
RUN chmod -R 755 /var/www/html/storage \
&& chmod -R 755 /var/www/html/bootstrap/cache
# Add scheduler to cron
RUN echo "* * * * * php /var/www/html/artisan schedule:run >> /dev/null 2>&1" | crontab -u www-data -
## supervisord config and log dir
RUN cp .github/docker/supervisord.conf /etc/supervisord.conf && \
mkdir /var/log/supervisord/
HEALTHCHECK --interval=5m --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost/up || exit 1
EXPOSE 80:2019
EXPOSE 443
VOLUME /pelican-data
EXPOSE 80 443
ENTRYPOINT [ "/bin/ash", ".github/docker/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View File

@@ -2,163 +2,49 @@
namespace App\Console\Commands\Environment;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
use App\Traits\Commands\EnvironmentWriterTrait;
use Illuminate\Support\Facades\Artisan;
class AppSettingsCommand extends Command
{
use EnvironmentWriterTrait;
public const CACHE_DRIVERS = [
'file' => 'Filesystem (recommended)',
'redis' => 'Redis',
];
public const SESSION_DRIVERS = [
'file' => 'Filesystem (recommended)',
'redis' => 'Redis',
'database' => 'Database',
'cookie' => 'Cookie',
];
public const QUEUE_DRIVERS = [
'database' => 'Database (recommended)',
'redis' => 'Redis',
'sync' => 'Synchronous',
];
protected $description = 'Configure basic environment settings for the Panel.';
protected $signature = 'p:environment:setup
{--url= : The URL that this Panel is running on.}
{--cache= : The cache driver backend to use.}
{--session= : The session driver backend to use.}
{--queue= : The queue driver backend to use.}
{--redis-host= : Redis host to use for connections.}
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}
{--settings-ui= : Enable or disable the settings UI.}';
{--url= : The URL that this Panel is running on.}';
protected array $variables = [];
/**
* AppSettingsCommand constructor.
*/
public function __construct(private Kernel $console)
public function handle(): void
{
parent::__construct();
}
$path = base_path('.env');
if (!file_exists($path)) {
$this->comment('Copying example .env file');
copy($path . '.example', $path);
}
if (!config('app.key')) {
$this->comment('Generating app key');
Artisan::call('key:generate');
}
/**
* Handle command execution.
*
* @throws \App\Exceptions\PanelException
*/
public function handle(): int
{
$this->variables['APP_TIMEZONE'] = 'UTC';
$this->output->comment(__('commands.appsettings.comment.url'));
$this->variables['APP_URL'] = $this->option('url') ?? $this->ask(
'Application URL',
config('app.url', 'https://example.com')
);
$selected = config('cache.default', 'file');
$this->variables['CACHE_STORE'] = $this->option('cache') ?? $this->choice(
'Cache Driver',
self::CACHE_DRIVERS,
array_key_exists($selected, self::CACHE_DRIVERS) ? $selected : null
);
$selected = config('session.driver', 'file');
$this->variables['SESSION_DRIVER'] = $this->option('session') ?? $this->choice(
'Session Driver',
self::SESSION_DRIVERS,
array_key_exists($selected, self::SESSION_DRIVERS) ? $selected : null
);
$selected = config('queue.default', 'database');
$this->variables['QUEUE_CONNECTION'] = $this->option('queue') ?? $this->choice(
'Queue Driver',
self::QUEUE_DRIVERS,
array_key_exists($selected, self::QUEUE_DRIVERS) ? $selected : null
);
if (!is_null($this->option('settings-ui'))) {
$this->variables['APP_ENVIRONMENT_ONLY'] = $this->option('settings-ui') == 'true' ? 'false' : 'true';
} else {
$this->variables['APP_ENVIRONMENT_ONLY'] = $this->confirm(__('commands.appsettings.comment.settings_ui'), true) ? 'false' : 'true';
}
// Make sure session cookies are set as "secure" when using HTTPS
if (str_starts_with($this->variables['APP_URL'], 'https://')) {
$this->variables['SESSION_SECURE_COOKIE'] = 'true';
}
$redisUsed = count(collect($this->variables)->filter(function ($item) {
return $item === 'redis';
})) !== 0;
if ($redisUsed) {
$this->requestRedisSettings();
}
$path = base_path('.env');
if (!file_exists($path)) {
copy($path . '.example', $path);
}
$this->comment('Writing variables to .env file');
$this->writeToEnvironment($this->variables);
if (!config('app.key')) {
Artisan::call('key:generate');
}
if ($this->variables['QUEUE_CONNECTION'] !== 'sync') {
$this->call('p:environment:queue-service', [
'--use-redis' => $redisUsed,
]);
}
$this->info($this->console->output());
return 0;
}
/**
* Request redis connection details and verify them.
*/
private function requestRedisSettings(): void
{
$this->output->note(__('commands.appsettings.redis.note'));
$this->variables['REDIS_HOST'] = $this->option('redis-host') ?? $this->ask(
'Redis Host',
config('database.redis.default.host')
);
$askForRedisPassword = true;
if (!empty(config('database.redis.default.password'))) {
$this->variables['REDIS_PASSWORD'] = config('database.redis.default.password');
$askForRedisPassword = $this->confirm('It seems a password is already defined for Redis, would you like to change it?');
}
if ($askForRedisPassword) {
$this->output->comment(__('commands.appsettings.redis.comment'));
$this->variables['REDIS_PASSWORD'] = $this->option('redis-pass') ?? $this->output->askHidden(
'Redis Password'
);
}
if (empty($this->variables['REDIS_PASSWORD'])) {
$this->variables['REDIS_PASSWORD'] = 'null';
}
$this->variables['REDIS_PORT'] = $this->option('redis-port') ?? $this->ask(
'Redis Port',
config('database.redis.default.port')
);
$this->info("Setup complete. Vist {$this->variables['APP_URL']}/installer to complete the installation");
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands\Environment;
use App\Traits\Commands\RequestRedisSettingsTrait;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
class CacheSettingsCommand extends Command
{
use EnvironmentWriterTrait;
use RequestRedisSettingsTrait;
public const CACHE_DRIVERS = [
'file' => 'Filesystem (default)',
'database' => 'Database',
'redis' => 'Redis',
];
protected $description = 'Configure cache settings for the Panel.';
protected $signature = 'p:environment:cache
{--driver= : The cache driver backend to use.}
{--redis-host= : Redis host to use for connections.}
{--redis-user= : User used to connect to redis.}
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* CacheSettingsCommand constructor.
*/
public function __construct(private Kernel $console)
{
parent::__construct();
}
/**
* Handle command execution.
*/
public function handle(): int
{
$selected = config('cache.default', 'file');
$this->variables['CACHE_STORE'] = $this->option('driver') ?? $this->choice(
'Cache Driver',
self::CACHE_DRIVERS,
array_key_exists($selected, self::CACHE_DRIVERS) ? $selected : null
);
if ($this->variables['CACHE_STORE'] === 'redis') {
$this->requestRedisSettings();
if (config('queue.default') !== 'sync') {
$this->call('p:environment:queue-service', [
'--overwrite' => true,
]);
}
}
$this->writeToEnvironment($this->variables);
$this->info($this->console->output());
return 0;
}
}

View File

@@ -2,10 +2,10 @@
namespace App\Console\Commands\Environment;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\DatabaseManager;
use App\Traits\Commands\EnvironmentWriterTrait;
class DatabaseSettingsCommand extends Command
{

View File

@@ -2,8 +2,8 @@
namespace App\Console\Commands\Environment;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use App\Traits\Commands\EnvironmentWriterTrait;
class EmailSettingsCommand extends Command
{
@@ -61,6 +61,8 @@ class EmailSettingsCommand extends Command
$this->writeToEnvironment($this->variables);
$this->call('queue:restart');
$this->line('Updating stored environment configuration file.');
$this->line('');
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Console\Commands\Environment;
use App\Traits\Commands\RequestRedisSettingsTrait;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
class QueueSettingsCommand extends Command
{
use EnvironmentWriterTrait;
use RequestRedisSettingsTrait;
public const QUEUE_DRIVERS = [
'database' => 'Database (default)',
'redis' => 'Redis',
'sync' => 'Synchronous',
];
protected $description = 'Configure queue settings for the Panel.';
protected $signature = 'p:environment:queue
{--driver= : The queue driver backend to use.}
{--redis-host= : Redis host to use for connections.}
{--redis-user= : User used to connect to redis.}
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* QueueSettingsCommand constructor.
*/
public function __construct(private Kernel $console)
{
parent::__construct();
}
/**
* Handle command execution.
*/
public function handle(): int
{
$selected = config('queue.default', 'database');
$this->variables['QUEUE_CONNECTION'] = $this->option('driver') ?? $this->choice(
'Queue Driver',
self::QUEUE_DRIVERS,
array_key_exists($selected, self::QUEUE_DRIVERS) ? $selected : null
);
if ($this->variables['QUEUE_CONNECTION'] === 'redis') {
$this->requestRedisSettings();
$this->call('p:environment:queue-service', [
'--overwrite' => true,
]);
}
$this->writeToEnvironment($this->variables);
$this->info($this->console->output());
return 0;
}
}

View File

@@ -14,7 +14,6 @@ class QueueWorkerServiceCommand extends Command
{--service-name= : Name of the queue worker service.}
{--user= : The user that PHP runs under.}
{--group= : The group that PHP runs under.}
{--use-redis : Whether redis is used.}
{--overwrite : Force overwrite if the service file already exists.}';
public function handle(): void
@@ -32,7 +31,8 @@ class QueueWorkerServiceCommand extends Command
$user = $this->option('user') ?? $this->ask('Webserver User', 'www-data');
$group = $this->option('group') ?? $this->ask('Webserver Group', 'www-data');
$afterRedis = $this->option('use-redis') ? '
$redisUsed = config('queue.default') === 'redis' || config('session.driver') === 'redis' || config('cache.default') === 'redis';
$afterRedis = $redisUsed ? '
After=redis-server.service' : '';
$basePath = base_path();

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands\Environment;
use App\Traits\Commands\RequestRedisSettingsTrait;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
class RedisSetupCommand extends Command
{
use EnvironmentWriterTrait;
use RequestRedisSettingsTrait;
protected $description = 'Configure the Panel to use Redis as cache, queue and session driver.';
protected $signature = 'p:redis:setup
{--redis-host= : Redis host to use for connections.}
{--redis-user= : User used to connect to redis.}
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* RedisSetupCommand constructor.
*/
public function __construct(private Kernel $console)
{
parent::__construct();
}
/**
* Handle command execution.
*/
public function handle(): int
{
$this->variables['CACHE_STORE'] = 'redis';
$this->variables['QUEUE_CONNECTION'] = 'redis';
$this->variables['SESSION_DRIVERS'] = 'redis';
$this->requestRedisSettings();
$this->call('p:environment:queue-service', [
'--overwrite' => true,
]);
$this->writeToEnvironment($this->variables);
$this->info($this->console->output());
return 0;
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands\Environment;
use App\Traits\Commands\RequestRedisSettingsTrait;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
class SessionSettingsCommand extends Command
{
use EnvironmentWriterTrait;
use RequestRedisSettingsTrait;
public const SESSION_DRIVERS = [
'file' => 'Filesystem (default)',
'redis' => 'Redis',
'database' => 'Database',
'cookie' => 'Cookie',
];
protected $description = 'Configure session settings for the Panel.';
protected $signature = 'p:environment:session
{--driver= : The session driver backend to use.}
{--redis-host= : Redis host to use for connections.}
{--redis-user= : User used to connect to redis.}
{--redis-pass= : Password used to connect to redis.}
{--redis-port= : Port to connect to redis over.}';
protected array $variables = [];
/**
* SessionSettingsCommand constructor.
*/
public function __construct(private Kernel $console)
{
parent::__construct();
}
/**
* Handle command execution.
*/
public function handle(): int
{
$selected = config('session.driver', 'file');
$this->variables['SESSION_DRIVER'] = $this->option('driver') ?? $this->choice(
'Session Driver',
self::SESSION_DRIVERS,
array_key_exists($selected, self::SESSION_DRIVERS) ? $selected : null
);
if ($this->variables['SESSION_DRIVER'] === 'redis') {
$this->requestRedisSettings();
if (config('queue.default') !== 'sync') {
$this->call('p:environment:queue-service', [
'--overwrite' => true,
]);
}
}
$this->writeToEnvironment($this->variables);
$this->info($this->console->output());
return 0;
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Console\Commands\Maintenance;
use App\Models\Node;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class PruneImagesCommand extends Command
{
protected $signature = 'p:maintenance:prune-images {node?}';
protected $description = 'Clean up all dangling docker images to clear up disk space.';
public function handle(): void
{
$node = $this->argument('node');
if (empty($node)) {
$nodes = Node::all();
/** @var Node $node */
foreach ($nodes as $node) {
$this->cleanupImages($node);
}
} else {
$this->cleanupImages((int) $node);
}
}
private function cleanupImages(int|Node $node): void
{
if (!$node instanceof Node) {
$node = Node::query()->findOrFail($node);
}
try {
$response = Http::daemon($node)
->connectTimeout(5)
->timeout(30)
->delete('/api/system/docker/image/prune')
->json() ?? [];
if (empty($response) || $response['ImagesDeleted'] === null) {
$this->warn("Node {$node->id}: No images to clean up.");
return;
}
$count = count($response['ImagesDeleted']);
$useBinaryPrefix = config('panel.use_binary_prefix');
$space = round($useBinaryPrefix ? $response['SpaceReclaimed'] / 1024 / 1024 : $response['SpaceReclaimed'] / 1000 / 1000, 2) . ($useBinaryPrefix ? ' MiB' : ' MB');
$this->info("Node {$node->id}: Cleaned up {$count} dangling docker images. ({$space})");
} catch (Exception $exception) {
$this->error($exception->getMessage());
}
}
}

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands\Overrides;
use App\Console\RequiresDatabaseMigrations;
use App\Traits\Commands\RequiresDatabaseMigrations;
use Illuminate\Database\Console\Seeds\SeedCommand as BaseSeedCommand;
class SeedCommand extends BaseSeedCommand

View File

@@ -2,7 +2,7 @@
namespace App\Console\Commands\Overrides;
use App\Console\RequiresDatabaseMigrations;
use App\Traits\Commands\RequiresDatabaseMigrations;
use Illuminate\Foundation\Console\UpCommand as BaseUpCommand;
class UpCommand extends BaseUpCommand

View File

@@ -15,7 +15,7 @@ class DeleteUserCommand extends Command
public function handle(): int
{
$search = $this->option('user') ?? $this->ask(trans('command/messages.user.search_users'));
Assert::notEmpty($search, 'Search term should be an email address, got: %s.');
Assert::notEmpty($search, 'Search term should not be empty.');
$results = User::query()
->where('id', 'LIKE', "$search%")
@@ -42,6 +42,8 @@ class DeleteUserCommand extends Command
if (!$deleteUser = $this->ask(trans('command/messages.user.select_search_user'))) {
return $this->handle();
}
$deleteUser = User::query()->findOrFail($deleteUser);
} else {
if (count($results) > 1) {
$this->error(trans('command/messages.user.multiple_found'));
@@ -53,8 +55,7 @@ class DeleteUserCommand extends Command
}
if ($this->confirm(trans('command/messages.user.confirm_delete')) || !$this->input->isInteractive()) {
$user = User::query()->findOrFail($deleteUser);
$user->delete();
$deleteUser->delete();
$this->info(trans('command/messages.user.deleted'));
}

View File

@@ -52,7 +52,7 @@ class MakeUserCommand extends Command
['UUID', $user->uuid],
['Email', $user->email],
['Username', $user->username],
['Admin', $user->root_admin ? 'Yes' : 'No'],
['Admin', $user->isRootAdmin() ? 'Yes' : 'No'],
]);
return 0;

View File

@@ -2,6 +2,7 @@
namespace App\Console;
use App\Jobs\NodeStatistics;
use App\Models\ActivityLog;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Database\Console\PruneCommand;
@@ -9,6 +10,7 @@ use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
use App\Console\Commands\Schedule\ProcessRunnableCommand;
use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use App\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use App\Console\Commands\Maintenance\PruneImagesCommand;
class Kernel extends ConsoleKernel
{
@@ -30,7 +32,11 @@ class Kernel extends ConsoleKernel
// Execute scheduled commands for servers every minute, as if there was a normal cron running.
$schedule->command(ProcessRunnableCommand::class)->everyMinute()->withoutOverlapping();
$schedule->command(CleanServiceBackupFilesCommand::class)->daily();
$schedule->command(PruneImagesCommand::class)->daily();
$schedule->job(new NodeStatistics())->everyFiveSeconds()->withoutOverlapping();
if (config('backups.prune_age')) {
// Every 30 minutes, run the backup pruning command so that any abandoned backups can be deleted.

View File

@@ -6,12 +6,14 @@ enum ContainerStatus: string
{
// Docker Based
case Created = 'created';
case Starting = 'starting';
case Running = 'running';
case Restarting = 'restarting';
case Exited = 'exited';
case Paused = 'paused';
case Dead = 'dead';
case Removing = 'removing';
case Stopping = 'stopping';
case Offline = 'offline';
// HTTP Based
@@ -20,15 +22,17 @@ enum ContainerStatus: string
public function icon(): string
{
return match ($this) {
self::Created => 'tabler-heart-plus',
self::Starting => 'tabler-heart-up',
self::Running => 'tabler-heartbeat',
self::Restarting => 'tabler-heart-bolt',
self::Exited => 'tabler-heart-exclamation',
self::Paused => 'tabler-heart-pause',
self::Dead => 'tabler-heart-x',
self::Dead, self::Offline => 'tabler-heart-x',
self::Removing => 'tabler-heart-down',
self::Missing => 'tabler-heart-question',
self::Offline => 'tabler-heart-bolt',
self::Missing => 'tabler-heart-search',
self::Stopping => 'tabler-heart-minus',
};
}
@@ -36,6 +40,7 @@ enum ContainerStatus: string
{
return match ($this) {
self::Created => 'primary',
self::Starting => 'warning',
self::Running => 'success',
self::Restarting => 'info',
self::Exited => 'danger',
@@ -43,6 +48,7 @@ enum ContainerStatus: string
self::Dead => 'danger',
self::Removing => 'warning',
self::Missing => 'danger',
self::Stopping => 'warning',
self::Offline => 'gray',
};
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Enums;
enum RolePermissionModels: string
{
case ApiKey = 'apiKey';
case DatabaseHost = 'databaseHost';
case Database = 'database';
case Egg = 'egg';
case Mount = 'mount';
case Node = 'node';
case Role = 'role';
case Server = 'server';
case User = 'user';
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum RolePermissionPrefixes: string
{
case ViewAny = 'viewList';
case View = 'view';
case Create = 'create';
case Update = 'update';
case Delete = 'delete';
}

View File

@@ -1,10 +0,0 @@
<?php
namespace App\Filament\Clusters;
use Filament\Clusters\Cluster;
class Settings extends Cluster
{
protected static ?string $navigationIcon = 'tabler-settings';
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Filament\Pages\Installer;
use App\Filament\Pages\Installer\Steps\AdminUserStep;
use App\Filament\Pages\Installer\Steps\DatabaseStep;
use App\Filament\Pages\Installer\Steps\EnvironmentStep;
use App\Filament\Pages\Installer\Steps\RedisStep;
use App\Filament\Pages\Installer\Steps\RequirementsStep;
use App\Models\User;
use App\Services\Users\UserCreationService;
use App\Traits\CheckMigrationsTrait;
use App\Traits\EnvironmentWriterTrait;
use Exception;
use Filament\Forms\Components\Wizard;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Pages\Concerns\HasUnsavedDataChangesAlert;
use Filament\Pages\SimplePage;
use Filament\Support\Enums\MaxWidth;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
/**
* @property Form $form
*/
class PanelInstaller extends SimplePage implements HasForms
{
use CheckMigrationsTrait;
use EnvironmentWriterTrait;
use HasUnsavedDataChangesAlert;
use InteractsWithForms;
public $data = [];
protected static string $view = 'filament.pages.installer';
public function getMaxWidth(): MaxWidth|string
{
return MaxWidth::SevenExtraLarge;
}
public static function show(): bool
{
if (User::count() <= 0) {
return true;
}
if (config('panel.client_features.installer.enabled')) {
return true;
}
return false;
}
public function mount()
{
abort_unless(self::show(), 404);
$this->form->fill();
}
public function dehydrate(): void
{
Artisan::call('config:clear');
Artisan::call('cache:clear');
}
protected function getFormSchema(): array
{
return [
Wizard::make([
RequirementsStep::make(),
EnvironmentStep::make(),
DatabaseStep::make(),
RedisStep::make()
->hidden(fn (Get $get) => $get('env.SESSION_DRIVER') != 'redis' && $get('env.QUEUE_CONNECTION') != 'redis' && $get('env.CACHE_STORE') != 'redis'),
AdminUserStep::make(),
])
->persistStepInQueryString()
->submitAction(new HtmlString(Blade::render(<<<'BLADE'
<x-filament::button
type="submit"
size="sm"
wire:loading.attr="disabled"
>
Finish
<span wire:loading><x-filament::loading-indicator class="h-4 w-4" /></span>
</x-filament::button>
BLADE))),
];
}
protected function getFormStatePath(): ?string
{
return 'data';
}
protected function hasUnsavedDataChangesAlert(): bool
{
return true;
}
public function submit()
{
try {
$inputs = $this->form->getState();
// Write variables to .env file
$variables = array_get($inputs, 'env');
$this->writeToEnvironment($variables);
// Clear config cache
Artisan::call('config:clear');
// Run migrations
Artisan::call('migrate', [
'--force' => true,
'--seed' => true,
'--database' => $variables['DB_CONNECTION'],
]);
if (!$this->hasCompletedMigrations()) {
throw new Exception('Migrations didn\'t run successfully. Double check your database configuration.');
}
// Create first admin user
$userData = array_get($inputs, 'user');
$userData['root_admin'] = true;
$user = app(UserCreationService::class)->handle($userData);
// Install setup complete
$this->writeToEnvironment(['APP_INSTALLER' => 'false']);
$this->rememberData();
Notification::make()
->title('Successfully Installed')
->success()
->send();
auth()->loginUsingId($user->id);
return redirect('/admin');
} catch (Exception $exception) {
report($exception);
Notification::make()
->title('Installation Failed')
->body($exception->getMessage())
->danger()
->persistent()
->send();
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
class AdminUserStep
{
public static function make(): Step
{
return Step::make('user')
->label('Admin User')
->schema([
TextInput::make('user.email')
->label('Admin E-Mail')
->required()
->email()
->placeholder('admin@example.com'),
TextInput::make('user.username')
->label('Admin Username')
->required()
->placeholder('admin'),
TextInput::make('user.password')
->label('Admin Password')
->required()
->password()
->revealable(),
]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Support\Exceptions\Halt;
use Illuminate\Support\Facades\DB;
use PDOException;
class DatabaseStep
{
public static function make(): Step
{
return Step::make('database')
->label('Database')
->columns()
->schema([
TextInput::make('env.DB_DATABASE')
->label(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite' ? 'Database Path' : 'Database Name')
->columnSpanFull()
->hintIcon('tabler-question-mark')
->hintIconTooltip(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite' ? 'The path of your .sqlite file relative to the database folder.' : 'The name of the panel database.')
->required()
->default(fn (Get $get) => env('DB_DATABASE', $get('env.DB_CONNECTION') === 'sqlite' ? 'database.sqlite' : 'panel')),
TextInput::make('env.DB_HOST')
->label('Database Host')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The host of your database. Make sure it is reachable.')
->required()
->default(env('DB_HOST', '127.0.0.1'))
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'),
TextInput::make('env.DB_PORT')
->label('Database Port')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The port of your database.')
->required()
->numeric()
->minValue(1)
->maxValue(65535)
->default(env('DB_PORT', 3306))
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'),
TextInput::make('env.DB_USERNAME')
->label('Database Username')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The name of your database user.')
->required()
->default(env('DB_USERNAME', 'pelican'))
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'),
TextInput::make('env.DB_PASSWORD')
->label('Database Password')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The password of your database user. Can be empty.')
->password()
->revealable()
->default(env('DB_PASSWORD'))
->hidden(fn (Get $get) => $get('env.DB_CONNECTION') === 'sqlite'),
])
->afterValidation(function (Get $get) {
$driver = $get('env.DB_CONNECTION');
if ($driver !== 'sqlite') {
try {
config()->set('database.connections._panel_install_test', [
'driver' => $driver,
'host' => $get('env.DB_HOST'),
'port' => $get('env.DB_PORT'),
'database' => $get('env.DB_DATABASE'),
'username' => $get('env.DB_USERNAME'),
'password' => $get('env.DB_PASSWORD'),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
]);
DB::connection('_panel_install_test')->getPdo();
} catch (PDOException $exception) {
Notification::make()
->title('Database connection failed')
->body($exception->getMessage())
->danger()
->send();
DB::disconnect('_panel_install_test');
throw new Halt('Database connection failed');
}
}
});
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Set;
class EnvironmentStep
{
public const CACHE_DRIVERS = [
'file' => 'Filesystem',
'redis' => 'Redis',
];
public const SESSION_DRIVERS = [
'file' => 'Filesystem',
'redis' => 'Redis',
'database' => 'Database',
'cookie' => 'Cookie',
];
public const QUEUE_DRIVERS = [
'sync' => 'Sync',
'database' => 'Database',
'redis' => 'Redis',
];
public const DATABASE_DRIVERS = [
'sqlite' => 'SQLite',
'mariadb' => 'MariaDB',
'mysql' => 'MySQL',
];
public static function make(): Step
{
return Step::make('environment')
->label('Environment')
->columns()
->schema([
TextInput::make('env.APP_NAME')
->label('App Name')
->hintIcon('tabler-question-mark')
->hintIconTooltip('This will be the Name of your Panel.')
->required()
->default(config('app.name')),
TextInput::make('env.APP_URL')
->label('App URL')
->hintIcon('tabler-question-mark')
->hintIconTooltip('This will be the URL you access your Panel from.')
->required()
->default(config('app.url'))
->live()
->afterStateUpdated(fn ($state, Set $set) => $set('env.SESSION_SECURE_COOKIE', str_starts_with($state, 'https://'))),
Toggle::make('env.SESSION_SECURE_COOKIE')
->hidden()
->default(env('SESSION_SECURE_COOKIE')),
ToggleButtons::make('env.CACHE_STORE')
->label('Cache Driver')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for caching. We recommend "Filesystem".')
->required()
->inline()
->options(self::CACHE_DRIVERS)
->default(config('cache.default', 'file')),
ToggleButtons::make('env.SESSION_DRIVER')
->label('Session Driver')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for storing sessions. We recommend "Filesystem" or "Database".')
->required()
->inline()
->options(self::SESSION_DRIVERS)
->default(config('session.driver', 'file')),
ToggleButtons::make('env.QUEUE_CONNECTION')
->label('Queue Driver')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for handling queues. We recommend "Sync" or "Database".')
->required()
->inline()
->options(self::QUEUE_DRIVERS)
->default(config('queue.default', 'database')),
ToggleButtons::make('env.DB_CONNECTION')
->label('Database Driver')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The driver used for the panel database. We recommend "SQLite".')
->required()
->inline()
->options(self::DATABASE_DRIVERS)
->default(config('database.default', 'sqlite')),
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
use Exception;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Support\Exceptions\Halt;
use Illuminate\Support\Facades\Redis;
class RedisStep
{
public static function make(): Step
{
return Step::make('redis')
->label('Redis')
->columns()
->schema([
TextInput::make('env.REDIS_HOST')
->label('Redis Host')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The host of your redis server. Make sure it is reachable.')
->required()
->default(config('database.redis.default.host')),
TextInput::make('env.REDIS_PORT')
->label('Redis Port')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The port of your redis server.')
->required()
->default(config('database.redis.default.port')),
TextInput::make('env.REDIS_USERNAME')
->label('Redis Username')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The name of your redis user. Can be empty')
->default(config('database.redis.default.username')),
TextInput::make('env.REDIS_PASSWORD')
->label('Redis Password')
->hintIcon('tabler-question-mark')
->hintIconTooltip('The password for your redis user. Can be empty.')
->password()
->revealable()
->default(config('database.redis.default.password')),
])
->afterValidation(function (Get $get) {
try {
config()->set('database.redis._panel_install_test', [
'host' => $get('env.REDIS_HOST'),
'username' => $get('env.REDIS_USERNAME'),
'password' => $get('env.REDIS_PASSWORD'),
'port' => $get('env.REDIS_PORT'),
]);
Redis::connection('_panel_install_test')->command('ping');
} catch (Exception $exception) {
Notification::make()
->title('Redis connection failed')
->body($exception->getMessage())
->danger()
->send();
throw new Halt('Redis connection failed');
}
});
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Pages\Installer\Steps;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Wizard\Step;
use Filament\Notifications\Notification;
use Filament\Support\Exceptions\Halt;
class RequirementsStep
{
public static function make(): Step
{
$correctPhpVersion = version_compare(PHP_VERSION, '8.2.0') >= 0;
$fields = [
Section::make('PHP Version')
->description('8.2 or newer')
->icon($correctPhpVersion ? 'tabler-check' : 'tabler-x')
->iconColor($correctPhpVersion ? 'success' : 'danger')
->schema([
Placeholder::make('')
->content('Your PHP Version ' . ($correctPhpVersion ? 'is' : 'needs to be') .' 8.2 or newer.'),
]),
];
$phpExtensions = [
'BCMath' => extension_loaded('bcmath'),
'cURL' => extension_loaded('curl'),
'GD' => extension_loaded('gd'),
'intl' => extension_loaded('intl'),
'mbstring' => extension_loaded('mbstring'),
'MySQL' => extension_loaded('pdo_mysql'),
'SQLite3' => extension_loaded('pdo_sqlite'),
'XML' => extension_loaded('xml'),
'Zip' => extension_loaded('zip'),
];
$allExtensionsInstalled = !in_array(false, $phpExtensions);
$fields[] = Section::make('PHP Extensions')
->description(implode(', ', array_keys($phpExtensions)))
->icon($allExtensionsInstalled ? 'tabler-check' : 'tabler-x')
->iconColor($allExtensionsInstalled ? 'success' : 'danger')
->schema([
Placeholder::make('')
->content('All needed PHP Extensions are installed.')
->visible($allExtensionsInstalled),
Placeholder::make('')
->content('The following PHP Extensions are missing: ' . implode(', ', array_keys($phpExtensions, false)))
->visible(!$allExtensionsInstalled),
]);
$folderPermissions = [
'Storage' => substr(sprintf('%o', fileperms(base_path('storage/'))), -4) >= 755,
'Cache' => substr(sprintf('%o', fileperms(base_path('bootstrap/cache/'))), -4) >= 755,
];
$correctFolderPermissions = !in_array(false, $folderPermissions);
$fields[] = Section::make('Folder Permissions')
->description(implode(', ', array_keys($folderPermissions)))
->icon($correctFolderPermissions ? 'tabler-check' : 'tabler-x')
->iconColor($correctFolderPermissions ? 'success' : 'danger')
->schema([
Placeholder::make('')
->content('All Folders have the correct permissions.')
->visible($correctFolderPermissions),
Placeholder::make('')
->content('The following Folders have wrong permissions: ' . implode(', ', array_keys($folderPermissions, false)))
->visible(!$correctFolderPermissions),
]);
return Step::make('requirements')
->label('Server Requirements')
->schema($fields)
->afterValidation(function () use ($correctPhpVersion, $allExtensionsInstalled, $correctFolderPermissions) {
if (!$correctPhpVersion || !$allExtensionsInstalled || !$correctFolderPermissions) {
Notification::make()
->title('Some requirements are missing!')
->danger()
->send();
throw new Halt();
}
});
}
}

View File

@@ -0,0 +1,578 @@
<?php
namespace App\Filament\Pages;
use App\Models\Backup;
use App\Notifications\MailTested;
use App\Traits\EnvironmentWriterTrait;
use Exception;
use Filament\Actions\Action;
use Filament\Forms\Components\Actions\Action as FormAction;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Tabs;
use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
use Filament\Pages\Concerns\HasUnsavedDataChangesAlert;
use Filament\Pages\Concerns\InteractsWithHeaderActions;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Notification as MailNotification;
/**
* @property Form $form
*/
class Settings extends Page implements HasForms
{
use EnvironmentWriterTrait;
use HasUnsavedDataChangesAlert;
use InteractsWithForms;
use InteractsWithHeaderActions;
protected static ?string $navigationIcon = 'tabler-settings';
protected static ?string $navigationGroup = 'Advanced';
protected static string $view = 'filament.pages.settings';
public ?array $data = [];
public function mount(): void
{
$this->form->fill();
}
public static function canAccess(): bool
{
return auth()->user()->can('view settings');
}
protected function getFormSchema(): array
{
return [
Tabs::make('Tabs')
->columns()
->persistTabInQueryString()
->disabled(fn () => !auth()->user()->can('update settings'))
->tabs([
Tab::make('general')
->label('General')
->icon('tabler-home')
->schema($this->generalSettings()),
Tab::make('recaptcha')
->label('reCAPTCHA')
->icon('tabler-shield')
->schema($this->recaptchaSettings()),
Tab::make('mail')
->label('Mail')
->icon('tabler-mail')
->schema($this->mailSettings()),
Tab::make('backup')
->label('Backup')
->icon('tabler-box')
->schema($this->backupSettings()),
Tab::make('misc')
->label('Misc')
->icon('tabler-tool')
->schema($this->miscSettings()),
]),
];
}
private function generalSettings(): array
{
return [
TextInput::make('APP_NAME')
->label('App Name')
->required()
->alphaNum()
->default(env('APP_NAME', 'Pelican')),
TextInput::make('APP_FAVICON')
->label('App Favicon')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Favicons should be placed in the public folder, located in the root panel directory.')
->required()
->default(env('APP_FAVICON', '/pelican.ico')),
Toggle::make('APP_DEBUG')
->label('Enable Debug Mode?')
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('APP_DEBUG', (bool) $state))
->default(env('APP_DEBUG', config('app.debug'))),
ToggleButtons::make('FILAMENT_TOP_NAVIGATION')
->label('Navigation')
->inline()
->options([
false => 'Sidebar',
true => 'Topbar',
])
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('FILAMENT_TOP_NAVIGATION', (bool) $state))
->default(env('FILAMENT_TOP_NAVIGATION', config('panel.filament.top-navigation'))),
ToggleButtons::make('PANEL_USE_BINARY_PREFIX')
->label('Unit prefix')
->inline()
->options([
false => 'Decimal Prefix (MB/ GB)',
true => 'Binary Prefix (MiB/ GiB)',
])
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_USE_BINARY_PREFIX', (bool) $state))
->default(env('PANEL_USE_BINARY_PREFIX', config('panel.use_binary_prefix'))),
ToggleButtons::make('APP_2FA_REQUIRED')
->label('2FA Requirement')
->inline()
->options([
0 => 'Not required',
1 => 'Required for only Admins',
2 => 'Required for all Users',
])
->formatStateUsing(fn ($state): int => (int) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('APP_2FA_REQUIRED', (int) $state))
->default(env('APP_2FA_REQUIRED', config('panel.auth.2fa_required'))),
TagsInput::make('TRUSTED_PROXIES')
->label('Trusted Proxies')
->separator()
->splitKeys(['Tab', ' '])
->placeholder('New IP or IP Range')
->default(env('TRUSTED_PROXIES', config('trustedproxy.proxies')))
->hintActions([
FormAction::make('clear')
->label('Clear')
->color('danger')
->icon('tabler-trash')
->requiresConfirmation()
->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [])),
FormAction::make('cloudflare')
->label('Set to Cloudflare IPs')
->icon('tabler-brand-cloudflare')
->authorize(fn () => auth()->user()->can('update settings'))
->action(fn (Set $set) => $set('TRUSTED_PROXIES', [
'173.245.48.0/20',
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'141.101.64.0/18',
'108.162.192.0/18',
'190.93.240.0/20',
'188.114.96.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'162.158.0.0/15',
'104.16.0.0/13',
'104.24.0.0/14',
'172.64.0.0/13',
'131.0.72.0/22',
])),
]),
];
}
private function recaptchaSettings(): array
{
return [
Toggle::make('RECAPTCHA_ENABLED')
->label('Enable reCAPTCHA?')
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('RECAPTCHA_ENABLED', (bool) $state))
->default(env('RECAPTCHA_ENABLED', config('recaptcha.enabled'))),
TextInput::make('RECAPTCHA_DOMAIN')
->label('Domain')
->required()
->visible(fn (Get $get) => $get('RECAPTCHA_ENABLED'))
->default(env('RECAPTCHA_DOMAIN', config('recaptcha.domain'))),
TextInput::make('RECAPTCHA_WEBSITE_KEY')
->label('Website Key')
->required()
->visible(fn (Get $get) => $get('RECAPTCHA_ENABLED'))
->default(env('RECAPTCHA_WEBSITE_KEY', config('recaptcha.website_key'))),
TextInput::make('RECAPTCHA_SECRET_KEY')
->label('Secret Key')
->required()
->visible(fn (Get $get) => $get('RECAPTCHA_ENABLED'))
->default(env('RECAPTCHA_SECRET_KEY', config('recaptcha.secret_key'))),
];
}
private function mailSettings(): array
{
return [
ToggleButtons::make('MAIL_MAILER')
->label('Mail Driver')
->columnSpanFull()
->inline()
->options([
'log' => 'Print mails to Log',
'smtp' => 'SMTP Server',
'sendmail' => 'sendmail Binary',
'mailgun' => 'Mailgun',
'mandrill' => 'Mandrill',
'postmark' => 'Postmark',
])
->live()
->default(env('MAIL_MAILER', config('mail.default')))
->hintAction(
FormAction::make('test')
->label('Send Test Mail')
->icon('tabler-send')
->hidden(fn (Get $get) => $get('MAIL_MAILER') === 'log')
->authorize(fn () => auth()->user()->can('update settings'))
->action(function () {
try {
MailNotification::route('mail', auth()->user()->email)
->notify(new MailTested(auth()->user()));
Notification::make()
->title('Test Mail sent')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Test Mail failed')
->body($exception->getMessage())
->danger()
->send();
}
})
),
Section::make('"From" Settings')
->description('Set the Address and Name used as "From" in mails.')
->columns()
->schema([
TextInput::make('MAIL_FROM_ADDRESS')
->label('From Address')
->required()
->email()
->default(env('MAIL_FROM_ADDRESS', config('mail.from.address'))),
TextInput::make('MAIL_FROM_NAME')
->label('From Name')
->required()
->default(env('MAIL_FROM_NAME', config('mail.from.name'))),
]),
Section::make('SMTP Configuration')
->columns()
->visible(fn (Get $get) => $get('MAIL_MAILER') === 'smtp')
->schema([
TextInput::make('MAIL_HOST')
->label('Host')
->required()
->default(env('MAIL_HOST', config('mail.mailers.smtp.host'))),
TextInput::make('MAIL_PORT')
->label('Port')
->required()
->numeric()
->minValue(1)
->maxValue(65535)
->default(env('MAIL_PORT', config('mail.mailers.smtp.port'))),
TextInput::make('MAIL_USERNAME')
->label('Username')
->default(env('MAIL_USERNAME', config('mail.mailers.smtp.username'))),
TextInput::make('MAIL_PASSWORD')
->label('Password')
->password()
->revealable()
->default(env('MAIL_PASSWORD')),
ToggleButtons::make('MAIL_ENCRYPTION')
->label('Encryption')
->inline()
->options(['tls' => 'TLS', 'ssl' => 'SSL', '' => 'None'])
->default(env('MAIL_ENCRYPTION', config('mail.mailers.smtp.encryption', 'tls'))),
]),
Section::make('Mailgun Configuration')
->columns()
->visible(fn (Get $get) => $get('MAIL_MAILER') === 'mailgun')
->schema([
TextInput::make('MAILGUN_DOMAIN')
->label('Domain')
->required()
->default(env('MAILGUN_DOMAIN', config('services.mailgun.domain'))),
TextInput::make('MAILGUN_SECRET')
->label('Secret')
->required()
->default(env('MAILGUN_SECRET', config('services.mailgun.secret'))),
TextInput::make('MAILGUN_ENDPOINT')
->label('Endpoint')
->required()
->default(env('MAILGUN_ENDPOINT', config('services.mailgun.endpoint'))),
]),
];
}
private function backupSettings(): array
{
return [
ToggleButtons::make('APP_BACKUP_DRIVER')
->label('Backup Driver')
->columnSpanFull()
->inline()
->options([
Backup::ADAPTER_DAEMON => 'Wings',
Backup::ADAPTER_AWS_S3 => 'S3',
])
->live()
->default(env('APP_BACKUP_DRIVER', config('backups.default'))),
Section::make('Throttles')
->description('Configure how many backups can be created in a period. Set period to 0 to disable this throttle.')
->columns()
->schema([
TextInput::make('BACKUP_THROTTLE_LIMIT')
->label('Limit')
->required()
->numeric()
->minValue(1)
->default(config('backups.throttles.limit')),
TextInput::make('BACKUP_THROTTLE_PERIOD')
->label('Period')
->required()
->numeric()
->minValue(0)
->suffix('Seconds')
->default(config('backups.throttles.period')),
]),
Section::make('S3 Configuration')
->columns()
->visible(fn (Get $get) => $get('APP_BACKUP_DRIVER') === Backup::ADAPTER_AWS_S3)
->schema([
TextInput::make('AWS_DEFAULT_REGION')
->label('Default Region')
->required()
->default(config('backups.disks.s3.region')),
TextInput::make('AWS_ACCESS_KEY_ID')
->label('Access Key ID')
->required()
->default(config('backups.disks.s3.key')),
TextInput::make('AWS_SECRET_ACCESS_KEY')
->label('Secret Access Key')
->required()
->default(config('backups.disks.s3.secret')),
TextInput::make('AWS_BACKUPS_BUCKET')
->label('Bucket')
->required()
->default(config('backups.disks.s3.bucket')),
TextInput::make('AWS_ENDPOINT')
->label('Endpoint')
->required()
->default(config('backups.disks.s3.endpoint')),
Toggle::make('AWS_USE_PATH_STYLE_ENDPOINT')
->label('Use path style endpoint?')
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('AWS_USE_PATH_STYLE_ENDPOINT', (bool) $state))
->default(env('AWS_USE_PATH_STYLE_ENDPOINT', config('backups.disks.s3.use_path_style_endpoint'))),
]),
];
}
private function miscSettings(): array
{
return [
Section::make('Automatic Allocation Creation')
->description('Toggle if Users can create allocations via the client area.')
->columns()
->collapsible()
->collapsed()
->schema([
Toggle::make('PANEL_CLIENT_ALLOCATIONS_ENABLED')
->label('Allow Users to create allocations?')
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->live()
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_CLIENT_ALLOCATIONS_ENABLED', (bool) $state))
->default(env('PANEL_CLIENT_ALLOCATIONS_ENABLED', config('panel.client_features.allocations.enabled'))),
TextInput::make('PANEL_CLIENT_ALLOCATIONS_RANGE_START')
->label('Starting Port')
->required()
->numeric()
->minValue(1024)
->maxValue(65535)
->visible(fn (Get $get) => $get('PANEL_CLIENT_ALLOCATIONS_ENABLED'))
->default(env('PANEL_CLIENT_ALLOCATIONS_RANGE_START')),
TextInput::make('PANEL_CLIENT_ALLOCATIONS_RANGE_END')
->label('Ending Port')
->required()
->numeric()
->minValue(1024)
->maxValue(65535)
->visible(fn (Get $get) => $get('PANEL_CLIENT_ALLOCATIONS_ENABLED'))
->default(env('PANEL_CLIENT_ALLOCATIONS_RANGE_END')),
]),
Section::make('Mail Notifications')
->description('Toggle which mail notifications should be sent to Users.')
->columns()
->collapsible()
->collapsed()
->schema([
Toggle::make('PANEL_SEND_INSTALL_NOTIFICATION')
->label('Server Installed')
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->live()
->columnSpanFull()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('PANEL_SEND_INSTALL_NOTIFICATION', (bool) $state))
->default(env('PANEL_SEND_INSTALL_NOTIFICATION', config('panel.email.send_install_notification'))),
Toggle::make('PANEL_SEND_REINSTALL_NOTIFICATION')
->label('Server Reinstalled')
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->live()
->columnSpanFull()
->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'))),
]),
Section::make('Connections')
->description('Timeouts used when making requests.')
->columns()
->collapsible()
->collapsed()
->schema([
TextInput::make('GUZZLE_TIMEOUT')
->label('Request Timeout')
->required()
->numeric()
->minValue(15)
->maxValue(60)
->suffix('Seconds')
->default(env('GUZZLE_TIMEOUT', config('panel.guzzle.timeout'))),
TextInput::make('GUZZLE_CONNECT_TIMEOUT')
->label('Connect Timeout')
->required()
->numeric()
->minValue(5)
->maxValue(60)
->suffix('Seconds')
->default(env('GUZZLE_CONNECT_TIMEOUT', config('panel.guzzle.connect_timeout'))),
]),
Section::make('Activity Logs')
->description('Configure how often old activity logs should be pruned and whether admin activities should be logged.')
->columns()
->collapsible()
->collapsed()
->schema([
TextInput::make('APP_ACTIVITY_PRUNE_DAYS')
->label('Prune age')
->required()
->numeric()
->minValue(1)
->maxValue(365)
->suffix('Days')
->default(env('APP_ACTIVITY_PRUNE_DAYS', config('activity.prune_days'))),
Toggle::make('APP_ACTIVITY_HIDE_ADMIN')
->label('Hide admin activities?')
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->live()
->formatStateUsing(fn ($state): bool => (bool) $state)
->afterStateUpdated(fn ($state, Set $set) => $set('APP_ACTIVITY_HIDE_ADMIN', (bool) $state))
->default(env('APP_ACTIVITY_HIDE_ADMIN', config('activity.hide_admin_activity'))),
]),
Section::make('API')
->description('Defines the rate limit for the number of requests per minute that can be executed.')
->columns()
->collapsible()
->collapsed()
->schema([
TextInput::make('APP_API_CLIENT_RATELIMIT')
->label('Client API Rate Limit')
->required()
->numeric()
->minValue(1)
->suffix('Requests Per Minute')
->default(env('APP_API_CLIENT_RATELIMIT', config('http.rate_limit.client'))),
TextInput::make('APP_API_APPLICATION_RATELIMIT')
->label('Application API Rate Limit')
->required()
->numeric()
->minValue(1)
->suffix('Requests Per Minute')
->default(env('APP_API_APPLICATION_RATELIMIT', config('http.rate_limit.application'))),
]),
];
}
protected function getFormStatePath(): ?string
{
return 'data';
}
protected function hasUnsavedDataChangesAlert(): bool
{
return true;
}
public function save(): void
{
try {
$data = $this->form->getState();
// Convert bools to a string, so they are correctly written to the .env file
$data = array_map(fn ($value) => is_bool($value) ? ($value ? 'true' : 'false') : $value, $data);
$this->writeToEnvironment($data);
Artisan::call('config:clear');
Artisan::call('queue:restart');
$this->rememberData();
$this->redirect($this->getUrl());
Notification::make()
->title('Settings saved')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Save failed')
->body($exception->getMessage())
->danger()
->send();
}
}
protected function getHeaderActions(): array
{
return [
Action::make('save')
->action('save')
->authorize(fn () => auth()->user()->can('update settings'))
->keyBindings(['mod+s']),
];
}
}

View File

@@ -64,8 +64,7 @@ class EditDatabaseHost extends EditRecord
->helperText('The password for the database user.')
->password()
->revealable()
->maxLength(255)
->required(),
->maxLength(255),
Select::make('node_id')
->searchable()
->preload()

View File

@@ -42,7 +42,8 @@ class ListDatabaseHosts extends ListRecords
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete databasehost')),
]),
]);
}

View File

@@ -4,10 +4,10 @@ namespace App\Filament\Resources\DatabaseResource\Pages;
use App\Filament\Resources\DatabaseResource;
use Filament\Actions;
use Filament\Tables\Actions\EditAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -48,7 +48,8 @@ class ListDatabases extends ListRecords
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete database')),
]),
]);
}

View File

@@ -74,9 +74,10 @@ class CreateEgg extends CreateRecord
->helperText('')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TextInput::make('update_url')
->disabled()
->helperText('Not implemented.')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
->hintIcon('tabler-question-mark')
->hintIconTooltip('URLs must point directly to the raw .json file.')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2])
->url(),
KeyValue::make('docker_images')
->live()
->columnSpanFull()
@@ -133,7 +134,7 @@ class CreateEgg extends CreateRecord
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
@@ -142,7 +143,7 @@ class CreateEgg extends CreateRecord
->mutateRelationshipDataBeforeSaveUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
@@ -172,7 +173,30 @@ class CreateEgg extends CreateRecord
Checkbox::make('user_viewable')->label('Viewable'),
Checkbox::make('user_editable')->label('Editable'),
]),
Textarea::make('rules')->columnSpanFull(),
TagsInput::make('rules')
->columnSpanFull()
->placeholder('Add Rule')
->reorderable()
->suggestions([
'required',
'nullable',
'string',
'integer',
'numeric',
'boolean',
'alpha',
'alpha_dash',
'alpha_num',
'url',
'email',
'regex:',
'min:',
'max:',
'between:',
'between:1024,65535',
'in:',
'in:true,false',
]),
]),
]),
Tab::make('Install Script')
@@ -186,7 +210,7 @@ class CreateEgg extends CreateRecord
TextInput::make('script_container')
->required()
->maxLength(255)
->default('alpine:3.4'),
->default('ghcr.io/pelican-eggs/installers:debian'),
Select::make('script_entry')
->selectablePlaceholder(false)

View File

@@ -2,12 +2,15 @@
namespace App\Filament\Resources\EggResource\Pages;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Filament\Resources\EggResource;
use App\Filament\Resources\EggResource\RelationManagers\ServersRelationManager;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use Exception;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\FileUpload;
@@ -22,12 +25,9 @@ use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Services\Eggs\Sharing\EggExporterService;
use Filament\Forms;
use Filament\Forms\Form;
class EditEgg extends EditRecord
{
@@ -91,8 +91,10 @@ class EditEgg extends EditRecord
->helperText('')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
TextInput::make('update_url')
->disabled()
->helperText('Not implemented.')
->label('Update URL')
->url()
->hintIcon('tabler-question-mark')
->hintIconTooltip('URLs must point directly to the raw .json file.')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 2, 'lg' => 2]),
KeyValue::make('docker_images')
->live()
@@ -142,7 +144,7 @@ class EditEgg extends EditRecord
->mutateRelationshipDataBeforeCreateUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
@@ -151,7 +153,7 @@ class EditEgg extends EditRecord
->mutateRelationshipDataBeforeSaveUsing(function (array $data): array {
$data['default_value'] ??= '';
$data['description'] ??= '';
$data['rules'] ??= '';
$data['rules'] ??= [];
$data['user_viewable'] ??= '';
$data['user_editable'] ??= '';
@@ -181,7 +183,30 @@ class EditEgg extends EditRecord
Checkbox::make('user_viewable')->label('Viewable'),
Checkbox::make('user_editable')->label('Editable'),
]),
TextInput::make('rules')->columnSpanFull(),
TagsInput::make('rules')
->columnSpanFull()
->placeholder('Add Rule')
->reorderable()
->suggestions([
'required',
'nullable',
'string',
'integer',
'numeric',
'boolean',
'alpha',
'alpha_dash',
'alpha_num',
'url',
'email',
'regex:',
'min:',
'max:',
'between:',
'between:1024,65535',
'in:',
'in:true,false',
]),
]),
]),
Tab::make('Install Script')
@@ -220,14 +245,13 @@ class EditEgg extends EditRecord
Actions\DeleteAction::make('deleteEgg')
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete' : 'In Use'),
Actions\Action::make('exportEgg')
->label('Export')
->color('primary')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')),
}, 'egg-' . $egg->getKebabName() . '.json'))
->authorize(fn () => auth()->user()->can('export egg')),
Actions\Action::make('importEgg')
->label('Import')
->form([
@@ -249,6 +273,7 @@ class EditEgg extends EditRecord
->schema([
TextInput::make('url')
->label('URL')
->default(fn (Egg $egg): ?string => $egg->update_url)
->hint('Link to the egg file (eg. minecraft.json)')
->url(),
]),
@@ -267,16 +292,14 @@ class EditEgg extends EditRecord
Notification::make()
->title('Import Failed')
->body($exception->getMessage())
->danger()
->danger() // Will Robinson
->send();
report($exception);
return;
}
}
if (!empty($data['url'])) {
} elseif (!empty($data['url'])) {
try {
$eggImportService->fromUrl($data['url'], $egg);
} catch (Exception $exception) {
@@ -297,8 +320,8 @@ class EditEgg extends EditRecord
->title('Import Success')
->success()
->send();
}),
})
->authorize(fn () => auth()->user()->can('import egg')),
$this->getSaveFormAction()->formId('form'),
];
}

View File

@@ -14,13 +14,13 @@ use Filament\Forms\Components\Tabs\Tab;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Filament\Tables;
class ListEggs extends ListRecords
{
@@ -55,11 +55,13 @@ class ListEggs extends ListRecords
->color('primary')
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')),
}, 'egg-' . $egg->getKebabName() . '.json'))
->authorize(fn () => auth()->user()->can('export egg')),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete egg')),
]),
]);
}
@@ -138,7 +140,8 @@ class ListEggs extends ListRecords
->title('Import Success')
->success()
->send();
}),
})
->authorize(fn () => auth()->user()->can('import egg')),
];
}
}

View File

@@ -43,7 +43,8 @@ class ListMounts extends ListRecords
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete mount')),
]),
])
->emptyStateIcon('tabler-layers-linked')

View File

@@ -3,12 +3,11 @@
namespace App\Filament\Resources\NodeResource\Pages;
use App\Filament\Resources\NodeResource;
use App\Filament\Resources\NodeResource\Widgets\NodeMemoryChart;
use App\Filament\Resources\NodeResource\Widgets\NodeStorageChart;
use App\Models\Node;
use App\Services\Nodes\NodeUpdateService;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Tabs;
@@ -17,6 +16,7 @@ use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\ToggleButtons;
use Filament\Forms\Components\View;
use Filament\Forms\Get;
use Filament\Forms\Set;
use Filament\Notifications\Notification;
@@ -41,6 +41,32 @@ class EditNode extends EditRecord
->persistTabInQueryString()
->columnSpanFull()
->tabs([
Tab::make('')
->label('Overview')
->icon('tabler-chart-area-line-filled')
->columns(6)
->schema([
Fieldset::make()
->label('Node Information')
->columns(4)
->schema([
Placeholder::make('')
->label('Wings Version')
->content(fn (Node $node) => $node->systemInformation()['version'] ?? 'Unknown'),
Placeholder::make('')
->label('CPU Threads')
->content(fn (Node $node) => $node->systemInformation()['cpu_count'] ?? 0),
Placeholder::make('')
->label('Architecture')
->content(fn (Node $node) => $node->systemInformation()['architecture'] ?? 'Unknown'),
Placeholder::make('')
->label('Kernel')
->content(fn (Node $node) => $node->systemInformation()['kernel_version'] ?? 'Unknown'),
]),
View::make('filament.components.node-cpu-chart')->columnSpan(3),
View::make('filament.components.node-memory-chart')->columnSpan(3),
// TODO: Make purdy View::make('filament.components.node-storage-chart')->columnSpan(3),
]),
Tab::make('Basic Settings')
->icon('tabler-server')
->schema([
@@ -437,16 +463,17 @@ class EditNode extends EditRecord
];
}
protected function getFooterWidgets(): array
{
return [
NodeStorageChart::class,
NodeMemoryChart::class,
];
}
protected function afterSave(): void
{
$this->fillForm();
}
protected function getColumnSpan()
{
return null;
}
protected function getColumnStart()
{
return null;
}
}

View File

@@ -84,7 +84,8 @@ class ListNodes extends ListRecords
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete node')),
]),
])
->emptyStateIcon('tabler-server-2')

View File

@@ -7,12 +7,12 @@ use App\Models\Node;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Set;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Forms\Form;
use Filament\Forms\Set;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Table;
@@ -152,7 +152,8 @@ class AllocationsRelationManager extends RelationManager
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete allocation')),
]),
]);
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Filament\Resources\NodeResource\Widgets;
use App\Models\Node;
use Carbon\Carbon;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Model;
class NodeCpuChart extends ChartWidget
{
protected static ?string $pollingInterval = '5s';
protected static ?string $maxHeight = '300px';
public ?Model $record = null;
protected function getData(): array
{
/** @var Node $node */
$node = $this->record;
$threads = $node->systemInformation()['cpu_count'] ?? 0;
$cpu = collect(cache()->get("nodes.$node->id.cpu_percent"))
->slice(-10)
->map(fn ($value, $key) => [
'cpu' => number_format($value * $threads, 2),
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
->all();
return [
'datasets' => [
[
'data' => array_column($cpu, 'cpu'),
'backgroundColor' => [
'rgba(96, 165, 250, 0.3)',
],
'tension' => '0.3',
'fill' => true,
],
],
'labels' => array_column($cpu, 'timestamp'),
];
}
protected function getType(): string
{
return 'line';
}
protected function getOptions(): RawJs
{
return RawJs::make(<<<'JS'
{
scales: {
y: {
min: 0,
},
},
plugins: {
legend: {
display: false,
}
}
}
JS);
}
public function getHeading(): string
{
/** @var Node $node */
$node = $this->record;
$threads = $node->systemInformation()['cpu_count'] ?? 0;
$cpu = number_format(collect(cache()->get("nodes.$node->id.cpu_percent"))->last() * $threads, 2);
$max = number_format($threads * 100) . '%';
return 'CPU - ' . $cpu . '% Of ' . $max;
}
}

View File

@@ -3,66 +3,83 @@
namespace App\Filament\Resources\NodeResource\Widgets;
use App\Models\Node;
use Carbon\Carbon;
use Filament\Support\RawJs;
use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Model;
class NodeMemoryChart extends ChartWidget
{
protected static ?string $heading = 'Memory';
protected static ?string $pollingInterval = '60s';
protected static ?string $pollingInterval = '5s';
protected static ?string $maxHeight = '300px';
public ?Model $record = null;
protected static ?array $options = [
'scales' => [
'x' => [
'grid' => [
'display' => false,
],
'ticks' => [
'display' => false,
],
],
'y' => [
'grid' => [
'display' => false,
],
'ticks' => [
'display' => false,
],
],
],
];
protected function getData(): array
{
/** @var Node $node */
$node = $this->record;
$total = ($node->statistics()['memory_total'] ?? 0) / 1024 / 1024 / 1024;
$used = ($node->statistics()['memory_used'] ?? 0) / 1024 / 1024 / 1024;
$unused = $total - $used;
$memUsed = collect(cache()->get("nodes.$node->id.memory_used"))->slice(-10)
->map(fn ($value, $key) => [
'memory' => config('panel.use_binary_prefix') ? $value / 1024 / 1024 / 1024 : $value / 1000 / 1000 / 1000,
'timestamp' => Carbon::createFromTimestamp($key, (auth()->user()->timezone ?? 'UTC'))->format('H:i:s'),
])
->all();
return [
'datasets' => [
[
'label' => 'Data Cool',
'data' => [$used, $unused],
'data' => array_column($memUsed, 'memory'),
'backgroundColor' => [
'rgb(255, 99, 132)',
'rgb(54, 162, 235)',
'rgb(255, 205, 86)',
'rgba(96, 165, 250, 0.3)',
],
'tension' => '0.3',
'fill' => true,
],
// 'backgroundColor' => [],
],
'labels' => ['Used', 'Unused'],
'labels' => array_column($memUsed, 'timestamp'),
];
}
protected function getType(): string
{
return 'pie';
return 'line';
}
protected function getOptions(): RawJs
{
return RawJs::make(<<<'JS'
{
scales: {
y: {
min: 0,
},
},
plugins: {
legend: {
display: false,
}
}
}
JS);
}
public function getHeading(): string
{
/** @var Node $node */
$node = $this->record;
$latestMemoryUsed = collect(cache()->get("nodes.$node->id.memory_used"))->last();
$totalMemory = collect(cache()->get("nodes.$node->id.memory_total"))->last();
$used = config('panel.use_binary_prefix')
? number_format($latestMemoryUsed / 1024 / 1024 / 1024, 2) .' GiB'
: number_format($latestMemoryUsed / 1000 / 1000 / 1000, 2) . ' GB';
$total = config('panel.use_binary_prefix')
? number_format($totalMemory / 1024 / 1024 / 1024, 2) .' GiB'
: number_format($totalMemory / 1000 / 1000 / 1000, 2) . ' GB';
return 'Memory - ' . $used . ' Of ' . $total;
}
}

View File

@@ -9,8 +9,8 @@ use Illuminate\Database\Eloquent\Model;
class NodeStorageChart extends ChartWidget
{
protected static ?string $heading = 'Storage';
protected static ?string $pollingInterval = '60s';
protected static ?string $maxHeight = '300px';
public ?Model $record = null;
@@ -47,7 +47,6 @@ class NodeStorageChart extends ChartWidget
return [
'datasets' => [
[
'label' => 'Data Cool',
'data' => [$used, $unused],
'backgroundColor' => [
'rgb(255, 99, 132)',
@@ -55,7 +54,6 @@ class NodeStorageChart extends ChartWidget
'rgb(255, 205, 86)',
],
],
// 'backgroundColor' => [],
],
'labels' => ['Used', 'Unused'],
];

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Filament\Resources;
use App\Enums\RolePermissionModels;
use App\Enums\RolePermissionPrefixes;
use App\Filament\Resources\RoleResource\Pages;
use App\Models\Role;
use Filament\Facades\Filament;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Forms\Get;
use Filament\Resources\Resource;
use Illuminate\Support\Str;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static ?string $navigationIcon = 'tabler-users-group';
protected static ?string $recordTitleAttribute = 'name';
public static function getNavigationBadge(): ?string
{
return static::getModel()::count() ?: null;
}
public static function form(Form $form): Form
{
$permissions = [];
foreach (RolePermissionModels::cases() as $model) {
$options = [];
foreach (RolePermissionPrefixes::cases() as $prefix) {
$options[$prefix->value . ' ' . strtolower($model->value)] = Str::headline($prefix->value);
}
if (array_key_exists($model->value, Role::MODEL_SPECIFIC_PERMISSIONS)) {
foreach (Role::MODEL_SPECIFIC_PERMISSIONS[$model->value] as $permission) {
$options[$permission . ' ' . strtolower($model->value)] = Str::headline($permission);
}
}
$permissions[] = self::makeSection($model->value, $options);
}
foreach (Role::SPECIAL_PERMISSIONS as $model => $prefixes) {
$options = [];
foreach ($prefixes as $prefix) {
$options[$prefix . ' ' . strtolower($model)] = Str::headline($prefix);
}
$permissions[] = self::makeSection($model, $options);
}
return $form
->columns(1)
->schema([
TextInput::make('name')
->label('Role Name')
->required()
->disabled(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
TextInput::make('guard_name')
->label('Guard Name')
->default(Filament::getCurrentPanel()?->getAuthGuard() ?? '')
->nullable()
->hidden(),
Fieldset::make('Permissions')
->columns(3)
->schema($permissions)
->hidden(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
Placeholder::make('permissions')
->content('The Root Admin has all permissions.')
->visible(fn (Get $get) => $get('name') === Role::ROOT_ADMIN),
]);
}
private static function makeSection(string $model, array $options): Section
{
$icon = null;
if (class_exists('\App\Filament\Resources\\' . $model . 'Resource')) {
$icon = ('\App\Filament\Resources\\' . $model . 'Resource')::getNavigationIcon();
} elseif (class_exists('\App\Filament\Pages\\' . $model)) {
$icon = ('\App\Filament\Pages\\' . $model)::getNavigationIcon();
}
return Section::make(Str::headline(Str::plural($model)))
->columnSpan(1)
->collapsible()
->collapsed()
->icon($icon)
->headerActions([
Action::make('count')
->label(fn (Get $get) => count($get(strtolower($model) . '_list')))
->badge(),
])
->schema([
CheckboxList::make(strtolower($model) . '_list')
->label('')
->options($options)
->columns()
->gridDirection('row')
->bulkToggleable()
->live()
->afterStateHydrated(
function (Component $component, string $operation, ?Role $record) use ($options) {
if (in_array($operation, ['edit', 'view'])) {
if (blank($record)) {
return;
}
if ($component->isVisible()) {
$component->state(
collect($options)
->filter(fn ($value, $key) => $record->checkPermissionTo($key))
->keys()
->toArray()
);
}
}
}
)
->dehydrated(fn ($state) => !blank($state)),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListRoles::route('/'),
'create' => Pages\CreateRole::route('/create'),
'edit' => Pages\EditRole::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
/**
* @property Role $record
*/
class CreateRole extends CreateRecord
{
protected static string $resource = RoleResource::class;
protected static bool $canCreateAnother = false;
public Collection $permissions;
protected function mutateFormDataBeforeCreate(array $data): array
{
$this->permissions = collect($data)
->filter(function ($permission, $key) {
return !in_array($key, ['name', 'guard_name']);
})
->values()
->flatten()
->unique();
return Arr::only($data, ['name', 'guard_name']);
}
protected function afterCreate(): void
{
$permissionModels = collect();
$this->permissions->each(function ($permission) use ($permissionModels) {
$permissionModels->push(Permission::firstOrCreate([
'name' => $permission,
'guard_name' => $this->data['guard_name'],
]));
});
$this->record->syncPermissions($permissionModels);
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Spatie\Permission\Models\Permission;
/**
* @property Role $record
*/
class EditRole extends EditRecord
{
protected static string $resource = RoleResource::class;
public Collection $permissions;
protected function mutateFormDataBeforeSave(array $data): array
{
$this->permissions = collect($data)
->filter(function ($permission, $key) {
return !in_array($key, ['name', 'guard_name']);
})
->values()
->flatten()
->unique();
return Arr::only($data, ['name', 'guard_name']);
}
protected function afterSave(): void
{
$permissionModels = collect();
$this->permissions->each(function ($permission) use ($permissionModels) {
$permissionModels->push(Permission::firstOrCreate([
'name' => $permission,
'guard_name' => $this->data['guard_name'],
]));
});
$this->record->syncPermissions($permissionModels);
}
protected function getHeaderActions(): array
{
return [
DeleteAction::make()
->disabled(fn (Role $role) => $role->isRootAdmin() || $role->users_count >= 1)
->label(fn (Role $role) => $role->isRootAdmin() ? 'Can\'t delete Root Admin' : ($role->users_count >= 1 ? 'In Use' : 'Delete')),
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use App\Models\Role;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction as CreateActionTable;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->sortable()
->searchable(),
TextColumn::make('guard_name')
->hidden()
->sortable()
->searchable(),
TextColumn::make('permissions_count')
->label('Permissions')
->badge()
->counts('permissions')
->formatStateUsing(fn (Role $role, $state) => $role->isRootAdmin() ? 'All' : $state),
TextColumn::make('users_count')
->label('Users')
->counts('users')
->icon('tabler-users'),
])
->actions([
EditAction::make(),
])
->checkIfRecordIsSelectableUsing(fn (Role $role) => !$role->isRootAdmin() && $role->users_count <= 0)
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete role')),
]),
])
->emptyStateIcon('tabler-users-group')
->emptyStateDescription('')
->emptyStateHeading('No Roles')
->emptyStateActions([
CreateActionTable::make('create')
->label('Create Role')
->button(),
]);
}
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Create Role'),
];
}
}

View File

@@ -40,8 +40,8 @@ class CreateServer extends CreateRecord
->icon('tabler-info-circle')
->completedIcon('tabler-check')
->columns([
'default' => 2,
'sm' => 2,
'default' => 1,
'sm' => 1,
'md' => 4,
'lg' => 6,
])
@@ -61,7 +61,7 @@ class CreateServer extends CreateRecord
}))
->columnSpan([
'default' => 2,
'sm' => 4,
'sm' => 3,
'md' => 2,
'lg' => 3,
])
@@ -75,13 +75,13 @@ class CreateServer extends CreateRecord
->label('Owner')
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 2,
'sm' => 3,
'md' => 3,
'lg' => 3,
])
->relationship('user', 'username')
->searchable(['user', 'username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->root_admin ? '(admin)' : ''))
->searchable(['username', 'email'])
->getOptionLabelFromRecordUsing(fn (User $user) => "$user->email | $user->username " . ($user->isRootAdmin() ? '(admin)' : ''))
->createOptionForm([
Forms\Components\TextInput::make('username')
->alphaNum()
@@ -98,21 +98,6 @@ class CreateServer extends CreateRecord
->hintIcon('tabler-question-mark')
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->inline()
->required()
->default(false)
->hidden(),
])
->createOptionUsing(function ($data) {
resolve(UserCreationService::class)->handle($data);
@@ -125,10 +110,10 @@ class CreateServer extends CreateRecord
->prefixIcon('tabler-server-2')
->default(fn () => ($this->node = Node::query()->latest()->first())?->id)
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 2,
'default' => 2,
'sm' => 3,
'md' => 6,
'lg' => 6,
])
->live()
->relationship('node', 'name')
@@ -146,10 +131,10 @@ class CreateServer extends CreateRecord
->prefixIcon('tabler-network')
->label('Primary Allocation')
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 1,
'lg' => 2,
'default' => 2,
'sm' => 3,
'md' => 2,
'lg' => 3,
])
->disabled(fn (Forms\Get $get) => $get('node_id') === null)
->searchable(['ip', 'port', 'ip_alias'])
@@ -233,7 +218,9 @@ class CreateServer extends CreateRecord
$end = min((int) $end, 2 ** 16 - 1);
$range = $start <= $end ? range($start, $end) : range($end, $start);
foreach ($range as $i) {
$ports->push($i);
if ($i > 1024 && $i <= 65535) {
$ports->push($i);
}
}
}
@@ -249,8 +236,6 @@ class CreateServer extends CreateRecord
$ports = $sortedPorts;
}
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) {
$set('allocation_ports', $ports->all());
}
@@ -268,10 +253,10 @@ class CreateServer extends CreateRecord
Forms\Components\Repeater::make('allocation_additional')
->label('Additional Allocations')
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 1,
'lg' => 2,
'default' => 2,
'sm' => 3,
'md' => 3,
'lg' => 3,
])
->addActionLabel('Add Allocation')
->disabled(fn (Forms\Get $get) => $get('allocation_id') === null)
@@ -303,12 +288,13 @@ class CreateServer extends CreateRecord
),
),
Forms\Components\TextInput::make('description')
Forms\Components\Textarea::make('description')
->placeholder('Description')
->rows(3)
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
'default' => 2,
'sm' => 6,
'md' => 6,
'lg' => 6,
])
->label('Notes'),
@@ -320,9 +306,9 @@ class CreateServer extends CreateRecord
->completedIcon('tabler-check')
->columns([
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 4,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->schema([
Forms\Components\Select::make('egg_id')
@@ -332,7 +318,7 @@ class CreateServer extends CreateRecord
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 3,
'lg' => 4,
])
->searchable()
->preload()
@@ -389,29 +375,51 @@ class CreateServer extends CreateRecord
->inline()
->required(),
Forms\Components\ToggleButtons::make('start_on_completion')
->label('Start Server After Install?')
->default(true)
->required()
->columnSpan([
'default' => 1,
'sm' => 1,
'md' => 1,
'lg' => 1,
])
->options([
true => 'Yes',
false => 'No',
])
->colors([
true => 'primary',
false => 'danger',
])
->icons([
true => 'tabler-code',
false => 'tabler-code-off',
])
->inline(),
Forms\Components\Textarea::make('startup')
->hintIcon('tabler-code')
->label('Startup Command')
->hidden(fn (Forms\Get $get) => $get('egg_id') === null)
->required()
->live()
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 4,
])
->rows(function ($state) {
return str($state)->explode("\n")->reduce(
fn (int $carry, $line) => $carry + floor(strlen($line) / 125),
1
);
}),
})
->columnSpan([
'default' => 1,
'sm' => 4,
'md' => 4,
'lg' => 6,
]),
Forms\Components\Hidden::make('environment')->default([]),
Forms\Components\Hidden::make('start_on_completion')->default(true),
Forms\Components\Section::make('Variables')
->icon('tabler-eggs')
->iconColor('primary')
@@ -442,8 +450,7 @@ class CreateServer extends CreateRecord
$text = Forms\Components\TextInput::make('variable_value')
->hidden($this->shouldHideComponent(...))
->maxLength(255)
->required(fn (Forms\Get $get) => in_array('required', explode('|', $get('rules'))))
->required(fn (Forms\Get $get) => in_array('required', $get('rules')))
->rules(
fn (Forms\Get $get): Closure => function (string $attribute, $value, Closure $fail) use ($get) {
$validator = Validator::make(['validatorkey' => $value], [
@@ -470,7 +477,7 @@ class CreateServer extends CreateRecord
->live(onBlur: true)
->hintIcon('tabler-code')
->label(fn (Forms\Get $get) => $get('name'))
->hintIconTooltip(fn (Forms\Get $get) => $get('rules'))
->hintIconTooltip(fn (Forms\Get $get) => implode('|', $get('rules')))
->prefix(fn (Forms\Get $get) => '{{' . $get('env_variable') . '}}')
->helperText(fn (Forms\Get $get) => empty($get('description')) ? '—' : $get('description'))
->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
@@ -491,12 +498,7 @@ class CreateServer extends CreateRecord
->completedIcon('tabler-check')
->schema([
Forms\Components\Fieldset::make('Resource Limits')
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->columnSpan(6)
->columns([
'default' => 1,
'sm' => 2,
@@ -676,12 +678,7 @@ class CreateServer extends CreateRecord
Forms\Components\Fieldset::make('Feature Limits')
->inlineLabel()
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->columnSpan(6)
->columns([
'default' => 1,
'sm' => 2,
@@ -712,18 +709,13 @@ class CreateServer extends CreateRecord
->default(0),
]),
Forms\Components\Fieldset::make('Docker Settings')
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->columns([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 3,
'lg' => 4,
])
->columnSpan(6)
->schema([
Forms\Components\Select::make('select_image')
->label('Image Name')
@@ -742,7 +734,12 @@ class CreateServer extends CreateRecord
return array_flip($images) + ['ghcr.io/custom-image' => 'Custom Image'];
})
->selectablePlaceholder(false)
->columnSpan(1),
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 2,
]),
Forms\Components\TextInput::make('image')
->label('Image')
@@ -758,13 +755,18 @@ class CreateServer extends CreateRecord
}
})
->placeholder('Enter a custom Image')
->columnSpan(2),
->columnSpan([
'default' => 1,
'sm' => 2,
'md' => 3,
'lg' => 2,
]),
Forms\Components\KeyValue::make('docker_labels')
->label('Container Labels')
->keyLabel('Title')
->valueLabel('Description')
->columnSpan(3),
->columnSpanFull(),
Forms\Components\CheckboxList::make('mounts')
->live()
@@ -812,7 +814,7 @@ class CreateServer extends CreateRecord
private function shouldHideComponent(Forms\Get $get, Forms\Components\Component $component): bool
{
$containsRuleIn = str($get('rules'))->explode('|')->reduce(
$containsRuleIn = collect($get('rules'))->reduce(
fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true
);
@@ -829,7 +831,7 @@ class CreateServer extends CreateRecord
private function getSelectOptionsFromRules(Forms\Get $get): array
{
$inRule = str($get('rules'))->explode('|')->reduce(
$inRule = collect($get('rules'))->reduce(
fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, ''
);

View File

@@ -7,6 +7,8 @@ use App\Services\Databases\DatabaseManagementService;
use App\Services\Databases\DatabasePasswordService;
use Filament\Forms\Components\Actions\Action;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Get;
use Filament\Forms\Set;
use LogicException;
use App\Filament\Resources\ServerResource;
use App\Http\Controllers\Admin\ServersController;
@@ -27,6 +29,7 @@ use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Validator;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
class EditServer extends EditRecord
@@ -36,22 +39,16 @@ class EditServer extends EditRecord
public function form(Form $form): Form
{
return $form
->columns([
'default' => 1,
'sm' => 2,
'md' => 2,
'lg' => 4,
])
->schema([
Tabs::make('Tabs')
->persistTabInQueryString()
->columnSpan(6)
->columns([
'default' => 2,
'sm' => 2,
'md' => 4,
'lg' => 6,
])
->columnSpanFull()
->tabs([
Tabs\Tab::make('Information')
->icon('tabler-info-circle')
@@ -124,7 +121,8 @@ class EditServer extends EditRecord
'md' => 2,
'lg' => 3,
])
->readOnly(),
->readOnly()
->dehydrated(false),
Forms\Components\TextInput::make('uuid_short')
->label('Short UUID')
->hintAction(CopyAction::make())
@@ -134,7 +132,8 @@ class EditServer extends EditRecord
'md' => 2,
'lg' => 3,
])
->readOnly(),
->readOnly()
->dehydrated(false),
Forms\Components\TextInput::make('external_id')
->label('External ID')
->columnSpan([
@@ -159,12 +158,6 @@ class EditServer extends EditRecord
->icon('tabler-brand-docker')
->schema([
Forms\Components\Fieldset::make('Resource Limits')
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->columns([
'default' => 1,
'sm' => 2,
@@ -340,12 +333,6 @@ class EditServer extends EditRecord
Forms\Components\Fieldset::make('Feature Limits')
->inlineLabel()
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->columns([
'default' => 1,
'sm' => 2,
@@ -370,12 +357,6 @@ class EditServer extends EditRecord
->numeric(),
]),
Forms\Components\Fieldset::make('Docker Settings')
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->columns([
'default' => 1,
'sm' => 2,
@@ -438,10 +419,10 @@ class EditServer extends EditRecord
->disabledOn('edit')
->prefixIcon('tabler-egg')
->columnSpan([
'default' => 1,
'default' => 6,
'sm' => 3,
'md' => 3,
'lg' => 5,
'lg' => 4,
])
->relationship('egg', 'name')
->searchable()
@@ -450,6 +431,12 @@ class EditServer extends EditRecord
Forms\Components\ToggleButtons::make('skip_scripts')
->label('Run Egg Install Script?')->inline()
->columnSpan([
'default' => 6,
'sm' => 1,
'md' => 1,
'lg' => 2,
])
->options([
false => 'Yes',
true => 'Skip',
@@ -467,12 +454,7 @@ class EditServer extends EditRecord
Forms\Components\Textarea::make('startup')
->label('Startup Command')
->required()
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 6,
])
->columnSpan(6)
->rows(function ($state) {
return str($state)->explode("\n")->reduce(
fn (int $carry, $line) => $carry + floor(strlen($line) / 125),
@@ -484,20 +466,29 @@ class EditServer extends EditRecord
->hintAction(CopyAction::make())
->label('Default Startup Command')
->disabled()
->formatStateUsing(function ($state, Forms\Get $get, Forms\Set $set) {
->formatStateUsing(function ($state, Get $get, Set $set) {
$egg = Egg::query()->find($get('egg_id'));
return $egg->startup;
})
->columnSpan([
'default' => 2,
'sm' => 4,
'md' => 4,
'lg' => 6,
]),
->columnSpan(6),
Forms\Components\Repeater::make('server_variables')
->relationship('serverVariables')
->relationship('serverVariables', function (Builder $query) {
/** @var Server $server */
$server = $this->getRecord();
foreach ($server->variables as $variable) {
ServerVariable::query()->firstOrCreate([
'server_id' => $server->id,
'variable_id' => $variable->id,
], [
'variable_value' => $variable->server_value ?? '',
]);
}
return $query;
})
->grid()
->mutateRelationshipDataBeforeSaveUsing(function (array &$data): array {
foreach ($data as $key => $value) {
@@ -513,7 +504,7 @@ class EditServer extends EditRecord
$text = Forms\Components\TextInput::make('variable_value')
->hidden($this->shouldHideComponent(...))
->required(fn (ServerVariable $serverVariable) => in_array('required', explode('|', $serverVariable->variable->rules)))
->required(fn (ServerVariable $serverVariable) => $serverVariable->variable->getRequiredAttribute())
->rules([
fn (ServerVariable $serverVariable): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) {
$validator = Validator::make(['validatorkey' => $value], [
@@ -540,7 +531,7 @@ class EditServer extends EditRecord
->live(onBlur: true)
->hintIcon('tabler-code')
->label(fn (ServerVariable $serverVariable) => $serverVariable->variable->name)
->hintIconTooltip(fn (ServerVariable $serverVariable) => $serverVariable->variable->rules)
->hintIconTooltip(fn (ServerVariable $serverVariable) => implode('|', $serverVariable->variable->rules))
->prefix(fn (ServerVariable $serverVariable) => '{{' . $serverVariable->variable->env_variable . '}}')
->helperText(fn (ServerVariable $serverVariable) => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description);
}
@@ -744,13 +735,16 @@ class EditServer extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make('Delete')
Actions\Action::make('Delete')
->successRedirectUrl(route('filament.admin.resources.servers.index'))
->color('danger')
->disabled(fn (Server $server) => $server->databases()->count() > 0)
->label(fn (Server $server) => $server->databases()->count() > 0 ? 'Server has a Database' : 'Delete')
->after(fn (Server $server) => resolve(ServerDeletionService::class)->handle($server))
->requiresConfirmation(),
->label('Delete')
->requiresConfirmation()
->action(function (Server $server) {
resolve(ServerDeletionService::class)->handle($server);
return redirect(ListServers::getUrl());
}),
Actions\Action::make('console')
->label('Console')
->icon('tabler-terminal')
@@ -782,28 +776,24 @@ class EditServer extends EditRecord
];
}
private function shouldHideComponent(Forms\Get $get, Forms\Components\Component $component): bool
private function shouldHideComponent(ServerVariable $serverVariable, Forms\Components\Component $component): bool
{
$containsRuleIn = str($get('rules'))->explode('|')->reduce(
fn ($result, $value) => $result === true && !str($value)->startsWith('in:'), true
);
$containsRuleIn = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:'), false);
if ($component instanceof Forms\Components\Select) {
return $containsRuleIn;
return !$containsRuleIn;
}
if ($component instanceof Forms\Components\TextInput) {
return !$containsRuleIn;
return $containsRuleIn;
}
throw new \Exception('Component type not supported: ' . $component::class);
}
private function getSelectOptionsFromRules(Forms\Get $get): array
private function getSelectOptionsFromRules(ServerVariable $serverVariable): array
{
$inRule = str($get('rules'))->explode('|')->reduce(
fn ($result, $value) => str($value)->startsWith('in:') ? $value : $result, ''
);
$inRule = array_first($serverVariable->variable->rules, fn ($value) => str($value)->startsWith('in:'));
return str($inRule)
->after('in:')

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Resources\ServerResource\Pages;
use App\Filament\Resources\ServerResource;
use App\Models\Server;
use App\Models\User;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\CreateAction;
@@ -76,7 +77,13 @@ class ListServers extends ListRecords
->actions([
Tables\Actions\Action::make('View')
->icon('tabler-terminal')
->url(fn (Server $server) => "/server/$server->uuid_short"),
->url(fn (Server $server) => "/server/$server->uuid_short")
->visible(function (Server $server) {
/** @var User $user */
$user = auth()->user();
return $user->isRootAdmin() || $user->id === $server->owner_id;
}),
Tables\Actions\EditAction::make(),
])
->emptyStateIcon('tabler-brand-docker')

View File

@@ -4,11 +4,15 @@ namespace App\Filament\Resources\ServerResource\RelationManagers;
use App\Models\Allocation;
use App\Models\Server;
use Filament\Forms;
use App\Services\Allocations\AssignmentService;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Set;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\HtmlString;
/**
* @method Server getOwnerRecord()
@@ -21,7 +25,7 @@ class AllocationsRelationManager extends RelationManager
{
return $form
->schema([
Forms\Components\TextInput::make('ip')
TextInput::make('ip')
->required()
->maxLength(255),
]);
@@ -62,9 +66,87 @@ class AllocationsRelationManager extends RelationManager
->label(fn (Allocation $allocation) => $allocation->id === $this->getOwnerRecord()->allocation_id ? '' : 'Make Primary'),
])
->headerActions([
//TODO Tables\Actions\CreateAction::make()->label('Create Allocation'),
Tables\Actions\CreateAction::make()->label('Create Allocation')
->createAnother(false)
->form(fn () => [
TextInput::make('allocation_ip')
->datalist($this->getOwnerRecord()->node->ipAddresses())
->label('IP Address')
->inlineLabel()
->ipv4()
->helperText("Usually your machine's public IP unless you are port forwarding.")
->required(),
TextInput::make('allocation_alias')
->label('Alias')
->inlineLabel()
->default(null)
->helperText('Optional display name to help you remember what these are.')
->required(false),
TagsInput::make('allocation_ports')
->placeholder('Examples: 27015, 27017-27019')
->helperText(new HtmlString('
These are the ports that users can connect to this Server through.
<br />
You would have to port forward these on your home network.
'))
->label('Ports')
->inlineLabel()
->live()
->afterStateUpdated(function ($state, Set $set) {
$ports = collect();
$update = false;
foreach ($state as $portEntry) {
if (!str_contains($portEntry, '-')) {
if (is_numeric($portEntry)) {
$ports->push((int) $portEntry);
continue;
}
// Do not add non numerical ports
$update = true;
continue;
}
$update = true;
[$start, $end] = explode('-', $portEntry);
if (!is_numeric($start) || !is_numeric($end)) {
continue;
}
$start = max((int) $start, 0);
$end = min((int) $end, 2 ** 16 - 1);
foreach (range($start, $end) as $i) {
$ports->push($i);
}
}
$uniquePorts = $ports->unique()->values();
if ($ports->count() > $uniquePorts->count()) {
$update = true;
$ports = $uniquePorts;
}
$sortedPorts = $ports->sort()->values();
if ($sortedPorts->all() !== $ports->all()) {
$update = true;
$ports = $sortedPorts;
}
$ports = $ports->filter(fn ($port) => $port > 1024 && $port < 65535)->values();
if ($update) {
$set('allocation_ports', $ports->all());
}
})
->splitKeys(['Tab', ' ', ','])
->required(),
])
->action(fn (array $data) => resolve(AssignmentService::class)->handle($this->getOwnerRecord()->node, $data, $this->getOwnerRecord())),
Tables\Actions\AssociateAction::make()
->multiple()
->associateAnother(false)
->preloadRecordSelect()
->recordSelectOptionsQuery(fn ($query) => $query->whereBelongsTo($this->getOwnerRecord()->node))
->label('Add Allocation'),

View File

@@ -53,6 +53,7 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->label(trans('strings.username'))
->disabled()
->readOnly()
->dehydrated(false)
->maxLength(255)
->unique(ignoreRecord: true)
->autofocus(),
@@ -119,6 +120,7 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->hidden(fn () => !cache()->get("users.{$this->getUser()->id}.2fa.tokens"))
->rows(10)
->readOnly()
->dehydrated(false)
->formatStateUsing(fn () => cache()->get("users.{$this->getUser()->id}.2fa.tokens"))
->helperText('These will not be shown again!')
->label('Backup Tokens:'),
@@ -215,7 +217,7 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
Action::make('Create')
->disabled(fn (Get $get) => $get('description') === null)
->successRedirectUrl(route('filament.admin.auth.profile', ['tab' => '-api-keys-tab']))
->action(function (Get $get, Action $action, $user) {
->action(function (Get $get, Action $action, User $user) {
$token = $user->createToken(
$get('description'),
$get('allowed_ips'),

View File

@@ -3,13 +3,16 @@
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Services\Exceptions\FilamentExceptionHandler;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use App\Models\Role;
use App\Models\User;
use Filament\Forms;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Hash;
class EditUser extends EditRecord
@@ -20,54 +23,33 @@ class EditUser extends EditRecord
return $form
->schema([
Section::make()->schema([
Forms\Components\TextInput::make('username')->required()->maxLength(255),
Forms\Components\TextInput::make('email')->email()->required()->maxLength(255),
Forms\Components\TextInput::make('password')
TextInput::make('username')->required()->maxLength(255),
TextInput::make('email')->email()->required()->maxLength(255),
TextInput::make('password')
->dehydrateStateUsing(fn (string $state): string => Hash::make($state))
->dehydrated(fn (?string $state): bool => filled($state))
->required(fn (string $operation): bool => $operation === 'create')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->disableOptionWhen(function (string $operation, $value, User $user) {
if ($operation !== 'edit' || $value) {
return false;
}
return $user->isLastRootAdmin();
})
->hint(fn (User $user) => $user->isLastRootAdmin() ? 'This is the last root administrator!' : '')
->helperText(fn (User $user) => $user->isLastRootAdmin() ? 'You must have at least one root administrator in your system.' : '')
->hintColor('warning')
->inline()
->required()
->default(false),
Forms\Components\Hidden::make('skipValidation')->default(true),
Forms\Components\Select::make('language')
Select::make('language')
->required()
->hidden()
->default('en')
->options(fn (User $user) => $user->getAvailableLanguages()),
Hidden::make('skipValidation')->default(true),
CheckboxList::make('roles')
->disabled(fn (User $user) => $user->id === auth()->user()->id)
->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->relationship('roles', 'name')
->label('Admin Roles')
->columnSpanFull()
->bulkToggleable(false),
])->columns(),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
DeleteAction::make()
->label(fn (User $user) => auth()->user()->id === $user->id ? 'Can\'t Delete Yourself' : ($user->servers()->count() > 0 ? 'User Has Servers' : 'Delete'))
->disabled(fn (User $user) => auth()->user()->id === $user->id || $user->servers()->count() > 0),
$this->getSaveFormAction()->formId('form'),
@@ -78,9 +60,4 @@ class EditUser extends EditRecord
{
return [];
}
public function exception($exception, $stopPropagation): void
{
(new FilamentExceptionHandler())->handle($exception, $stopPropagation);
}
}

View File

@@ -3,14 +3,22 @@
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Models\Role;
use App\Models\User;
use App\Services\Users\UserCreationService;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Tables;
use Filament\Forms;
class ListUsers extends ListRecords
{
@@ -21,101 +29,102 @@ class ListUsers extends ListRecords
return $table
->searchable(false)
->columns([
Tables\Columns\ImageColumn::make('picture')
ImageColumn::make('picture')
->visibleFrom('lg')
->label('')
->extraImgAttributes(['class' => 'rounded-full'])
->defaultImageUrl(fn (User $user) => 'https://gravatar.com/avatar/' . md5(strtolower($user->email))),
Tables\Columns\TextColumn::make('external_id')
TextColumn::make('external_id')
->searchable()
->hidden(),
Tables\Columns\TextColumn::make('uuid')
TextColumn::make('uuid')
->label('UUID')
->hidden()
->searchable(),
Tables\Columns\TextColumn::make('username')
TextColumn::make('username')
->searchable(),
Tables\Columns\TextColumn::make('email')
TextColumn::make('email')
->searchable()
->icon('tabler-mail'),
Tables\Columns\IconColumn::make('root_admin')
->visibleFrom('md')
->label('Admin')
->boolean()
->trueIcon('tabler-star-filled')
->falseIcon('tabler-star-off')
->sortable(),
Tables\Columns\IconColumn::make('use_totp')->label('2FA')
IconColumn::make('use_totp')
->label('2FA')
->visibleFrom('lg')
->icon(fn (User $user) => $user->use_totp ? 'tabler-lock' : 'tabler-lock-open-off')
->boolean()->sortable(),
Tables\Columns\TextColumn::make('servers_count')
TextColumn::make('roles_count')
->counts('roles')
->icon('tabler-users-group')
->label('Roles')
->formatStateUsing(fn (User $user, $state) => $state . ($user->isRootAdmin() ? ' (Root Admin)' : '')),
TextColumn::make('servers_count')
->counts('servers')
->icon('tabler-server')
->label('Servers'),
Tables\Columns\TextColumn::make('subusers_count')
TextColumn::make('subusers_count')
->visibleFrom('sm')
->label('Subusers')
->counts('subusers')
->icon('tabler-users'),
// ->formatStateUsing(fn (string $state, $record): string => (string) ($record->servers_count + $record->subusers_count))
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
EditAction::make(),
])
->checkIfRecordIsSelectableUsing(fn (User $user) => auth()->user()->id !== $user->id && !$user->servers_count)
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make()
->authorize(fn () => auth()->user()->can('delete user')),
]),
]);
}
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make('create')
CreateAction::make('create')
->label('Create User')
->createAnother(false)
->form([
Forms\Components\Grid::make()
Grid::make()
->schema([
Forms\Components\TextInput::make('username')
TextInput::make('username')
->alphaNum()
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
TextInput::make('email')
->email()
->required()
->unique()
->maxLength(255),
Forms\Components\TextInput::make('password')
TextInput::make('password')
->hintIcon('tabler-question-mark')
->hintIconTooltip('Providing a user password is optional. New user email will prompt users to create a password the first time they login.')
->password(),
Forms\Components\ToggleButtons::make('root_admin')
->label('Administrator (Root)')
->options([
false => 'No',
true => 'Admin',
])
->colors([
false => 'primary',
true => 'danger',
])
->inline()
->required()
->default(false),
CheckboxList::make('roles')
->disableOptionWhen(fn (string $value): bool => $value == Role::getRootAdmin()->id)
->relationship('roles', 'name')
->dehydrated()
->label('Admin Roles')
->columnSpanFull()
->bulkToggleable(false),
]),
])
->successRedirectUrl(route('filament.admin.resources.users.index'))
->action(function (array $data) {
resolve(UserCreationService::class)->handle($data);
Notification::make()->title('User Created!')->success()->send();
$roles = $data['roles'];
$roles = collect($roles)->map(fn ($role) => Role::findById($role));
unset($data['roles']);
/** @var UserCreationService $creationService */
$creationService = resolve(UserCreationService::class);
$user = $creationService->handle($data);
$user->syncRoles($roles);
Notification::make()
->title('User Created!')
->success()
->send();
return redirect()->route('filament.admin.resources.users.index');
}),

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Models\Setting;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Illuminate\Contracts\Console\Kernel;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Settings\AdvancedSettingsFormRequest;
class AdvancedController extends Controller
{
/**
* AdvancedController constructor.
*/
public function __construct(
private AlertsMessageBag $alert,
private Kernel $kernel,
) {
}
/**
* Render advanced Panel settings UI.
*/
public function index(): View
{
$showRecaptchaWarning = false;
if (
config('recaptcha._shipped_secret_key') === config('recaptcha.secret_key')
|| config('recaptcha._shipped_website_key') === config('recaptcha.website_key')
) {
$showRecaptchaWarning = true;
}
return view('admin.settings.advanced', [
'showRecaptchaWarning' => $showRecaptchaWarning,
]);
}
/**
* @throws \App\Exceptions\Model\DataValidationException
*/
public function update(AdvancedSettingsFormRequest $request): RedirectResponse
{
foreach ($request->normalize() as $key => $value) {
Setting::set('settings::' . $key, $value);
}
$this->kernel->call('queue:restart');
$this->alert->success('Advanced settings have been updated successfully and the queue worker was restarted to apply these changes.')->flash();
return redirect()->route('admin.settings.advanced');
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Models\Setting;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Illuminate\Contracts\Console\Kernel;
use App\Http\Controllers\Controller;
use App\Traits\Helpers\AvailableLanguages;
use App\Services\Helpers\SoftwareVersionService;
use App\Http\Requests\Admin\Settings\BaseSettingsFormRequest;
class IndexController extends Controller
{
use AvailableLanguages;
/**
* IndexController constructor.
*/
public function __construct(
private AlertsMessageBag $alert,
private Kernel $kernel,
private SoftwareVersionService $versionService,
) {
}
/**
* Render the UI for basic Panel settings.
*/
public function index(): View
{
return view('admin.settings.index', [
'version' => $this->versionService,
'languages' => $this->getAvailableLanguages(),
]);
}
/**
* Handle settings update.
*
* @throws \App\Exceptions\Model\DataValidationException
*/
public function update(BaseSettingsFormRequest $request): RedirectResponse
{
foreach ($request->normalize() as $key => $value) {
Setting::set('settings::' . $key, $value);
}
$this->kernel->call('queue:restart');
$this->alert->success('Panel settings have been updated successfully and the queue worker was restarted to apply these changes.')->flash();
return redirect()->route('admin.settings');
}
}

View File

@@ -1,82 +0,0 @@
<?php
namespace App\Http\Controllers\Admin\Settings;
use App\Models\Setting;
use Illuminate\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Contracts\Console\Kernel;
use App\Notifications\MailTested;
use Illuminate\Support\Facades\Notification;
use App\Exceptions\DisplayException;
use App\Http\Controllers\Controller;
use App\Providers\SettingsServiceProvider;
use App\Http\Requests\Admin\Settings\MailSettingsFormRequest;
class MailController extends Controller
{
/**
* MailController constructor.
*/
public function __construct(
private Kernel $kernel,
) {
}
/**
* Render UI for editing mail settings. This UI should only display if
* the server is configured to send mail using SMTP.
*/
public function index(): View
{
return view('admin.settings.mail', [
'disabled' => config('mail.default') !== 'smtp',
]);
}
/**
* Handle request to update SMTP mail settings.
*
* @throws DisplayException
* @throws \App\Exceptions\Model\DataValidationException
*/
public function update(MailSettingsFormRequest $request): Response
{
if (config('mail.default') !== 'smtp') {
throw new DisplayException('This feature is only available if SMTP is the selected email driver for the Panel.');
}
$values = $request->normalize();
if (array_get($values, 'mail:mailers:smtp:password') === '!e') {
$values['mail:mailers:smtp:password'] = '';
}
foreach ($values as $key => $value) {
if (in_array($key, SettingsServiceProvider::getEncryptedKeys()) && !empty($value)) {
$value = encrypt($value);
}
Setting::set('settings::' . $key, $value);
}
$this->kernel->call('queue:restart');
return response('', 204);
}
/**
* Submit a request to send a test mail message.
*/
public function test(Request $request): Response
{
try {
Notification::route('mail', $request->user()->email)
->notify(new MailTested($request->user()));
} catch (\Exception $exception) {
return response($exception->getMessage(), 500);
}
return response('', 204);
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Api\Application\Roles;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use App\Models\Role;
use Spatie\QueryBuilder\QueryBuilder;
use App\Transformers\Api\Application\RoleTransformer;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Roles\GetRoleRequest;
use App\Http\Requests\Api\Application\Roles\StoreRoleRequest;
use App\Http\Requests\Api\Application\Roles\DeleteRoleRequest;
use App\Http\Requests\Api\Application\Roles\UpdateRoleRequest;
class RoleController extends ApplicationApiController
{
/**
* Return all the roles currently registered on the Panel.
*/
public function index(GetRoleRequest $request): array
{
$roles = QueryBuilder::for(Role::query())
->allowedFilters(['name'])
->allowedSorts(['name'])
->paginate($request->query('per_page') ?? 10);
return $this->fractal->collection($roles)
->transformWith($this->getTransformer(RoleTransformer::class))
->toArray();
}
/**
* Return a single role.
*/
public function view(GetRoleRequest $request, Role $role): array
{
return $this->fractal->item($role)
->transformWith($this->getTransformer(RoleTransformer::class))
->toArray();
}
/**
* Store a new role on the Panel and return an HTTP/201 response code with the
* new role attached.
*
* @throws \Throwable
*/
public function store(StoreRoleRequest $request): JsonResponse
{
$role = Role::create($request->validated());
return $this->fractal->item($role)
->transformWith($this->getTransformer(RoleTransformer::class))
->addMeta([
'resource' => route('api.application.roles.view', [
'role' => $role->id,
]),
])
->respond(201);
}
/**
* Update a role on the Panel and return the updated record to the user.
*
* @throws \Throwable
*/
public function update(UpdateRoleRequest $request, Role $role): array
{
$role->update($request->validated());
return $this->fractal->item($role)
->transformWith($this->getTransformer(RoleTransformer::class))
->toArray();
}
/**
* Delete a role from the Panel.
*
* @throws \Exception
*/
public function delete(DeleteRoleRequest $request, Role $role): Response
{
$role->delete();
return $this->returnNoContent();
}
}

View File

@@ -13,6 +13,7 @@ use App\Http\Requests\Api\Application\Users\StoreUserRequest;
use App\Http\Requests\Api\Application\Users\DeleteUserRequest;
use App\Http\Requests\Api\Application\Users\UpdateUserRequest;
use App\Http\Controllers\Api\Application\ApplicationApiController;
use App\Http\Requests\Api\Application\Users\AssignUserRolesRequest;
class UserController extends ApplicationApiController
{
@@ -75,6 +76,19 @@ class UserController extends ApplicationApiController
return $response->toArray();
}
/**
* Assign roles to a user.
*/
public function roles(AssignUserRolesRequest $request, User $user): array
{
$user->syncRoles($request->input('roles'));
$response = $this->fractal->item($user)
->transformWith($this->getTransformer(UserTransformer::class));
return $response->toArray();
}
/**
* Store a new user on the system. Returns the created user and an HTTP/201
* header on successful creation.

View File

@@ -48,7 +48,7 @@ class ClientController extends ClientApiController
if (in_array($type, ['admin', 'admin-all'])) {
// If they aren't an admin but want all the admin servers don't fail the request, just
// make it a query that will never return any results back.
if (!$user->root_admin) {
if (!$user->isRootAdmin()) {
$builder->whereRaw('1 = 2');
} else {
$builder = $type === 'admin-all'

View File

@@ -13,6 +13,7 @@ use Illuminate\Database\Query\JoinClause;
use App\Http\Requests\Api\Client\ClientApiRequest;
use App\Transformers\Api\Client\ActivityLogTransformer;
use App\Http\Controllers\Api\Client\ClientApiController;
use App\Models\Role;
class ActivityLogController extends ClientApiController
{
@@ -32,15 +33,16 @@ class ActivityLogController extends ClientApiController
// We could do this with a query and a lot of joins, but that gets pretty
// painful so for now we'll execute a simpler query.
$subusers = $server->subusers()->pluck('user_id')->merge([$server->owner_id]);
$rootAdmins = Role::getRootAdmin()->users()->pluck('id');
$builder->select('activity_logs.*')
->leftJoin('users', function (JoinClause $join) {
$join->on('users.id', 'activity_logs.actor_id')
->where('activity_logs.actor_type', (new User())->getMorphClass());
})
->where(function (Builder $builder) use ($subusers) {
->where(function (Builder $builder) use ($subusers, $rootAdmins) {
$builder->whereNull('users.id')
->orWhere('users.root_admin', 0)
->orWhereNotIn('users.id', $rootAdmins)
->orWhereIn('users.id', $subusers);
});
})

View File

@@ -140,7 +140,7 @@ class SftpAuthenticationController extends Controller
*/
protected function validateSftpAccess(User $user, Server $server): void
{
if (!$user->root_admin && $server->owner_id !== $user->id) {
if (!$user->isRootAdmin() && $server->owner_id !== $user->id) {
$permissions = $this->permissions->handle($server, $user);
if (!in_array(Permission::ACTION_FILE_SFTP, $permissions)) {

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Auth;
use App\Filament\Pages\Installer\PanelInstaller;
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
@@ -17,8 +18,12 @@ class LoginController extends AbstractLoginController
* base authentication view component. React will take over at this point and
* turn the login area into an SPA.
*/
public function index(): View
public function index()
{
if (PanelInstaller::show()) {
return redirect('/installer');
}
return view('templates/auth.core');
}

View File

@@ -14,7 +14,7 @@ class AdminAuthenticate
*/
public function handle(Request $request, \Closure $next): mixed
{
if (!$request->user() || !$request->user()->root_admin) {
if (!$request->user() || !$request->user()->isRootAdmin()) {
throw new AccessDeniedHttpException();
}

View File

@@ -15,7 +15,7 @@ class AuthenticateApplicationUser
{
/** @var \App\Models\User|null $user */
$user = $request->user();
if (!$user || !$user->root_admin) {
if (!$user || !$user->isRootAdmin()) {
throw new AccessDeniedHttpException('This account does not have permission to access the API.');
}

View File

@@ -39,7 +39,7 @@ class AuthenticateServerAccess
// At the very least, ensure that the user trying to make this request is the
// server owner, a subuser, or a root admin. We'll leave it up to the controllers
// to authenticate more detailed permissions if needed.
if ($user->id !== $server->owner_id && !$user->root_admin) {
if ($user->id !== $server->owner_id && !$user->isRootAdmin()) {
// Check for subuser status.
if (!$server->subusers->contains('user_id', $user->id)) {
throw new NotFoundHttpException(trans('exceptions.api.resource_not_found'));
@@ -55,7 +55,7 @@ class AuthenticateServerAccess
if (($server->isSuspended() || $server->node->isUnderMaintenance()) && !$request->routeIs('api:client:server.resources')) {
throw $exception;
}
if (!$user->root_admin || !$request->routeIs($this->except)) {
if (!$user->isRootAdmin() || !$request->routeIs($this->except)) {
throw $exception;
}
}

View File

@@ -51,7 +51,7 @@ class RequireTwoFactorAuthentication
// If the level is set as admin and the user is not an admin, pass them through as well.
if ($level === self::LEVEL_NONE || $user->use_totp) {
return $next($request);
} elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) {
} elseif ($level === self::LEVEL_ADMIN && !$user->isRootAdmin()) {
return $next($request);
}

View File

@@ -21,7 +21,7 @@ abstract class AdminFormRequest extends FormRequest
return false;
}
return (bool) $this->user()->root_admin;
return $this->user()->isRootAdmin();
}
/**

View File

@@ -22,7 +22,6 @@ class NewUserFormRequest extends AdminFormRequest
'name_last',
'password',
'language',
'root_admin',
])->toArray();
}
}

View File

@@ -22,7 +22,6 @@ class UserFormRequest extends AdminFormRequest
'name_last',
'password',
'language',
'root_admin',
])->toArray();
}
}

View File

@@ -20,6 +20,7 @@ class StoreNodeRequest extends ApplicationApiRequest
return collect($rules ?? Node::getRules())->only([
'public',
'name',
'description',
'fqdn',
'scheme',
'behind_proxy',

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class DeleteRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::WRITE;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class GetRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::READ;
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
use App\Services\Acl\Api\AdminAcl;
use App\Http\Requests\Api\Application\ApplicationApiRequest;
class StoreRoleRequest extends ApplicationApiRequest
{
protected ?string $resource = AdminAcl::RESOURCE_ROLES;
protected int $permission = AdminAcl::WRITE;
public function rules(array $rules = null): array
{
return [
'name' => 'required|string',
'guard_name' => 'nullable|string',
];
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Http\Requests\Api\Application\Roles;
class UpdateRoleRequest extends StoreRoleRequest
{
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\Api\Application\Users;
class AssignUserRolesRequest extends StoreUserRequest
{
/**
* Return the validation rules for this request.
*/
public function rules(array $rules = null): array
{
return [
'roles' => 'array',
'roles.*' => 'string',
];
}
}

View File

@@ -26,7 +26,6 @@ class StoreUserRequest extends ApplicationApiRequest
'password',
'language',
'timezone',
'root_admin',
])->toArray();
$response['first_name'] = $rules['name_first'];
@@ -56,7 +55,6 @@ class StoreUserRequest extends ApplicationApiRequest
'external_id' => 'Third Party Identifier',
'name_first' => 'First Name',
'name_last' => 'Last Name',
'root_admin' => 'Root Administrator Status',
];
}
}

View File

@@ -19,7 +19,7 @@ class StoreTaskRequest extends ViewScheduleRequest
public function rules(): array
{
return [
'action' => 'required|in:command,power,backup',
'action' => 'required|in:command,power,backup,delete_files',
'payload' => 'required_unless:action,backup|string|nullable',
'time_offset' => 'required|numeric|min:0|max:900',
'sequence_id' => 'sometimes|required|numeric|min:1',

View File

@@ -56,7 +56,7 @@ abstract class SubuserRequest extends ClientApiRequest
$server = $this->route()->parameter('server');
// If we are a root admin or the server owner, no need to perform these checks.
if ($user->root_admin || $user->id === $server->owner_id) {
if ($user->isRootAdmin() || $user->id === $server->owner_id) {
return;
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Jobs;
use App\Models\Node;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class NodeStatistics implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct()
{
//
}
/**
* Execute the job.
*/
public function handle(): void
{
foreach (Node::all() as $node) {
$stats = $node->statistics();
$timestamp = now()->getTimestamp();
foreach ($stats as $key => $value) {
$cacheKey = "nodes.{$node->id}.$key";
$data = cache()->get($cacheKey, []);
// Add current timestamp and value to the data array
$data[$timestamp] = $value;
// Update the cache with the new data, expires in 1 minute
cache()->put($cacheKey, $data, now()->addMinute());
}
}
}
}

View File

@@ -12,6 +12,7 @@ use Illuminate\Foundation\Bus\DispatchesJobs;
use App\Services\Backups\InitiateBackupService;
use App\Repositories\Daemon\DaemonPowerRepository;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Services\Files\DeleteFilesService;
class RunTaskJob extends Job implements ShouldQueue
{
@@ -34,7 +35,8 @@ class RunTaskJob extends Job implements ShouldQueue
*/
public function handle(
InitiateBackupService $backupService,
DaemonPowerRepository $powerRepository
DaemonPowerRepository $powerRepository,
DeleteFilesService $deleteFilesService
): void {
// Do not process a task that is not set to active, unless it's been manually triggered.
if (!$this->task->schedule->is_active && !$this->manualRun) {
@@ -67,6 +69,9 @@ class RunTaskJob extends Job implements ShouldQueue
case Task::ACTION_BACKUP:
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null, true);
break;
case Task::ACTION_DELETE_FILES:
$deleteFilesService->handle($server, explode(PHP_EOL, $this->task->payload));
break;
default:
throw new \InvalidArgumentException('Invalid task action provided: ' . $this->task->action);
}

View File

@@ -5,7 +5,6 @@ namespace App\Listeners\Auth;
use App\Facades\Activity;
use Illuminate\Auth\Events\Failed;
use App\Events\Auth\DirectLogin;
use Illuminate\Events\Dispatcher;
class AuthenticationListener
{
@@ -28,10 +27,4 @@ class AuthenticationListener
$activity->event($event instanceof Failed ? 'auth:fail' : 'auth:success')->log();
}
public function subscribe(Dispatcher $events): void
{
$events->listen(Failed::class, self::class);
$events->listen(DirectLogin::class, self::class);
}
}

View File

@@ -140,6 +140,10 @@ class ActivityLog extends Model
{
parent::boot();
static::creating(function (self $model) {
$model->timestamp = Carbon::now();
});
static::created(function (self $model) {
Event::dispatch(new ActivityLogged($model));
});

View File

@@ -17,7 +17,7 @@ use Illuminate\Support\Str;
* @property array|null $features
* @property string $docker_image -- deprecated, use $docker_images
* @property array<string, string> $docker_images
* @property string $update_url
* @property string|null $update_url
* @property bool $force_outgoing_ip
* @property array|null $file_denylist
* @property string|null $config_files
@@ -95,6 +95,7 @@ class Egg extends Model
'config_stop',
'config_from',
'startup',
'update_url',
'script_is_privileged',
'script_install',
'script_entry',

View File

@@ -15,7 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $default_value
* @property bool $user_viewable
* @property bool $user_editable
* @property string $rules
* @property array $rules
* @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at
* @property bool $required
@@ -58,12 +58,14 @@ class EggVariable extends Model
'default_value' => 'string',
'user_viewable' => 'boolean',
'user_editable' => 'boolean',
'rules' => 'string',
'rules' => 'array',
'rules.*' => 'string',
];
protected $attributes = [
'user_editable' => 0,
'user_viewable' => 0,
'rules' => '[]',
];
protected function casts(): array
@@ -72,6 +74,7 @@ class EggVariable extends Model
'egg_id' => 'integer',
'user_viewable' => 'bool',
'user_editable' => 'bool',
'rules' => 'array',
'created_at' => 'immutable_datetime',
'updated_at' => 'immutable_datetime',
];
@@ -79,7 +82,7 @@ class EggVariable extends Model
public function getRequiredAttribute(): bool
{
return in_array('required', explode('|', $this->rules));
return in_array('required', $this->rules);
}
public function egg(): HasOne

View File

@@ -244,21 +244,21 @@ class Node extends Model
*/
public function isViable(int $memory, int $disk, int $cpu): bool
{
if ($this->memory_overallocate >= 0) {
if ($this->memory > 0 && $this->memory_overallocate >= 0) {
$memoryLimit = $this->memory * (1 + ($this->memory_overallocate / 100));
if ($this->servers_sum_memory + $memory > $memoryLimit) {
return false;
}
}
if ($this->disk_overallocate >= 0) {
if ($this->disk > 0 && $this->disk_overallocate >= 0) {
$diskLimit = $this->disk * (1 + ($this->disk_overallocate / 100));
if ($this->servers_sum_disk + $disk > $diskLimit) {
return false;
}
}
if ($this->cpu_overallocate >= 0) {
if ($this->cpu > 0 && $this->cpu_overallocate >= 0) {
$cpuLimit = $this->cpu * (1 + ($this->cpu_overallocate / 100));
if ($this->servers_sum_cpu + $cpu > $cpuLimit) {
return false;

48
app/Models/Role.php Normal file
View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Spatie\Permission\Models\Role as BaseRole;
/**
* @property int $id
* @property string $name
* @property string $guard_name
* @property \Illuminate\Database\Eloquent\Collection|\Spatie\Permission\Models\Permission[] $permissions
* @property int|null $permissions_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\User[] $users
* @property int|null $users_count
*/
class Role extends BaseRole
{
public const RESOURCE_NAME = 'role';
public const ROOT_ADMIN = 'Root Admin';
public const MODEL_SPECIFIC_PERMISSIONS = [
'egg' => [
'import',
'export',
],
];
public const SPECIAL_PERMISSIONS = [
'settings' => [
'view',
'update',
],
];
public function isRootAdmin(): bool
{
return $this->name === self::ROOT_ADMIN;
}
public static function getRootAdmin(): self
{
/** @var self $role */
$role = self::findOrCreate(self::ROOT_ADMIN);
return $role;
}
}

View File

@@ -24,62 +24,4 @@ class Setting extends Model
'key' => 'required|string|between:1,255',
'value' => 'string',
];
private static array $cache = [];
private static array $databaseMiss = [];
/**
* Store a new persistent setting in the database.
*/
public static function set(string $key, string $value = null): void
{
// Clear item from the cache.
self::clearCache($key);
self::query()->updateOrCreate(['key' => $key], ['value' => $value ?? '']);
self::$cache[$key] = $value;
}
/**
* Retrieve a persistent setting from the database.
*/
public static function get(string $key, mixed $default = null): mixed
{
// If item has already been requested return it from the cache. If
// we already know it is missing, immediately return the default value.
if (array_key_exists($key, self::$cache)) {
return self::$cache[$key];
} elseif (array_key_exists($key, self::$databaseMiss)) {
return value($default);
}
$instance = self::query()->where('key', $key)->first();
if (is_null($instance)) {
self::$databaseMiss[$key] = true;
return value($default);
}
return self::$cache[$key] = $instance->value;
}
/**
* Remove a key from the database cache.
*/
public static function forget(string $key)
{
self::clearCache($key);
return self::query()->where('key', $key)->delete();
}
/**
* Remove a key from the cache.
*/
private static function clearCache(string $key): void
{
unset(self::$cache[$key], self::$databaseMiss[$key]);
}
}

View File

@@ -33,6 +33,7 @@ class Task extends Model
public const ACTION_POWER = 'power';
public const ACTION_COMMAND = 'command';
public const ACTION_BACKUP = 'backup';
public const ACTION_DELETE_FILES = 'delete_files';
/**
* The table associated with the model.

View File

@@ -25,6 +25,9 @@ use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use App\Notifications\SendPasswordReset as ResetPasswordNotification;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Model as IlluminateModel;
use Spatie\Permission\Traits\HasRoles;
/**
* App\Models\User.
@@ -40,7 +43,6 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property string|null $remember_token
* @property string $language
* @property string $timezone
* @property bool $root_admin
* @property bool $use_totp
* @property string|null $totp_secret
* @property \Illuminate\Support\Carbon|null $totp_authenticated_at
@@ -77,7 +79,6 @@ use App\Notifications\SendPasswordReset as ResetPasswordNotification;
* @method static Builder|User whereNameLast($value)
* @method static Builder|User wherePassword($value)
* @method static Builder|User whereRememberToken($value)
* @method static Builder|User whereRootAdmin($value)
* @method static Builder|User whereTotpAuthenticatedAt($value)
* @method static Builder|User whereTotpSecret($value)
* @method static Builder|User whereUpdatedAt($value)
@@ -94,6 +95,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
use AvailableLanguages;
use CanResetPassword;
use HasAccessTokens;
use HasRoles;
use Notifiable;
public const USER_LEVEL_USER = 0;
@@ -131,7 +133,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'totp_secret',
'totp_authenticated_at',
'gravatar',
'root_admin',
'oauth',
];
@@ -145,7 +146,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/
protected $attributes = [
'external_id' => null,
'root_admin' => false,
'language' => 'en',
'timezone' => 'UTC',
'use_totp' => false,
@@ -166,7 +166,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'name_first' => 'nullable|string|between:0,255',
'name_last' => 'nullable|string|between:0,255',
'password' => 'sometimes|nullable|string',
'root_admin' => 'boolean',
'language' => 'string',
'timezone' => 'string',
'use_totp' => 'boolean',
@@ -177,7 +176,6 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function casts(): array
{
return [
'root_admin' => 'boolean',
'use_totp' => 'boolean',
'gravatar' => 'boolean',
'totp_authenticated_at' => 'datetime',
@@ -226,7 +224,10 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
*/
public function toReactObject(): array
{
return collect($this->toArray())->except(['id', 'external_id'])->toArray();
return array_merge(collect($this->toArray())->except(['id', 'external_id'])->toArray(), [
'root_admin' => $this->isRootAdmin(),
'admin' => $this->canAccessPanel(Filament::getPanel('admin')),
]);
}
/**
@@ -315,7 +316,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
protected function checkPermission(Server $server, string $permission = ''): bool
{
if ($this->root_admin || $server->owner_id === $this->id) {
if ($this->isRootAdmin() || $server->owner_id === $this->id) {
return true;
}
@@ -351,14 +352,23 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
public function isLastRootAdmin(): bool
{
$rootAdmins = User::query()->where('root_admin', true)->limit(2)->get();
$rootAdmins = User::all()->filter(fn ($user) => $user->isRootAdmin());
return once(fn () => $rootAdmins->count() === 1 && $rootAdmins->first()->is($this));
}
public function isRootAdmin(): bool
{
return $this->hasRole(Role::ROOT_ADMIN);
}
public function canAccessPanel(Panel $panel): bool
{
return $this->root_admin;
if ($this->isRootAdmin()) {
return true;
}
return $this->roles()->count() >= 1 && $this->getAllPermissions()->count() >= 1;
}
public function getFilamentName(): string
@@ -370,4 +380,13 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
{
return 'https://gravatar.com/avatar/' . md5(strtolower($this->email));
}
public function canTarget(IlluminateModel $user): bool
{
if ($this->isRootAdmin()) {
return true;
}
return $user instanceof User && !$user->isRootAdmin();
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class ApiKeyPolicy
{
use DefaultPolicies;
protected string $modelName = 'apikey';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class DatabaseHostPolicy
{
use DefaultPolicies;
protected string $modelName = 'databasehost';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class DatabasePolicy
{
use DefaultPolicies;
protected string $modelName = 'database';
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
trait DefaultPolicies
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->can('viewList ' . $this->modelName);
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Model $model): bool
{
return $user->can('view ' . $this->modelName, $model);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->can('create ' . $this->modelName);
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Model $model): bool
{
return $user->can('update ' . $this->modelName, $model);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Model $model): bool
{
return $user->can('delete ' . $this->modelName, $model);
}
}

View File

@@ -2,12 +2,9 @@
namespace App\Policies;
use App\Models\User;
class EggPolicy
{
public function create(User $user): bool
{
return true;
}
use DefaultPolicies;
protected string $modelName = 'egg';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class MountPolicy
{
use DefaultPolicies;
protected string $modelName = 'mount';
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Policies;
class NodePolicy
{
use DefaultPolicies;
protected string $modelName = 'node';
}

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