Compare commits

..

No commits in common. "main" and "v1.0.0-beta19" have entirely different histories.

1572 changed files with 19667 additions and 53976 deletions

View File

@ -64,9 +64,10 @@ body:
label: Error Logs
description: |
Run the following command to collect logs on your system.
Wings: `sudo wings diagnostics --hastebin-url=https://logs.pelican.dev`
Panel: `tail -n 300 /var/www/pelican/storage/logs/laravel-$(date +%F).log | curl --data-binary @- https://logs.pelican.dev`
placeholder: "https://logs.pelican.dev/c17f750e"
Wings: `sudo wings diagnostics`
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:
required: false

View File

@ -11,9 +11,9 @@ jobs:
name: UI
runs-on: ubuntu-latest
strategy:
fail-fast: true
fail-fast: false
matrix:
node-version: [20, 22]
node-version: [18, 20]
steps:
- name: Code Checkout
uses: actions/checkout@v4

View File

@ -6,75 +6,12 @@ on:
- main
pull_request:
env:
APP_ENV: testing
APP_DEBUG: "false"
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
GUZZLE_TIMEOUT: 60
GUZZLE_CONNECT_TIMEOUT: 60
jobs:
sqlite:
name: SQLite
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: [8.2, 8.3, 8.4]
env:
DB_CONNECTION: sqlite
DB_DATABASE: testing.sqlite
steps:
- name: Code Checkout
uses: actions/checkout@v4
- name: Get cache directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-${{ matrix.php }}-
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Create SQLite file
run: touch database/testing.sqlite
- name: Unit tests
run: vendor/bin/pest tests/Unit
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration
mysql:
name: MySQL
runs-on: ubuntu-latest
strategy:
fail-fast: true
fail-fast: false
matrix:
php: [8.2, 8.3, 8.4]
database: ["mysql:8"]
@ -88,10 +25,21 @@ jobs:
- 3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
env:
APP_ENV: testing
APP_DEBUG: "false"
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: mysql
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
@ -136,7 +84,7 @@ jobs:
name: MariaDB
runs-on: ubuntu-latest
strategy:
fail-fast: true
fail-fast: false
matrix:
php: [8.2, 8.3, 8.4]
database: ["mariadb:10.6", "mariadb:10.11", "mariadb:11.4"]
@ -150,10 +98,21 @@ jobs:
- 3306
options: --health-cmd="mariadb-admin ping || mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
env:
APP_ENV: testing
APP_DEBUG: "false"
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: mariadb
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
@ -194,11 +153,72 @@ jobs:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
sqlite:
name: SQLite
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3, 8.4]
env:
APP_ENV: testing
APP_DEBUG: "false"
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
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
- name: Get cache directory
id: composer-cache
run: |
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-composer-${{ matrix.php }}-
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
- name: Install dependencies
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: Create SQLite file
run: touch database/testing.sqlite
- name: Unit tests
run: vendor/bin/pest tests/Unit
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/pest tests/Integration
postgresql:
name: PostgreSQL
runs-on: ubuntu-latest
strategy:
fail-fast: true
fail-fast: false
matrix:
php: [8.2, 8.3, 8.4]
database: ["postgres:14"]
@ -218,11 +238,22 @@ jobs:
--health-timeout 5s
--health-retries 5
env:
APP_ENV: testing
APP_DEBUG: "false"
APP_KEY: ThisIsARandomStringForTests12345
APP_TIMEZONE: UTC
APP_URL: http://localhost/
CACHE_DRIVER: array
MAIL_MAILER: array
SESSION_DRIVER: array
QUEUE_CONNECTION: sync
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: postgres
DB_PASSWORD: postgres
GUZZLE_TIMEOUT: 60
GUZZLE_CONNECT_TIMEOUT: 60
steps:
- name: Code Checkout
uses: actions/checkout@v4

View File

@ -66,6 +66,8 @@ jobs:
permissions:
contents: read
packages: write
strategy:
fail-fast: false
# Start a temp local registry because workflow can not pull from localy loaded images
services:
registry:
@ -132,11 +134,6 @@ jobs:
docker push localhost:5000/base-php:arm64
rm base-php-arm64.tar base-php-amd64.tar
- name: Update version in config/app.php (tag)
if: "github.event_name == 'release' && github.event.action == 'published'"
run: |
sed -i "s/'version' => 'canary',/'version' => '${{ steps.build_info.outputs.version_tag }}',/" config/app.php
- name: Build and Push (tag)
uses: docker/build-push-action@v6
if: "github.event_name == 'release' && github.event.action == 'published'"

View File

@ -33,9 +33,9 @@ jobs:
name: PHPStan
runs-on: ubuntu-latest
strategy:
fail-fast: true
fail-fast: false
matrix:
php: [ 8.2, 8.3, 8.4 ]
php: [8.2, 8.3, 8.4]
steps:
- name: Code Checkout
uses: actions/checkout@v4
@ -68,4 +68,4 @@ jobs:
run: composer install --no-interaction --no-suggest --no-progress --no-scripts
- name: PHPStan
run: vendor/bin/phpstan --memory-limit=-1 --error-format=github
run: vendor/bin/phpstan --memory-limit=-1

6
.gitignore vendored
View File

@ -1,6 +1,7 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
@ -21,9 +22,10 @@ yarn-error.log
/.idea
/.nova
/.vscode
/.ddev
public/assets/manifest.json
/database/*.sqlite*
/database/*.sqlite
/database/*.sqlite-journal
filament-monaco-editor/
_ide_helper*
/.phpstorm.meta.php

View File

@ -1,9 +1,16 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Production Dockerfile
##
# If you want to build this locally you want to run `docker build -f Dockerfile.dev .`
##
# For those who want to build this Dockerfile themselves, uncomment lines 6-12 and replace "localhost:5000/base-php:$TARGETARCH" on lines 17 and 67 with "base".
# FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine as base
# ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
# RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql
# RUN rm /usr/local/bin/install-php-extensions
# ================================
# Stage 1-1: Composer Install
@ -63,8 +70,8 @@ FROM --platform=$TARGETOS/$TARGETARCH localhost:5000/base-php:$TARGETARCH AS fin
WORKDIR /var/www/html
# Install additional required libraries
RUN apk add --no-cache \
caddy ca-certificates supervisor supercronic fcgi
RUN apk update && apk add --no-cache \
caddy ca-certificates supervisor supercronic
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
@ -75,18 +82,14 @@ RUN chown root:www-data ./ \
&& chmod 750 ./ \
# Files should not have execute set, but directories need it
&& find ./ -type d -exec chmod 750 {} \; \
# Create necessary directories
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
# Symlink to env/database path, as www-data won't be able to write to webroot
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
# Allow www-data write permissions where necessary
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/
# Create necessary directories
&& mkdir -p /pelican-data /var/run/supervisord /etc/supercronic \
# Finally allow www-data write permissions where necessary
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
@ -94,11 +97,10 @@ COPY docker/Caddyfile /etc/caddy/Caddyfile
# Add Laravel scheduler to crontab
COPY docker/crontab /etc/supercronic/crontab
COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/healthcheck.sh /healthcheck.sh
COPY docker/entrypoint.sh ./docker/entrypoint.sh
HEALTHCHECK --interval=5m --timeout=10s --start-period=5s --retries=3 \
CMD /bin/ash /healthcheck.sh
CMD curl -f http://localhost/up || exit 1
EXPOSE 80 443
@ -106,5 +108,5 @@ VOLUME /pelican-data
USER www-data
ENTRYPOINT [ "/bin/ash", "/entrypoint.sh" ]
ENTRYPOINT [ "/bin/ash", "docker/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View File

@ -1,10 +1,10 @@
# ================================
# Stage 0: Build PHP Base Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine
FROM --platform=$TARGETOS/$TARGETARCH php:8.3-fpm-alpine
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql
RUN rm /usr/local/bin/install-php-extensions

View File

@ -1,114 +0,0 @@
# syntax=docker.io/docker/dockerfile:1.13-labs
# Pelican Development Dockerfile
FROM --platform=$TARGETOS/$TARGETARCH php:8.4-fpm-alpine AS base
ADD --chmod=0755 https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/
RUN install-php-extensions bcmath gd intl zip opcache pcntl posix pdo_mysql pdo_pgsql
RUN rm /usr/local/bin/install-php-extensions
# ================================
# Stage 1-1: Composer Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH base AS composer
WORKDIR /build
COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
# Copy bare minimum to install Composer dependencies
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-autoloader --no-scripts
# ================================
# Stage 1-2: Yarn Install
# ================================
FROM --platform=$TARGETOS/$TARGETARCH node:20-alpine AS yarn
WORKDIR /build
# Copy bare minimum to install Yarn dependencies
COPY package.json yarn.lock ./
RUN yarn config set network-timeout 300000 \
&& yarn install --frozen-lockfile
# ================================
# Stage 2-1: Composer Optimize
# ================================
FROM --platform=$TARGETOS/$TARGETARCH composer AS composerbuild
# Copy full code to optimize autoload
COPY --exclude=Caddyfile --exclude=docker/ . ./
RUN composer dump-autoload --optimize
# ================================
# Stage 2-2: Build Frontend Assets
# ================================
FROM --platform=$TARGETOS/$TARGETARCH yarn AS yarnbuild
WORKDIR /build
# Copy full code
COPY --exclude=Caddyfile --exclude=docker/ . ./
COPY --from=composer /build .
RUN yarn run build
# ================================
# Stage 5: Build Final Application Image
# ================================
FROM --platform=$TARGETOS/$TARGETARCH base AS final
WORKDIR /var/www/html
# Install additional required libraries
RUN apk add --no-cache \
caddy ca-certificates supervisor supercronic fcgi coreutils
COPY --chown=root:www-data --chmod=640 --from=composerbuild /build .
COPY --chown=root:www-data --chmod=640 --from=yarnbuild /build/public ./public
# Set permissions
# First ensure all files are owned by root and restrict www-data to read access
RUN chown root:www-data ./ \
&& chmod 750 ./ \
# Files should not have execute set, but directories need it
&& find ./ -type d -exec chmod 750 {} \; \
# Create necessary directories
&& mkdir -p /pelican-data/storage /var/www/html/storage/app/public /var/run/supervisord /etc/supercronic \
# Symlinks for env, database, and avatars
&& ln -s /pelican-data/.env ./.env \
&& ln -s /pelican-data/database/database.sqlite ./database/database.sqlite \
&& ln -sf /var/www/html/storage/app/public /var/www/html/public/storage \
&& ln -s /pelican-data/storage/avatars /var/www/html/storage/app/public/avatars \
&& ln -s /pelican-data/storage/fonts /var/www/html/storage/app/public/fonts \
# Allow www-data write permissions where necessary
&& chown -R www-data:www-data /pelican-data ./storage ./bootstrap/cache /var/run/supervisord /var/www/html/public/storage \
&& chmod -R u+rwX,g+rwX,o-rwx /pelican-data ./storage ./bootstrap/cache /var/run/supervisord \
&& chown -R www-data: /usr/local/etc/php/
# Configure Supervisor
COPY docker/supervisord.conf /etc/supervisord.conf
COPY docker/Caddyfile /etc/caddy/Caddyfile
# Add Laravel scheduler to crontab
COPY docker/crontab /etc/supercronic/crontab
COPY docker/entrypoint.sh /entrypoint.sh
COPY docker/healthcheck.sh /healthcheck.sh
HEALTHCHECK --interval=5m --timeout=10s --start-period=5s --retries=3 \
CMD /bin/ash /healthcheck.sh
EXPOSE 80 443
VOLUME /pelican-data
USER www-data
ENTRYPOINT [ "/bin/ash", "/entrypoint.sh" ]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View File

@ -14,9 +14,9 @@ class NodeVersionsCheck extends Check
public function run(): Result
{
$all = Node::all();
$all = Node::query()->count();
if ($all->isEmpty()) {
if ($all === 0) {
$result = Result::make()
->notificationMessage(trans('admin/health.results.nodeversions.no_nodes_created'))
->shortSummary(trans('admin/health.results.nodeversions.no_nodes'));
@ -25,18 +25,16 @@ class NodeVersionsCheck extends Check
return $result;
}
$outdated = $all
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && !$this->versionService->isLatestWings($node->systemInformation()['version']))
->count();
$all = $all->count();
$latestVersion = $this->versionService->latestWingsVersion();
$outdated = Node::query()->get()
->filter(fn (Node $node) => !isset($node->systemInformation()['exception']) && $node->systemInformation()['version'] !== $latestVersion)
->count();
$result = Result::make()
->meta([
'all' => $all,
'outdated' => $outdated,
'latestVersion' => $latestVersion,
])
->shortSummary($outdated === 0 ? trans('admin/health.results.nodeversions.all_up_to_date') : trans('admin/health.results.nodeversions.outdated', ['outdated' => $outdated, 'all' => $all]));

View File

@ -2,14 +2,10 @@
namespace App\Console\Commands\Egg;
use App\Enums\EggFormat;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
use JsonException;
use Symfony\Component\Yaml\Yaml;
class CheckEggUpdatesCommand extends Command
{
@ -27,9 +23,6 @@ class CheckEggUpdatesCommand extends Command
}
}
/**
* @throws JsonException
*/
private function check(Egg $egg, EggExporterService $exporterService): void
{
if (is_null($egg->update_url)) {
@ -38,24 +31,22 @@ class CheckEggUpdatesCommand extends Command
return;
}
$ext = strtolower(pathinfo(parse_url($egg->update_url, PHP_URL_PATH), PATHINFO_EXTENSION));
$isYaml = in_array($ext, ['yaml', 'yml']);
$currentJson = json_decode($exporterService->handle($egg->id));
unset($currentJson->exported_at);
$local = $isYaml
? Yaml::parse($exporterService->handle($egg->id, EggFormat::YAML))
: json_decode($exporterService->handle($egg->id, EggFormat::JSON), true);
$updatedEgg = file_get_contents($egg->update_url);
assert($updatedEgg !== false);
$updatedJson = json_decode($updatedEgg);
unset($updatedJson->exported_at);
$remote = Http::timeout(5)->connectTimeout(1)->get($egg->update_url)->throw()->body();
$remote = $isYaml ? Yaml::parse($remote) : json_decode($remote, true);
if (md5(json_encode($currentJson, JSON_THROW_ON_ERROR)) === md5(json_encode($updatedJson, JSON_THROW_ON_ERROR))) {
$this->info("$egg->name: Up-to-date");
cache()->put("eggs.$egg->uuid.update", false, now()->addHour());
unset($local['exported_at'], $remote['exported_at']);
return;
}
$localHash = md5(json_encode($local, JSON_THROW_ON_ERROR));
$remoteHash = md5(json_encode($remote, JSON_THROW_ON_ERROR));
$status = $localHash === $remoteHash ? 'Up-to-date' : 'Found update';
$this->{($localHash === $remoteHash) ? 'info' : 'warn'}("$egg->name: $status");
cache()->put("eggs.$egg->uuid.update", $localHash !== $remoteHash, now()->addHour());
$this->warn("$egg->name: Found update");
cache()->put("eggs.$egg->uuid.update", true, now()->addHour());
}
}

View File

@ -1,46 +0,0 @@
<?php
namespace App\Console\Commands\Egg;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class UpdateEggIndexCommand extends Command
{
protected $signature = 'p:egg:update-index';
public function handle(): int
{
try {
$data = Http::timeout(5)->connectTimeout(1)->get('https://raw.githubusercontent.com/pelican-eggs/pelican-eggs.github.io/refs/heads/main/content/pelican.json')->throw()->json();
} catch (Exception $exception) {
$this->error($exception->getMessage());
return 1;
}
$index = [];
foreach ($data['nests'] as $nest) {
$nestName = $nest['nest_type'];
$this->info("Nest: $nestName");
$nestEggs = [];
foreach ($nest['Eggs'] as $egg) {
$eggName = $egg['egg']['name'];
$this->comment("Egg: $eggName");
$nestEggs[$egg['download_url']] = $eggName;
}
$index[$nestName] = $nestEggs;
$this->info('');
}
cache()->forever('eggs.index', $index);
return 0;
}
}

View File

@ -3,6 +3,7 @@
namespace App\Console\Commands\Environment;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
class AppSettingsCommand extends Command
{
@ -20,13 +21,9 @@ class AppSettingsCommand extends Command
if (!config('app.key')) {
$this->comment('Generating app key');
$this->call('key:generate');
Artisan::call('key:generate');
}
$this->comment('Creating storage link');
$this->call('storage:link');
$this->comment('Caching components & icons');
$this->call('filament:optimize');
Artisan::call('filament:optimize');
}
}

View File

@ -6,7 +6,6 @@ use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Database\DatabaseManager;
use PDOException;
class DatabaseSettingsCommand extends Command
{
@ -106,7 +105,7 @@ class DatabaseSettingsCommand extends Command
]);
$this->database->connection('_panel_command_test')->getPdo();
} catch (PDOException $exception) {
} catch (\PDOException $exception) {
$this->output->error(sprintf('Unable to connect to the MySQL server using the provided credentials. The error returned was "%s".', $exception->getMessage()));
$this->output->error(trans('commands.database_settings.DB_error_2'));
@ -166,7 +165,7 @@ class DatabaseSettingsCommand extends Command
]);
$this->database->connection('_panel_command_test')->getPdo();
} catch (PDOException $exception) {
} catch (\PDOException $exception) {
$this->output->error(sprintf('Unable to connect to the MariaDB server using the provided credentials. The error returned was "%s".', $exception->getMessage()));
$this->output->error(trans('commands.database_settings.DB_error_2'));

View File

@ -2,7 +2,6 @@
namespace App\Console\Commands\Environment;
use App\Exceptions\PanelException;
use App\Traits\EnvironmentWriterTrait;
use Illuminate\Console\Command;
@ -29,7 +28,7 @@ class EmailSettingsCommand extends Command
/**
* Handle command execution.
*
* @throws PanelException
* @throws \App\Exceptions\PanelException
*/
public function handle(): void
{

View File

@ -18,17 +18,6 @@ class QueueWorkerServiceCommand extends Command
public function handle(): void
{
if (@file_exists('/.dockerenv')) {
$result = Process::run('supervisorctl restart queue-worker');
if ($result->failed()) {
$this->error('Error restarting service: ' . $result->errorOutput());
return;
}
$this->line('Queue worker service file updated successfully.');
return;
}
$serviceName = $this->option('service-name') ?? $this->ask('Queue worker service name', 'pelican-queue');
$path = '/etc/systemd/system/' . $serviceName . '.service';

View File

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

View File

@ -5,7 +5,6 @@ namespace App\Console\Commands\Maintenance;
use App\Models\Backup;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use InvalidArgumentException;
class PruneOrphanedBackupsCommand extends Command
{
@ -17,7 +16,7 @@ class PruneOrphanedBackupsCommand extends Command
{
$since = $this->option('prune-age') ?? config('backups.prune_age', 360);
if (!$since || !is_digit($since)) {
throw new InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.');
throw new \InvalidArgumentException('The "--prune-age" argument must be a value greater than 0.');
}
$query = Backup::query()

View File

@ -2,7 +2,6 @@
namespace App\Console\Commands\Node;
use App\Exceptions\Model\DataValidationException;
use App\Models\Node;
use Illuminate\Console\Command;
@ -25,7 +24,6 @@ class MakeNodeCommand extends Command
{--overallocateCpu= : Enter the amount of cpu to overallocate (% or -1 to overallocate the maximum).}
{--uploadSize= : Enter the maximum upload filesize.}
{--daemonListeningPort= : Enter the daemon listening port.}
{--daemonConnectingPort= : Enter the daemon connecting port.}
{--daemonSFTPPort= : Enter the daemon SFTP listening port.}
{--daemonSFTPAlias= : Enter the daemon SFTP alias.}
{--daemonBase= : Enter the base folder.}';
@ -35,7 +33,7 @@ class MakeNodeCommand extends Command
/**
* Handle the command execution process.
*
* @throws DataValidationException
* @throws \App\Exceptions\Model\DataValidationException
*/
public function handle(): void
{
@ -59,7 +57,6 @@ class MakeNodeCommand extends Command
$data['cpu_overallocate'] = $this->option('overallocateCpu') ?? $this->ask(trans('commands.make_node.cpu_overallocate'), '-1');
$data['upload_size'] = $this->option('uploadSize') ?? $this->ask(trans('commands.make_node.upload_size'), '256');
$data['daemon_listen'] = $this->option('daemonListeningPort') ?? $this->ask(trans('commands.make_node.daemonListen'), '8080');
$data['daemon_connect'] = $this->option('daemonConnectingPort') ?? $this->ask(trans('commands.make_node.daemonConnect'), '8080');
$data['daemon_sftp'] = $this->option('daemonSFTPPort') ?? $this->ask(trans('commands.make_node.daemonSFTP'), '2022');
$data['daemon_sftp_alias'] = $this->option('daemonSFTPAlias') ?? $this->ask(trans('commands.make_node.daemonSFTPAlias'), '');
$data['daemon_base'] = $this->option('daemonBase') ?? $this->ask(trans('commands.make_node.daemonBase'), '/var/lib/pelican/volumes');

View File

@ -17,7 +17,7 @@ class NodeConfigurationCommand extends Command
{
$column = ctype_digit((string) $this->argument('node')) ? 'id' : 'uuid';
/** @var Node $node */
/** @var \App\Models\Node $node */
$node = Node::query()->where($column, $this->argument('node'))->firstOr(function () {
$this->error(trans('commands.node_config.error_not_exist'));

View File

@ -2,10 +2,10 @@
namespace App\Console\Commands\Schedule;
use App\Models\Schedule;
use App\Services\Schedules\ProcessScheduleService;
use Illuminate\Console\Command;
use App\Models\Schedule;
use Illuminate\Database\Eloquent\Builder;
use App\Services\Schedules\ProcessScheduleService;
use Throwable;
class ProcessRunnableCommand extends Command
@ -64,7 +64,7 @@ class ProcessRunnableCommand extends Command
} catch (Throwable $exception) {
logger()->error($exception, ['schedule_id' => $schedule->id]);
$this->error(trans('commands.schedule.process.error_message', ['schedules' => " #$schedule->id: " . $exception->getMessage()]));
$this->error(trans('commands.schedule.process.no_tasks') . " #$schedule->id: " . $exception->getMessage());
}
}
}

View File

@ -3,12 +3,12 @@
namespace App\Console\Commands\Server;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use Exception;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Factory as ValidatorFactory;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Factory as ValidatorFactory;
use App\Repositories\Daemon\DaemonPowerRepository;
use Exception;
class BulkPowerActionCommand extends Command
{
@ -19,7 +19,7 @@ class BulkPowerActionCommand extends Command
protected $description = 'Perform bulk power management on large groupings of servers or nodes at once.';
public function handle(DaemonServerRepository $serverRepository, ValidatorFactory $validator): void
public function handle(DaemonPowerRepository $powerRepository, ValidatorFactory $validator): void
{
$action = $this->argument('action');
$nodes = empty($this->option('nodes')) ? [] : explode(',', $this->option('nodes'));
@ -52,7 +52,7 @@ class BulkPowerActionCommand extends Command
$bar = $this->output->createProgressBar($count);
$this->getQueryBuilder($servers, $nodes)->get()->each(function ($server, int $index) use ($action, $serverRepository, &$bar): mixed {
$this->getQueryBuilder($servers, $nodes)->get()->each(function ($server, int $index) use ($action, $powerRepository, &$bar): mixed {
$bar->clear();
if (!$server instanceof Server) {
@ -60,7 +60,7 @@ class BulkPowerActionCommand extends Command
}
try {
$serverRepository->setServer($server)->power($action);
$powerRepository->setServer($server)->send($action);
} catch (Exception $exception) {
$this->output->error(trans('command/messages.server.power.action_failed', [
'name' => $server->name,

View File

@ -0,0 +1,193 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Console\Kernel;
use Symfony\Component\Process\Process;
use Symfony\Component\Console\Helper\ProgressBar;
class UpgradeCommand extends Command
{
protected const DEFAULT_URL = 'https://github.com/pelican-dev/panel/releases/%s/panel.tar.gz';
protected $signature = 'p:upgrade
{--user= : The user that PHP runs under. All files will be owned by this user.}
{--group= : The group that PHP runs under. All files will be owned by this group.}
{--url= : The specific archive to download.}
{--release= : A specific version to download from GitHub. Leave blank to use latest.}
{--skip-download : If set no archive will be downloaded.}';
protected $description = 'Downloads a new archive from GitHub and then executes the normal upgrade commands.';
/**
* Executes an upgrade command which will run through all of our standard
* Panel commands and enable users to basically just download
* the archive and execute this and be done.
*
* This places the application in maintenance mode as well while the commands
* are being executed.
*
* @throws \Exception
*/
public function handle(): void
{
$skipDownload = $this->option('skip-download');
if (!$skipDownload) {
$this->output->warning(trans('commands.upgrade.integrity'));
$this->output->comment(trans('commands.upgrade.source_url'));
$this->line($this->getUrl());
}
$user = 'www-data';
$group = 'www-data';
if ($this->input->isInteractive()) {
if (!$skipDownload) {
$skipDownload = !$this->confirm(trans('commands.upgrade.skipDownload'), true);
}
if (is_null($this->option('user'))) {
$userDetails = function_exists('posix_getpwuid') ? posix_getpwuid(fileowner('public')) : [];
$user = $userDetails['name'] ?? 'www-data';
$message = trans('commands.upgrade.webserver_user', ['user' => $user]);
if (!$this->confirm($message, true)) {
$user = $this->anticipate(
trans('commands.upgrade.name_webserver'),
[
'www-data',
'nginx',
'apache',
]
);
}
}
if (is_null($this->option('group'))) {
$groupDetails = function_exists('posix_getgrgid') ? posix_getgrgid(filegroup('public')) : [];
$group = $groupDetails['name'] ?? 'www-data';
$message = trans('commands.upgrade.group_webserver', ['group' => $user]);
if (!$this->confirm($message, true)) {
$group = $this->anticipate(
trans('commands.upgrade.group_webserver_question'),
[
'www-data',
'nginx',
'apache',
]
);
}
}
if (!$this->confirm(trans('commands.upgrade.are_your_sure'))) {
$this->warn(trans('commands.upgrade.terminated'));
return;
}
}
ini_set('output_buffering', '0');
$bar = $this->output->createProgressBar($skipDownload ? 9 : 10);
$bar->start();
if (!$skipDownload) {
$this->withProgress($bar, function () {
$this->line("\$upgrader> curl -L \"{$this->getUrl()}\" | tar -xzv");
$process = Process::fromShellCommandline("curl -L \"{$this->getUrl()}\" | tar -xzv");
$process->run(function ($type, $buffer) {
$this->{$type === Process::ERR ? 'error' : 'line'}($buffer);
});
});
}
$this->withProgress($bar, function () {
$this->line('$upgrader> php artisan down');
$this->call('down');
});
$this->withProgress($bar, function () {
$this->line('$upgrader> chmod -R 755 storage bootstrap/cache');
$process = new Process(['chmod', '-R', '755', 'storage', 'bootstrap/cache']);
$process->run(function ($type, $buffer) {
$this->{$type === Process::ERR ? 'error' : 'line'}($buffer);
});
});
$this->withProgress($bar, function () {
$command = ['composer', 'install', '--no-ansi'];
if (config('app.env') === 'production' && !config('app.debug')) {
$command[] = '--optimize-autoloader';
$command[] = '--no-dev';
}
$this->line('$upgrader> ' . implode(' ', $command));
$process = new Process($command);
$process->setTimeout(10 * 60);
$process->run(function ($type, $buffer) {
$this->line($buffer);
});
});
/** @var \Illuminate\Foundation\Application $app */
$app = require __DIR__ . '/../../../bootstrap/app.php';
/** @var \App\Console\Kernel $kernel */
$kernel = $app->make(Kernel::class);
$kernel->bootstrap();
$this->setLaravel($app);
$this->withProgress($bar, function () {
$this->line('$upgrader> php artisan view:clear');
$this->call('view:clear');
});
$this->withProgress($bar, function () {
$this->line('$upgrader> php artisan config:clear');
$this->call('config:clear');
});
$this->withProgress($bar, function () {
$this->line('$upgrader> php artisan migrate --force --seed');
$this->call('migrate', ['--force' => true, '--seed' => true]);
});
$this->withProgress($bar, function () use ($user, $group) {
$this->line("\$upgrader> chown -R {$user}:{$group} *");
$process = Process::fromShellCommandline("chown -R {$user}:{$group} *", $this->getLaravel()->basePath());
$process->setTimeout(10 * 60);
$process->run(function ($type, $buffer) {
$this->{$type === Process::ERR ? 'error' : 'line'}($buffer);
});
});
$this->withProgress($bar, function () {
$this->line('$upgrader> php artisan queue:restart');
$this->call('queue:restart');
});
$this->withProgress($bar, function () {
$this->line('$upgrader> php artisan up');
$this->call('up');
});
$this->newLine(2);
$this->info(trans('commands.upgrade.success'));
}
protected function withProgress(ProgressBar $bar, \Closure $callback): void
{
$bar->clear();
$callback();
$bar->advance();
$bar->display();
}
protected function getUrl(): string
{
if ($this->option('url')) {
return $this->option('url');
}
return sprintf(self::DEFAULT_URL, $this->option('release') ? 'download/v' . $this->option('release') : 'latest/download');
}
}

View File

@ -3,8 +3,8 @@
namespace App\Console\Commands\User;
use App\Models\User;
use Illuminate\Console\Command;
use Webmozart\Assert\Assert;
use Illuminate\Console\Command;
class DeleteUserCommand extends Command
{
@ -35,7 +35,7 @@ class DeleteUserCommand extends Command
if ($this->input->isInteractive()) {
$tableValues = [];
foreach ($results as $user) {
$tableValues[] = [$user->id, $user->email, $user->username];
$tableValues[] = [$user->id, $user->email, $user->name];
}
$this->table(['User ID', 'Email', 'Name'], $tableValues);

View File

@ -2,7 +2,6 @@
namespace App\Console\Commands\User;
use App\Exceptions\Model\DataValidationException;
use App\Models\User;
use Illuminate\Console\Command;
@ -15,22 +14,20 @@ class DisableTwoFactorCommand extends Command
/**
* Handle command execution process.
*
* @throws DataValidationException
* @throws \App\Exceptions\Model\DataValidationException
*/
public function handle(): void
{
if ($this->input->isInteractive()) {
$this->output->warning(trans('command/messages.user.2fa_help_text'));
$this->output->warning(trans('command/messages.user.2fa_help_text.0') . trans('command/messages.user.2fa_help_text.1'));
}
$email = $this->option('email') ?? $this->ask(trans('command/messages.user.ask_email'));
$user = User::where('email', $email)->firstOrFail();
$user->update([
'mfa_app_secret' => null,
'mfa_app_recovery_codes' => null,
'mfa_email_enabled' => false,
]);
$user = User::query()->where('email', $email)->firstOrFail();
$user->use_totp = false;
$user->totp_secret = null;
$user->save();
$this->info(trans('command/messages.user.2fa_disabled', ['email' => $user->email]));
}

View File

@ -2,10 +2,9 @@
namespace App\Console\Commands\User;
use App\Exceptions\Model\DataValidationException;
use App\Services\Users\UserCreationService;
use Exception;
use Illuminate\Console\Command;
use App\Services\Users\UserCreationService;
use Illuminate\Support\Facades\DB;
class MakeUserCommand extends Command
@ -26,7 +25,7 @@ class MakeUserCommand extends Command
* Handle command request to create a new user.
*
* @throws Exception
* @throws DataValidationException
* @throws \App\Exceptions\Model\DataValidationException
*/
public function handle(): int
{

View File

@ -3,11 +3,11 @@
namespace App\Console;
use App\Console\Commands\Egg\CheckEggUpdatesCommand;
use App\Console\Commands\Egg\UpdateEggIndexCommand;
use App\Console\Commands\Maintenance\CleanServiceBackupFilesCommand;
use App\Console\Commands\Maintenance\PruneImagesCommand;
use App\Console\Commands\Maintenance\PruneOrphanedBackupsCommand;
use App\Console\Commands\Schedule\ProcessRunnableCommand;
use App\Jobs\NodeStatistics;
use App\Models\ActivityLog;
use App\Models\Webhook;
use Illuminate\Console\Scheduling\Schedule;
@ -31,20 +31,17 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule): void
{
if (config('cache.default') === 'redis') {
// https://laravel.com/docs/10.x/upgrade#redis-cache-tags
// This only needs to run when using redis. anything else throws an error.
$schedule->command('cache:prune-stale-tags')->hourly();
}
// 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->command(CheckEggUpdatesCommand::class)->hourly();
$schedule->command(CheckEggUpdatesCommand::class)->daily();
$schedule->command(UpdateEggIndexCommand::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

@ -2,13 +2,12 @@
namespace App\Contracts\Http;
use App\Enums\SubuserPermission;
interface ClientPermissionsRequest
{
/**
* Returns the permission used to validate that the authenticated user may perform
* this action against the given resource (server).
* Returns the permissions string indicating which permission should be used to
* validate that the authenticated user has permission to perform this action against
* the given resource (server).
*/
public function permission(): SubuserPermission|string;
public function permission(): string;
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum BackupStatus: string implements HasColor, HasIcon, HasLabel
{
case InProgress = 'in_progress';
case Successful = 'successful';
case Failed = 'failed';
public function getIcon(): string
{
return match ($this) {
self::InProgress => 'tabler-circle-dashed',
self::Successful => 'tabler-circle-check',
self::Failed => 'tabler-circle-x',
};
}
public function getColor(): string
{
return match ($this) {
self::InProgress => 'primary',
self::Successful => 'success',
self::Failed => 'danger',
};
}
public function getLabel(): string
{
return trans('server/backup.backup_status.' . $this->value);
}
}

View File

@ -68,7 +68,7 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
public function getLabel(): string
{
return trans('server/console.status.' . $this->value);
return str($this->value)->title();
}
public function isOffline(): bool
@ -88,7 +88,7 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
public function isStartable(): bool
{
return !in_array($this, [ContainerStatus::Running, ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Missing]);
return !in_array($this, [ContainerStatus::Running, ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting]);
}
public function isRestartable(): bool
@ -97,16 +97,18 @@ enum ContainerStatus: string implements HasColor, HasIcon, HasLabel
return true;
}
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Missing]);
return !in_array($this, [ContainerStatus::Offline]);
}
public function isStoppable(): bool
{
return !in_array($this, [ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Exited, ContainerStatus::Offline, ContainerStatus::Missing]);
return !in_array($this, [ContainerStatus::Starting, ContainerStatus::Stopping, ContainerStatus::Restarting, ContainerStatus::Exited, ContainerStatus::Offline]);
}
public function isKillable(): bool
{
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Running, ContainerStatus::Exited, ContainerStatus::Missing]);
// [ContainerStatus::Restarting, ContainerStatus::Removing, ContainerStatus::Dead, ContainerStatus::Created]
return !in_array($this, [ContainerStatus::Offline, ContainerStatus::Running, ContainerStatus::Exited]);
}
}

View File

@ -1,9 +0,0 @@
<?php
namespace App\Enums;
enum CustomRenderHooks: string
{
case FooterStart = 'pelican::footer.start';
case FooterEnd = 'pelican::footer.end';
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Enums;
enum CustomizationKey: string
{
case ConsoleRows = 'console_rows';
case ConsoleFont = 'console_font';
case ConsoleFontSize = 'console_font_size';
case ConsoleGraphPeriod = 'console_graph_period';
case TopNavigation = 'top_navigation';
case DashboardLayout = 'dashboard_layout';
public function getDefaultValue(): string|int|bool
{
return match ($this) {
self::ConsoleRows => 30,
self::ConsoleFont => 'monospace',
self::ConsoleFontSize => 14,
self::ConsoleGraphPeriod => 30,
self::TopNavigation => config('panel.filament.default-navigation', 'sidebar'),
self::DashboardLayout => 'grid',
};
}
/** @return array<string, string|int|bool> */
public static function getDefaultCustomization(): array
{
$default = [];
foreach (self::cases() as $key) {
$default[$key->value] = $key->getDefaultValue();
}
return $default;
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasLabel;
enum EditorLanguages: string implements HasLabel
{
case plaintext = 'plaintext';
case abap = 'abap';
case apex = 'apex';
case azcali = 'azcali';
case bat = 'bat';
case bicep = 'bicep';
case cameligo = 'cameligo';
case coljure = 'coljure';
case coffeescript = 'coffeescript';
case c = 'c';
case cpp = 'cpp';
case csharp = 'csharp';
case csp = 'csp';
case css = 'css';
case cypher = 'cypher';
case dart = 'dart';
case dockerfile = 'dockerfile';
case ecl = 'ecl';
case elixir = 'elixir';
case flow9 = 'flow9';
case fsharp = 'fsharp';
case go = 'go';
case graphql = 'graphql';
case handlebars = 'handlebars';
case hcl = 'hcl';
case html = 'html';
case ini = 'ini';
case java = 'java';
case javascript = 'javascript';
case julia = 'julia';
case json = 'json';
case kotlin = 'kotlin';
case less = 'less';
case lexon = 'lexon';
case lua = 'lua';
case liquid = 'liquid';
case m3 = 'm3';
case markdown = 'markdown';
case mdx = 'mdx';
case mips = 'mips';
case msdax = 'msdax';
case mysql = 'mysql';
case objectivec = 'objective-c';
case pascal = 'pascal';
case pascaligo = 'pascaligo';
case perl = 'perl';
case pgsql = 'pgsql';
case php = 'php';
case pla = 'pla';
case postiats = 'postiats';
case powerquery = 'powerquery';
case powershell = 'powershell';
case proto = 'proto';
case pug = 'pug';
case python = 'python';
case qsharp = 'qsharp';
case r = 'r';
case razor = 'razor';
case redis = 'redis';
case redshift = 'redshift';
case restructuredtext = 'restructuredtext';
case ruby = 'ruby';
case rust = 'rust';
case sb = 'sb';
case scala = 'scala';
case scheme = 'scheme';
case scss = 'scss';
case shell = 'shell';
case sol = 'sol';
case aes = 'aes';
case sparql = 'sparql';
case sql = 'sql';
case st = 'st';
case swift = 'swift';
case systemverilog = 'systemverilog';
case verilog = 'verilog';
case tcl = 'tcl';
case twig = 'twig';
case typescript = 'typescript';
case typespec = 'typespec';
case vb = 'vb';
case wgsl = 'wgsl';
case xml = 'xml';
case yaml = 'yaml';
public static function fromWithAlias(string $match): self
{
return match ($match) {
'h' => self::c,
'cc', 'hpp' => self::cpp,
'cs' => self::csharp,
'class' => self::java,
'htm' => self::html,
'js', 'mjs', 'cjs' => self::javascript,
'kt', 'kts' => self::kotlin,
'md' => self::markdown,
'm' => self::objectivec,
'pl', 'pm' => self::perl,
'php3', 'php4', 'php5', 'phtml' => self::php,
'py', 'pyc', 'pyo', 'pyi' => self::python,
'rdata', 'rds' => self::r,
'rb', 'erb' => self::ruby,
'sc' => self::scala,
'sh', 'zsh' => self::shell,
'ts', 'tsx' => self::typescript,
'yml' => self::yaml,
default => self::tryFrom($match) ?? self::plaintext,
};
}
public function getLabel(): string
{
return $this->name;
}
}

View File

@ -1,9 +0,0 @@
<?php
namespace App\Enums;
enum EggFormat: string
{
case YAML = 'yaml';
case JSON = 'json';
}

View File

@ -1,9 +0,0 @@
<?php
namespace App\Enums;
enum HeaderActionPosition: string
{
case Before = 'before';
case After = 'after';
}

View File

@ -1,9 +0,0 @@
<?php
namespace App\Enums;
enum HeaderWidgetPosition: string
{
case Before = 'before';
case After = 'after';
}

View File

@ -14,24 +14,4 @@ enum RolePermissionModels: string
case Server = 'server';
case User = 'user';
case Webhook = 'webhook';
public function viewAny(): string
{
return RolePermissionPrefixes::ViewAny->value . ' ' . $this->value;
}
public function view(): string
{
return RolePermissionPrefixes::View->value . ' ' . $this->value;
}
public function create(): string
{
return RolePermissionPrefixes::Create->value . ' ' . $this->value;
}
public function update(): string
{
return RolePermissionPrefixes::Update->value . ' ' . $this->value;
}
}

View File

@ -1,27 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum ScheduleStatus: string implements HasColor, HasLabel
{
case Inactive = 'inactive';
case Processing = 'processing';
case Active = 'active';
public function getColor(): string
{
return match ($this) {
self::Inactive => 'danger',
self::Processing => 'warning',
self::Active => 'success',
};
}
public function getLabel(): string
{
return trans('server/schedule.schedule_status.' . $this->value);
}
}

View File

@ -2,50 +2,9 @@
namespace App\Enums;
use App\Models\Server;
enum ServerResourceType: string
enum ServerResourceType
{
case Uptime = 'uptime';
case CPU = 'cpu_absolute';
case Memory = 'memory_bytes';
case Disk = 'disk_bytes';
case CPULimit = 'cpu';
case MemoryLimit = 'memory';
case DiskLimit = 'disk';
/**
* @return int resource amount in bytes
*/
public function getResourceAmount(Server $server): int
{
if ($this->isLimit()) {
$resourceAmount = $server->{$this->value} ?? 0;
if (!$this->isPercentage()) {
// Our limits are entered as MiB/ MB so we need to convert them to bytes
$resourceAmount *= config('panel.use_binary_prefix') ? 1024 * 1024 : 1000 * 1000;
}
return $resourceAmount;
}
return $server->retrieveResources()[$this->value] ?? 0;
}
public function isLimit(): bool
{
return $this === ServerResourceType::CPULimit || $this === ServerResourceType::MemoryLimit || $this === ServerResourceType::DiskLimit;
}
public function isTime(): bool
{
return $this === ServerResourceType::Uptime;
}
public function isPercentage(): bool
{
return $this === ServerResourceType::CPU || $this === ServerResourceType::CPULimit;
}
case Unit;
case Percentage;
case Time;
}

View File

@ -8,6 +8,7 @@ use Filament\Support\Contracts\HasLabel;
enum ServerState: string implements HasColor, HasIcon, HasLabel
{
case Normal = 'normal';
case Installing = 'installing';
case InstallFailed = 'install_failed';
case ReinstallFailed = 'reinstall_failed';
@ -17,6 +18,7 @@ enum ServerState: string implements HasColor, HasIcon, HasLabel
public function getIcon(): string
{
return match ($this) {
self::Normal => 'tabler-heart',
self::Installing => 'tabler-heart-bolt',
self::InstallFailed => 'tabler-heart-x',
self::ReinstallFailed => 'tabler-heart-x',
@ -25,17 +27,10 @@ enum ServerState: string implements HasColor, HasIcon, HasLabel
};
}
public function getColor(bool $hex = false): string
public function getColor(): string
{
if ($hex) {
return match ($this) {
self::Installing, self::RestoringBackup => '#2563EB',
self::Suspended => '#D97706',
self::InstallFailed, self::ReinstallFailed => '#EF4444',
};
}
return match ($this) {
self::Normal => 'primary',
self::Installing => 'primary',
self::InstallFailed => 'danger',
self::ReinstallFailed => 'danger',

View File

@ -1,11 +0,0 @@
<?php
namespace App\Enums;
enum StartupVariableType: string
{
case Text = 'text';
case Number = 'number';
case Select = 'select';
case Toggle = 'toggle';
}

View File

@ -1,88 +0,0 @@
<?php
namespace App\Enums;
enum SubuserPermission: string
{
case WebsocketConnect = 'websocket.connect';
case ControlConsole = 'control.console';
case ControlStart = 'control.start';
case ControlStop = 'control.stop';
case ControlRestart = 'control.restart';
case FileRead = 'file.read';
case FileReadContent = 'file.read-content';
case FileCreate = 'file.create';
case FileUpdate = 'file.update';
case FileDelete = 'file.delete';
case FileArchive = 'file.archive';
case FileSftp = 'file.sftp';
case BackupRead = 'backup.read';
case BackupCreate = 'backup.create';
case BackupDelete = 'backup.delete';
case BackupDownload = 'backup.download';
case BackupRestore = 'backup.restore';
case ScheduleRead = 'schedule.read';
case ScheduleCreate = 'schedule.create';
case ScheduleUpdate = 'schedule.update';
case ScheduleDelete = 'schedule.delete';
case UserRead = 'user.read';
case UserCreate = 'user.create';
case UserUpdate = 'user.update';
case UserDelete = 'user.delete';
case DatabaseRead = 'database.read';
case DatabaseCreate = 'database.create';
case DatabaseUpdate = 'database.update';
case DatabaseDelete = 'database.delete';
case DatabaseViewPassword = 'database.view-password';
case AllocationRead = 'allocation.read';
case AllocationCreate = 'allocation.create';
case AllocationUpdate = 'allocation.update';
case AllocationDelete = 'allocation.delete';
case ActivityRead = 'activity.read';
case StartupRead = 'startup.read';
case StartupUpdate = 'startup.update';
case StartupDockerImage = 'startup.docker-image';
case SettingsRename = 'settings.rename';
case SettingsDescription = 'settings.description';
case SettingsReinstall = 'settings.reinstall';
/** @return string[] */
public function split(): array
{
return explode('.', $this->value, 2);
}
public function isHidden(): bool
{
return $this === self::WebsocketConnect;
}
public function getIcon(): ?string
{
[$group, $permission] = $this->split();
return match ($group) {
'control' => 'tabler-terminal-2',
'user' => 'tabler-users',
'file' => 'tabler-files',
'backup' => 'tabler-file-zip',
'allocation' => 'tabler-network',
'startup' => 'tabler-player-play',
'database' => 'tabler-database',
'schedule' => 'tabler-clock',
'settings' => 'tabler-settings',
'activity' => 'tabler-stack',
default => null,
};
}
}

View File

@ -1,34 +0,0 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
enum WebhookType: string implements HasColor, HasIcon, HasLabel
{
case Regular = 'regular';
case Discord = 'discord';
public function getLabel(): string
{
return trans('admin/webhook.' . $this->value);
}
public function getColor(): ?string
{
return match ($this) {
self::Regular => null,
self::Discord => 'blurple',
};
}
public function getIcon(): string
{
return match ($this) {
self::Regular => 'tabler-world-www',
self::Discord => 'tabler-brand-discord',
};
}
}

View File

@ -2,9 +2,9 @@
namespace App\Events;
use Illuminate\Support\Str;
use App\Models\ActivityLog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class ActivityLogged extends Event
{

View File

@ -0,0 +1,11 @@
<?php
namespace App\Events\Auth;
use App\Models\User;
use App\Events\Event;
class DirectLogin extends Event
{
public function __construct(public User $user, public bool $remember) {}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Events\Auth;
use App\Events\Event;
use Illuminate\Queue\SerializesModels;
class FailedPasswordReset extends Event
{
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public string $ip, public string $email) {}
}

View File

@ -2,8 +2,8 @@
namespace App\Events\Auth;
use App\Events\Event;
use App\Models\User;
use App\Events\Event;
class ProvidedAuthenticationToken extends Event
{

View File

@ -4,14 +4,13 @@ namespace App\Exceptions;
use Exception;
use Filament\Notifications\Notification;
use Illuminate\Container\Container;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Psr\Log\LoggerInterface;
use Illuminate\Http\Response;
use Illuminate\Container\Container;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable;
/**
* @deprecated
@ -29,7 +28,7 @@ class DisplayException extends PanelException implements HttpExceptionInterface
/**
* DisplayException constructor.
*/
public function __construct(string $message, ?Throwable $previous = null, protected string $level = self::LEVEL_ERROR, int $code = 0)
public function __construct(string $message, ?\Throwable $previous = null, protected string $level = self::LEVEL_ERROR, int $code = 0)
{
parent::__construct($message, $code, $previous);
}
@ -80,11 +79,11 @@ class DisplayException extends PanelException implements HttpExceptionInterface
* Log the exception to the logs using the defined error level only if the previous
* exception is set.
*
* @throws Throwable
* @throws \Throwable
*/
public function report(): void
{
if (!$this->getPrevious() instanceof Exception || !Handler::isReportable($this->getPrevious())) {
if (!$this->getPrevious() instanceof \Exception || !Handler::isReportable($this->getPrevious())) {
return;
}

View File

@ -2,27 +2,24 @@
namespace App\Exceptions;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Illuminate\Container\Container;
use Illuminate\Database\Connection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Foundation\Application;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Session\TokenMismatchException;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use PDOException;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\Mailer\Exception\TransportException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable;
class Handler extends ExceptionHandler
@ -82,7 +79,7 @@ class Handler extends ExceptionHandler
$this->dontReport = [];
}
$this->reportable(function (PDOException $ex) {
$this->reportable(function (\PDOException $ex) {
$ex = $this->generateCleanedExceptionStack($ex);
});
@ -91,7 +88,7 @@ class Handler extends ExceptionHandler
});
}
private function generateCleanedExceptionStack(Throwable $exception): string
private function generateCleanedExceptionStack(\Throwable $exception): string
{
$cleanedStack = '';
foreach ($exception->getTrace() as $index => $item) {
@ -120,11 +117,11 @@ class Handler extends ExceptionHandler
/**
* Render an exception into an HTTP response.
*
* @param Request $request
* @param \Illuminate\Http\Request $request
*
* @throws Throwable
* @throws \Throwable
*/
public function render($request, Throwable $e): Response
public function render($request, \Throwable $e): Response
{
$connections = $this->container->make(Connection::class);
@ -146,7 +143,7 @@ class Handler extends ExceptionHandler
* Transform a validation exception into a consistent format to be returned for
* calls to the API.
*
* @param Request $request
* @param \Illuminate\Http\Request $request
*/
public function invalidJson($request, ValidationException $exception): JsonResponse
{
@ -252,7 +249,7 @@ class Handler extends ExceptionHandler
/**
* Return an array of exceptions that should not be reported.
*/
public static function isReportable(Exception $exception): bool
public static function isReportable(\Exception $exception): bool
{
return (new self(Container::getInstance()))->shouldReport($exception);
}
@ -260,7 +257,7 @@ class Handler extends ExceptionHandler
/**
* Convert an authentication exception into an unauthenticated response.
*
* @param Request $request
* @param \Illuminate\Http\Request $request
*/
protected function unauthenticated($request, AuthenticationException $exception): JsonResponse|RedirectResponse
{
@ -294,7 +291,7 @@ class Handler extends ExceptionHandler
*
* @return array<mixed>
*/
public static function toArray(Throwable $e): array
public static function toArray(\Throwable $e): array
{
return self::exceptionToArray($e);
}

View File

@ -4,14 +4,13 @@ namespace App\Exceptions\Http;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;
class HttpForbiddenException extends HttpException
{
/**
* HttpForbiddenException constructor.
*/
public function __construct(?string $message = null, ?Throwable $previous = null)
public function __construct(?string $message = null, ?\Throwable $previous = null)
{
parent::__construct(Response::HTTP_FORBIDDEN, $message, $previous);
}

View File

@ -5,7 +5,6 @@ namespace App\Exceptions\Http\Server;
use App\Enums\ServerState;
use App\Models\Server;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Throwable;
class ServerStateConflictException extends ConflictHttpException
{
@ -13,7 +12,7 @@ class ServerStateConflictException extends ConflictHttpException
* Exception thrown when the server is in an unsupported state for API access or
* certain operations within the codebase.
*/
public function __construct(Server $server, ?Throwable $previous = null)
public function __construct(Server $server, ?\Throwable $previous = null)
{
$message = 'This server is currently in an unsupported state, please try again later.';
if ($server->isSuspended()) {

View File

@ -2,15 +2,13 @@
namespace App\Exceptions;
use App\Exceptions\Solutions\ManifestDoesNotExistSolution;
use Exception;
use Spatie\Ignition\Contracts\ProvidesSolution;
use Spatie\Ignition\Contracts\Solution;
use Spatie\Ignition\Contracts\ProvidesSolution;
class ManifestDoesNotExistException extends Exception implements ProvidesSolution
class ManifestDoesNotExistException extends \Exception implements ProvidesSolution
{
public function getSolution(): Solution
{
return new ManifestDoesNotExistSolution();
return new Solutions\ManifestDoesNotExistSolution();
}
}

View File

@ -2,11 +2,11 @@
namespace App\Exceptions\Model;
use Illuminate\Support\MessageBag;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Contracts\Validation\Validator;
use App\Exceptions\PanelException;
use Illuminate\Contracts\Support\MessageProvider;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\MessageBag;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class DataValidationException extends PanelException implements HttpExceptionInterface, MessageProvider

View File

@ -2,6 +2,4 @@
namespace App\Exceptions;
use Exception;
class PanelException extends Exception {}
class PanelException extends \Exception {}

View File

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

View File

@ -1,7 +0,0 @@
<?php
namespace App\Exceptions\Service\Deployment;
use App\Exceptions\DisplayException;
class NoViableNodeException extends DisplayException {}

View File

@ -2,8 +2,8 @@
namespace App\Exceptions\Service;
use App\Exceptions\DisplayException;
use Illuminate\Http\Response;
use App\Exceptions\DisplayException;
class HasActiveServersException extends DisplayException
{

View File

@ -3,7 +3,6 @@
namespace App\Exceptions\Service;
use App\Exceptions\DisplayException;
use Throwable;
class ServiceLimitExceededException extends DisplayException
{
@ -11,7 +10,7 @@ class ServiceLimitExceededException extends DisplayException
* Exception thrown when something goes over a defined limit, such as allocated
* ports, tasks, databases, etc.
*/
public function __construct(string $message, ?Throwable $previous = null)
public function __construct(string $message, ?\Throwable $previous = null)
{
parent::__construct($message, $previous, self::LEVEL_WARNING);
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Exceptions\Service\User;
use App\Exceptions\DisplayException;
class TwoFactorAuthenticationTokenInvalid extends DisplayException
{
public string $title = 'Invalid 2FA Code';
public string $icon = 'tabler-2fa';
public function __construct()
{
parent::__construct('The provided two-factor authentication token was not valid.');
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace App\Extensions\Avatar;
use Filament\AvatarProviders\Contracts\AvatarProvider as AvatarProviderContract;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
abstract class AvatarProvider implements AvatarProviderContract
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
public static function getProvider(string $id): ?self
{
return Arr::get(static::$providers, $id);
}
/**
* @return array<string, static>
*/
public static function getAll(): array
{
return static::$providers;
}
public function __construct()
{
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
public function getName(): string
{
return Str::title($this->getId());
}
}

View File

@ -1,14 +0,0 @@
<?php
namespace App\Extensions\Avatar;
use App\Models\User;
interface AvatarSchemaInterface
{
public function getId(): string;
public function getName(): string;
public function get(User $user): ?string;
}

View File

@ -1,55 +0,0 @@
<?php
namespace App\Extensions\Avatar;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
class AvatarService
{
/** @var AvatarSchemaInterface[] */
private array $schemas = [];
public function __construct(
private readonly bool $allowUploadedAvatars,
private readonly string $activeSchema,
) {}
public function get(string $id): ?AvatarSchemaInterface
{
return array_get($this->schemas, $id);
}
public function getActiveSchema(): ?AvatarSchemaInterface
{
return $this->get($this->activeSchema);
}
public function getAvatarUrl(User $user): ?string
{
if ($this->allowUploadedAvatars) {
$path = "avatars/$user->id.png";
if (Storage::disk('public')->exists($path)) {
return Storage::url($path);
}
}
return $this->getActiveSchema()?->get($user);
}
public function register(AvatarSchemaInterface $schema): void
{
if (array_key_exists($schema->getId(), $this->schemas)) {
return;
}
$this->schemas[$schema->getId()] = $schema;
}
/** @return array<string, string> */
public function getMappings(): array
{
return collect($this->schemas)->mapWithKeys(fn ($schema) => [$schema->getId() => $schema->getName()])->all();
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use App\Models\User;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
class GravatarProvider extends AvatarProvider
{
public function getId(): string
{
return 'gravatar';
}
public function get(Model|Authenticatable $record): string
{
/** @var User $record */
return 'https://gravatar.com/avatar/' . md5($record->email);
}
public static function register(): self
{
return new self();
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use Filament\AvatarProviders\UiAvatarsProvider as FilamentUiAvatarsProvider;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class LocalAvatarProvider extends AvatarProvider
{
public function getId(): string
{
return 'local';
}
public function get(Model|Authenticatable $record): string
{
$path = 'avatars/' . $record->getKey() . '.png';
return Storage::disk('public')->exists($path) ? Storage::url($path) : (new FilamentUiAvatarsProvider())->get($record);
}
public static function register(): self
{
return new self();
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Extensions\Avatar\Providers;
use App\Extensions\Avatar\AvatarProvider;
use Filament\AvatarProviders\UiAvatarsProvider as FilamentUiAvatarsProvider;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Model;
class UiAvatarsProvider extends AvatarProvider
{
public function getId(): string
{
return 'uiavatars';
}
public function getName(): string
{
return 'UI Avatars';
}
public function get(Model|Authenticatable $record): string
{
return (new FilamentUiAvatarsProvider())->get($record);
}
public static function register(): self
{
return new self();
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Extensions\Avatar\Schemas;
use App\Extensions\Avatar\AvatarSchemaInterface;
use App\Models\User;
class GravatarSchema implements AvatarSchemaInterface
{
public function getId(): string
{
return 'gravatar';
}
public function getName(): string
{
return 'Gravatar';
}
public function get(User $user): string
{
return 'https://gravatar.com/avatar/' . md5($user->email);
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Extensions\Avatar\Schemas;
use App\Extensions\Avatar\AvatarSchemaInterface;
use App\Models\User;
class UiAvatarsSchema implements AvatarSchemaInterface
{
public function getId(): string
{
return 'uiavatars';
}
public function getName(): string
{
return 'UI Avatars';
}
public function get(User $user): ?string
{
// UI Avatars is the default of filament so just return null here
return null;
}
}

View File

@ -2,16 +2,15 @@
namespace App\Extensions\Backups;
use App\Extensions\Filesystem\S3Filesystem;
use Aws\S3\S3Client;
use Closure;
use Illuminate\Foundation\Application;
use Aws\S3\S3Client;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;
use League\Flysystem\FilesystemAdapter;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
use Webmozart\Assert\Assert;
use Illuminate\Foundation\Application;
use League\Flysystem\FilesystemAdapter;
use App\Extensions\Filesystem\S3Filesystem;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
class BackupManager
{
@ -65,7 +64,7 @@ class BackupManager
$config = $this->getConfig($name);
if (empty($config['adapter'])) {
throw new InvalidArgumentException("Backup disk [$name] does not have a configured adapter.");
throw new \InvalidArgumentException("Backup disk [$name] does not have a configured adapter.");
}
$adapter = $config['adapter'];
@ -83,7 +82,7 @@ class BackupManager
return $instance;
}
throw new InvalidArgumentException("Adapter [$adapter] is not supported.");
throw new \InvalidArgumentException("Adapter [$adapter] is not supported.");
}
/**

View File

@ -1,48 +0,0 @@
<?php
namespace App\Extensions\Captcha;
use App\Extensions\Captcha\Schemas\CaptchaSchemaInterface;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class CaptchaService
{
/** @var array<string, CaptchaSchemaInterface> */
private array $schemas = [];
/**
* @return CaptchaSchemaInterface[]
*/
public function getAll(): array
{
return $this->schemas;
}
public function get(string $id): ?CaptchaSchemaInterface
{
return array_get($this->schemas, $id);
}
public function register(CaptchaSchemaInterface $schema): void
{
if (array_key_exists($schema->getId(), $this->schemas)) {
return;
}
config()->set('captcha.' . Str::lower($schema->getId()), $schema->getConfig());
$this->schemas[$schema->getId()] = $schema;
}
/** @return Collection<CaptchaSchemaInterface> */
public function getActiveSchemas(): Collection
{
return collect($this->schemas)
->filter(fn (CaptchaSchemaInterface $schema) => $schema->isEnabled());
}
public function getActiveSchema(): ?CaptchaSchemaInterface
{
return $this->getActiveSchemas()->first();
}
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Extensions\Captcha\Providers;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\TextInput;
use Illuminate\Foundation\Application;
use Illuminate\Support\Str;
abstract class CaptchaProvider
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
/**
* @return self|static[]
*/
public static function get(?string $id = null): array|self
{
return $id ? static::$providers[$id] : static::$providers;
}
protected function __construct(protected Application $app)
{
if (array_key_exists($this->getId(), static::$providers)) {
if (!$this->app->runningUnitTests()) {
logger()->warning("Tried to create duplicate Captcha provider with id '{$this->getId()}'");
}
return;
}
config()->set('captcha.' . Str::lower($this->getId()), $this->getConfig());
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
abstract public function getComponent(): Component;
/**
* @return array<string, string|string[]|bool|null>
*/
public function getConfig(): array
{
$id = Str::upper($this->getId());
return [
'site_key' => env("CAPTCHA_{$id}_SITE_KEY"),
'secret_key' => env("CAPTCHA_{$id}_SECRET_KEY"),
];
}
/**
* @return Component[]
*/
public function getSettingsForm(): array
{
$id = Str::upper($this->getId());
return [
TextInput::make("CAPTCHA_{$id}_SITE_KEY")
->label('Site Key')
->placeholder('Site Key')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("CAPTCHA_{$id}_SITE_KEY")),
TextInput::make("CAPTCHA_{$id}_SECRET_KEY")
->label('Secret Key')
->placeholder('Secret Key')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("CAPTCHA_{$id}_SECRET_KEY")),
];
}
public function getName(): string
{
return Str::title($this->getId());
}
public function getIcon(): ?string
{
return null;
}
public function isEnabled(): bool
{
$id = Str::upper($this->getId());
return env("CAPTCHA_{$id}_ENABLED", false);
}
/**
* @return array<string, string|bool>
*/
public function validateResponse(?string $captchaResponse = null): array
{
return [
'success' => false,
'message' => 'validateResponse not defined',
];
}
public function verifyDomain(string $hostname, ?string $requestUrl = null): bool
{
return true;
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Extensions\Captcha\Providers;
use App\Filament\Components\Forms\Fields\TurnstileCaptcha;
use Exception;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Toggle;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\HtmlString;
class TurnstileProvider extends CaptchaProvider
{
public function getId(): string
{
return 'turnstile';
}
public function getComponent(): Component
{
return TurnstileCaptcha::make('turnstile');
}
/**
* @return array<string, string|string[]|bool|null>
*/
public function getConfig(): array
{
return array_merge(parent::getConfig(), [
'verify_domain' => env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN'),
]);
}
/**
* @return Component[]
*/
public function getSettingsForm(): array
{
return array_merge(parent::getSettingsForm(), [
Toggle::make('CAPTCHA_TURNSTILE_VERIFY_DOMAIN')
->label(trans('admin/setting.captcha.verify'))
->columnSpan(2)
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->default(env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN', true)),
Placeholder::make('info')
->label(trans('admin/setting.captcha.info_label'))
->columnSpan(2)
->content(new HtmlString(trans('admin/setting.captcha.info'))),
]);
}
public function getIcon(): string
{
return 'tabler-brand-cloudflare';
}
public static function register(Application $app): self
{
return new self($app);
}
/**
* @return array<string, string|bool>
*/
public function validateResponse(?string $captchaResponse = null): array
{
$captchaResponse ??= request()->get('cf-turnstile-response');
if (!$secret = env('CAPTCHA_TURNSTILE_SECRET_KEY')) {
throw new Exception('Turnstile secret key is not defined.');
}
$response = Http::asJson()
->timeout(15)
->connectTimeout(5)
->throw()
->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => $secret,
'response' => $captchaResponse,
]);
return count($response->json()) ? $response->json() : [
'success' => false,
'message' => 'Unknown error occurred, please try again',
];
}
public function verifyDomain(string $hostname, ?string $requestUrl = null): bool
{
if (!env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN', true)) {
return true;
}
$requestUrl ??= request()->url;
$requestUrl = parse_url($requestUrl);
return $hostname === array_get($requestUrl, 'host');
}
}

View File

@ -1,59 +0,0 @@
<?php
namespace App\Extensions\Captcha\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Component;
use Illuminate\Support\Str;
abstract class BaseSchema
{
abstract public function getId(): string;
public function getName(): string
{
return Str::upper($this->getId());
}
/**
* @return array<string, string|string[]|bool|null>
*/
public function getConfig(): array
{
$id = Str::upper($this->getId());
return [
'site_key' => env("CAPTCHA_{$id}_SITE_KEY"),
'secret_key' => env("CAPTCHA_{$id}_SECRET_KEY"),
];
}
/**
* @return Component[]
*/
public function getSettingsForm(): array
{
$id = Str::upper($this->getId());
return [
TextInput::make("CAPTCHA_{$id}_SITE_KEY")
->label('Site Key')
->placeholder('Site Key')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("CAPTCHA_{$id}_SITE_KEY")),
TextInput::make("CAPTCHA_{$id}_SECRET_KEY")
->label('Secret Key')
->placeholder('Secret Key')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("CAPTCHA_{$id}_SECRET_KEY")),
];
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Extensions\Captcha\Schemas;
use Filament\Schemas\Components\Component;
interface CaptchaSchemaInterface
{
public function getId(): string;
public function getName(): string;
/**
* @return array<string, string|string[]|bool|null>
*/
public function getConfig(): array;
public function isEnabled(): bool;
public function getFormComponent(): Component;
/**
* @return Component[]
*/
public function getSettingsForm(): array;
public function getIcon(): ?string;
public function validateResponse(?string $captchaResponse = null): void;
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Extensions\Captcha\Schemas\Turnstile;
use App\Extensions\Captcha\CaptchaService;
use Closure;
use Exception;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\App;
class Rule implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
App::call(fn (CaptchaService $service) => $service->get('turnstile')->validateResponse($value));
} catch (Exception $exception) {
report($exception);
$fail('Captcha validation failed: ' . $exception->getMessage());
}
}
}

View File

@ -1,117 +0,0 @@
<?php
namespace App\Extensions\Captcha\Schemas\Turnstile;
use App\Extensions\Captcha\Schemas\BaseSchema;
use App\Extensions\Captcha\Schemas\CaptchaSchemaInterface;
use Exception;
use Filament\Forms\Components\Toggle;
use Filament\Infolists\Components\TextEntry;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\HtmlString;
class TurnstileSchema extends BaseSchema implements CaptchaSchemaInterface
{
public function getId(): string
{
return 'turnstile';
}
public function isEnabled(): bool
{
return env('CAPTCHA_TURNSTILE_ENABLED', false);
}
public function getFormComponent(): Component
{
return Component::make('turnstile');
}
/**
* @return array<string, string|string[]|bool|null>
*/
public function getConfig(): array
{
return array_merge(parent::getConfig(), [
'verify_domain' => env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN'),
]);
}
/**
* @return \Filament\Support\Components\Component[]
*
* @throws Exception
*/
public function getSettingsForm(): array
{
return array_merge(parent::getSettingsForm(), [
Toggle::make('CAPTCHA_TURNSTILE_VERIFY_DOMAIN')
->label(trans('admin/setting.captcha.verify'))
->columnSpan(2)
->inline(false)
->onIcon('tabler-check')
->offIcon('tabler-x')
->onColor('success')
->offColor('danger')
->default(env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN', true)),
TextEntry::make('info')
->label(trans('admin/setting.captcha.info_label'))
->columnSpan(2)
->state(new HtmlString(trans('admin/setting.captcha.info'))),
]);
}
public function getIcon(): ?string
{
return 'tabler-brand-cloudflare';
}
/**
* @throws Exception
*/
public function validateResponse(?string $captchaResponse = null): void
{
$captchaResponse ??= request()->get('cf-turnstile-response');
if (!$secret = env('CAPTCHA_TURNSTILE_SECRET_KEY')) {
throw new Exception('Turnstile secret key is not defined.');
}
$response = Http::asJson()
->timeout(15)
->connectTimeout(5)
->throw()
->post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'secret' => $secret,
'response' => $captchaResponse,
])
->json();
if (!$response['success']) {
match ($response['error-codes'][0] ?? null) {
'missing-input-secret' => throw new Exception('The secret parameter was not passed.'),
'invalid-input-secret' => throw new Exception('The secret parameter was invalid, did not exist, or is a testing secret key with a non-testing response.'),
'missing-input-response' => throw new Exception('The response parameter (token) was not passed.'),
'invalid-input-response' => throw new Exception('The response parameter (token) is invalid or has expired.'),
'bad-request' => throw new Exception('The request was rejected because it was malformed.'),
'timeout-or-duplicate' => throw new Exception('The response parameter (token) has already been validated before.'),
default => throw new Exception('An internal error happened while validating the response.'),
};
}
if (!$this->verifyDomain($response['hostname'] ?? '')) {
throw new Exception('Domain verification failed.');
}
}
private function verifyDomain(string $hostname): bool
{
if (!env('CAPTCHA_TURNSTILE_VERIFY_DOMAIN', true)) {
return true;
}
$requestUrl = parse_url(request()->url());
return $hostname === array_get($requestUrl, 'host');
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Extensions;
use App\Models\DatabaseHost;
class DynamicDatabaseConnection
{
public const DB_CHARSET = 'utf8';
public const DB_COLLATION = 'utf8_unicode_ci';
public const DB_DRIVER = 'mysql';
/**
* Adds a dynamic database connection entry to the runtime config.
*/
public function set(string $connection, DatabaseHost|int $host, string $database = 'mysql'): void
{
if (!$host instanceof DatabaseHost) {
$host = DatabaseHost::query()->findOrFail($host);
}
config()->set('database.connections.' . $connection, [
'driver' => self::DB_DRIVER,
'host' => $host->host,
'port' => $host->port,
'database' => $database,
'username' => $host->username,
'password' => $host->password,
'charset' => self::DB_CHARSET,
'collation' => self::DB_COLLATION,
]);
}
}

View File

@ -1,15 +0,0 @@
<?php
namespace App\Extensions\Features;
use Filament\Actions\Action;
interface FeatureSchemaInterface
{
/** @return string[] */
public function getListeners(): array;
public function getId(): string;
public function getAction(): Action;
}

View File

@ -1,52 +0,0 @@
<?php
namespace App\Extensions\Features;
class FeatureService
{
/** @var FeatureSchemaInterface[] */
private array $schemas = [];
/**
* @return FeatureSchemaInterface[]
*/
public function getAll(): array
{
return $this->schemas;
}
public function get(string $id): ?FeatureSchemaInterface
{
return array_get($this->schemas, $id);
}
/**
* @param ?string[] $features
* @return FeatureSchemaInterface[]
*/
public function getActiveSchemas(?array $features = []): array
{
return collect($this->schemas)->only($features)->all();
}
public function register(FeatureSchemaInterface $schema): void
{
if (array_key_exists($schema->getId(), $this->schemas)) {
return;
}
$this->schemas[$schema->getId()] = $schema;
}
/**
* @param ?string[] $features
* @return array<string, array<string>>
*/
public function getMappings(?array $features = []): array
{
return collect($this->getActiveSchemas($features))
->mapWithKeys(fn (FeatureSchemaInterface $schema) => [
$schema->getId() => $schema->getListeners(),
])->all();
}
}

View File

@ -1,119 +0,0 @@
<?php
namespace App\Extensions\Features\Schemas;
use App\Enums\SubuserPermission;
use App\Extensions\Features\FeatureSchemaInterface;
use App\Facades\Activity;
use App\Models\Server;
use App\Models\ServerVariable;
use App\Repositories\Daemon\DaemonServerRepository;
use Closure;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\HtmlString;
class GSLTokenSchema implements FeatureSchemaInterface
{
/** @return array<string> */
public function getListeners(): array
{
return [
'(gsl token expired)',
'(account not found)',
];
}
public function getId(): string
{
return 'gsl_token';
}
/**
* @throws Exception
*/
public function getAction(): Action
{
/** @var Server $server */
$server = Filament::getTenant();
/** @var ServerVariable $serverVariable */
$serverVariable = $server->serverVariables()->whereHas('variable', function (Builder $query) {
$query->where('env_variable', 'STEAM_ACC');
})->first();
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Invalid GSL token')
->modalDescription('It seems like your Gameserver Login Token (GSL token) is invalid or has expired.')
->modalSubmitActionLabel('Update GSL Token')
->disabledSchema(fn () => !user()?->can(SubuserPermission::StartupUpdate, $server))
->schema([
TextEntry::make('info')
->label(new HtmlString(Blade::render('You can either <x-filament::link href="https://steamcommunity.com/dev/managegameservers" target="_blank">generate a new one</x-filament::link> and enter it below or leave the field blank to remove it completely.'))),
TextInput::make('gsltoken')
->label('GSL Token')
->rules([
fn (): Closure => function (string $attribute, $value, Closure $fail) use ($serverVariable) {
$validator = Validator::make(['validatorkey' => $value], [
'validatorkey' => $serverVariable->variable->rules,
]);
if ($validator->fails()) {
$message = str($validator->errors()->first())->replace('validatorkey', $serverVariable->variable->name);
$fail($message);
}
},
])
->hintIcon('tabler-code', fn () => implode('|', $serverVariable->variable->rules))
->label(fn () => $serverVariable->variable->name)
->prefix(fn () => '{{' . $serverVariable->variable->env_variable . '}}')
->helperText(fn () => empty($serverVariable->variable->description) ? '—' : $serverVariable->variable->description),
])
->action(function (array $data, DaemonServerRepository $serverRepository) use ($server, $serverVariable) {
/** @var Server $server */
$server = Filament::getTenant();
try {
$new = $data['gsltoken'] ?? '';
$original = $serverVariable->variable_value;
$serverVariable->update([
'variable_value' => $new,
]);
if ($original !== $new) {
Activity::event('server:startup.edit')
->property([
'variable' => $serverVariable->variable->env_variable,
'old' => $original,
'new' => $new,
])
->log();
}
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title('GSL Token updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not update GSL Token')
->body($exception->getMessage())
->danger()
->send();
}
});
}
}

View File

@ -1,89 +0,0 @@
<?php
namespace App\Extensions\Features\Schemas;
use App\Enums\SubuserPermission;
use App\Extensions\Features\FeatureSchemaInterface;
use App\Facades\Activity;
use App\Models\Server;
use App\Repositories\Daemon\DaemonServerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification;
class JavaVersionSchema implements FeatureSchemaInterface
{
/** @return array<string> */
public function getListeners(): array
{
return [
'java.lang.UnsupportedClassVersionError',
'unsupported major.minor version',
'has been compiled by a more recent version of the java runtime',
'minecraft 1.17 requires running the server with java 16 or above',
'minecraft 1.18 requires running the server with java 17 or above',
'minecraft 1.19 requires running the server with java 17 or above',
];
}
public function getId(): string
{
return 'java_version';
}
public function getAction(): Action
{
/** @var Server $server */
$server = Filament::getTenant();
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Unsupported Java Version')
->modalDescription('This server is currently running an unsupported version of Java and cannot be started.')
->modalSubmitActionLabel('Update Docker Image')
->disabledSchema(fn () => !user()?->can(SubuserPermission::StartupDockerImage, $server))
->schema([
TextEntry::make('java')
->label('Please select a supported version from the list below to continue starting the server.'),
Select::make('image')
->label('Docker Image')
->disabled(fn () => !in_array($server->image, $server->egg->docker_images))
->options(fn () => collect($server->egg->docker_images)->mapWithKeys(fn ($key, $value) => [$key => $value]))
->selectablePlaceholder(false)
->default(fn () => $server->image)
->notIn(fn () => $server->image)
->required()
->preload(),
])
->action(function (array $data, DaemonServerRepository $serverRepository) use ($server) {
try {
$new = $data['image'];
$original = $server->image;
$server->forceFill(['image' => $new])->saveOrFail();
if ($original !== $server->image) {
Activity::event('server:startup.image')
->property(['old' => $original, 'new' => $new])
->log();
}
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title('Docker image updated')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not update docker image')
->body($exception->getMessage())
->danger()
->send();
}
});
}
}

View File

@ -1,61 +0,0 @@
<?php
namespace App\Extensions\Features\Schemas;
use App\Extensions\Features\FeatureSchemaInterface;
use App\Models\Server;
use App\Repositories\Daemon\DaemonFileRepository;
use App\Repositories\Daemon\DaemonServerRepository;
use Exception;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class MinecraftEulaSchema implements FeatureSchemaInterface
{
/** @return array<string> */
public function getListeners(): array
{
return [
'you need to agree to the eula in order to run the server',
];
}
public function getId(): string
{
return 'eula';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Minecraft EULA')
->modalDescription(new HtmlString(Blade::render('By pressing "I Accept" below you are indicating your agreement to the <x-filament::link href="https://minecraft.net/eula" target="_blank">Minecraft EULA </x-filament::link>.')))
->modalSubmitActionLabel('I Accept')
->action(function (DaemonFileRepository $fileRepository, DaemonServerRepository $serverRepository) {
try {
/** @var Server $server */
$server = Filament::getTenant();
$fileRepository->setServer($server)->putContent('eula.txt', 'eula=true');
$serverRepository->setServer($server)->power('restart');
Notification::make()
->title('Minecraft EULA accepted')
->body('Server will restart now.')
->success()
->send();
} catch (Exception $exception) {
Notification::make()
->title('Could not accept Minecraft EULA')
->body($exception->getMessage())
->danger()
->send();
}
});
}
}

View File

@ -1,66 +0,0 @@
<?php
namespace App\Extensions\Features\Schemas;
use App\Extensions\Features\FeatureSchemaInterface;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class PIDLimitSchema implements FeatureSchemaInterface
{
/** @return array<string> */
public function getListeners(): array
{
return [
'pthread_create failed',
'failed to create thread',
'unable to create thread',
'unable to create native thread',
'unable to create new native thread',
'exception in thread "craft async scheduler management thread"',
];
}
public function getId(): string
{
return 'pid_limit';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->icon('tabler-alert-triangle')
->modalHeading(fn () => user()?->isAdmin() ? 'Memory or process limit reached...' : 'Possible resource limit reached...')
->modalDescription(new HtmlString(Blade::render(
user()?->isAdmin() ? <<<'HTML'
<p>
This server has reached the maximum process or memory limit.
</p>
<p class="mt-4">
Increasing <code>container_pid_limit</code> in the wings
configuration, <code>config.yml</code>, might help resolve
this issue.
</p>
<p class="mt-4">
<b>Note: Wings must be restarted for the configuration file changes to take effect</b>
</p>
HTML
:
<<<'HTML'
<p>
This server is attempting to use more resources than allocated. Please contact the administrator
and give them the error below.
</p>
<p class="mt-4">
<code>
pthread_create failed, Possibly out of memory or process/resource limits reached
</code>
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Extensions\Features\Schemas;
use App\Extensions\Features\FeatureSchemaInterface;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
class SteamDiskSpaceSchema implements FeatureSchemaInterface
{
/** @return array<string> */
public function getListeners(): array
{
return [
'steamcmd needs 250mb of free disk space to update',
'0x202 after update job',
];
}
public function getId(): string
{
return 'steam_disk_space';
}
public function getAction(): Action
{
return Action::make($this->getId())
->requiresConfirmation()
->modalHeading('Out of available disk space...')
->modalDescription(new HtmlString(Blade::render(
user()?->isAdmin() ? <<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process.
</p>
<p class="mt-4">
Ensure the machine has enough disk space by typing{' '}
<code class="rounded py-1 px-2">df -h</code> on the machine hosting
this server. Delete files or increase the available disk space to resolve the issue.
</p>
HTML
:
<<<'HTML'
<p>
This server has run out of available disk space and cannot complete the install or update
process. Please get in touch with the administrator(s) and inform them of disk space issues.
</p>
HTML
)))
->modalCancelActionLabel('Close')
->action(fn () => null);
}
}

View File

@ -6,7 +6,7 @@ use App\Models\ApiKey;
use Laravel\Sanctum\NewAccessToken as SanctumAccessToken;
/**
* @property ApiKey $accessToken
* @property \App\Models\ApiKey $accessToken
*/
class NewAccessToken extends SanctumAccessToken
{

View File

@ -2,7 +2,6 @@
namespace App\Extensions\Lcobucci\JWT\Encoding;
use DateTimeImmutable;
use Lcobucci\JWT\ClaimsFormatter;
use Lcobucci\JWT\Token\RegisteredClaims;
@ -21,7 +20,7 @@ final class TimestampDates implements ClaimsFormatter
continue;
}
assert($claims[$claim] instanceof DateTimeImmutable);
assert($claims[$claim] instanceof \DateTimeImmutable);
$claims[$claim] = $claims[$claim]->getTimestamp();
}

View File

@ -1,39 +0,0 @@
<?php
namespace App\Extensions\OAuth;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Wizard\Step;
interface OAuthSchemaInterface
{
public function getId(): string;
public function getName(): string;
public function getConfigKey(): string;
/** @return ?class-string */
public function getSocialiteProvider(): ?string;
/**
* @return array<string, string|string[]|bool|null>
*/
public function getServiceConfig(): array;
/** @return Component[] */
public function getSettingsForm(): array;
/** @return Step[] */
public function getSetupSteps(): array;
public function getIcon(): ?string;
public function getHexColor(): ?string;
public function isEnabled(): bool;
public function shouldCreateMissingUsers(): bool;
public function shouldLinkMissingUsers(): bool;
}

View File

@ -1,71 +0,0 @@
<?php
namespace App\Extensions\OAuth;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Laravel\Socialite\Contracts\User as OAuthUser;
use SocialiteProviders\Manager\SocialiteWasCalled;
class OAuthService
{
/** @var OAuthSchemaInterface[] */
private array $schemas = [];
/** @return OAuthSchemaInterface[] */
public function getAll(): array
{
return $this->schemas;
}
public function get(string $id): ?OAuthSchemaInterface
{
return array_get($this->schemas, $id);
}
/** @return OAuthSchemaInterface[] */
public function getEnabled(): array
{
return collect($this->schemas)
->filter(fn (OAuthSchemaInterface $schema) => $schema->isEnabled())
->all();
}
public function register(OAuthSchemaInterface $schema): void
{
if (array_key_exists($schema->getId(), $this->schemas)) {
return;
}
config()->set('services.' . $schema->getId(), array_merge($schema->getServiceConfig(), ['redirect' => '/auth/oauth/callback/' . $schema->getId()]));
if ($schema->getSocialiteProvider()) {
Event::listen(fn (SocialiteWasCalled $event) => $event->extendSocialite($schema->getId(), $schema->getSocialiteProvider()));
}
$this->schemas[$schema->getId()] = $schema;
}
public function linkUser(User $user, OAuthSchemaInterface $schema, OAuthUser $oauthUser): User
{
$oauth = $user->oauth ?? [];
$oauth[$schema->getId()] = $oauthUser->getId();
$user->update(['oauth' => $oauth]);
return $user->refresh();
}
public function unlinkUser(User $user, OAuthSchemaInterface $schema): User
{
$oauth = $user->oauth ?? [];
if (!isset($oauth[$schema->getId()])) {
return $user;
}
unset($oauth[$schema->getId()]);
$user->update(['oauth' => $oauth]);
return $user->refresh();
}
}

View File

@ -1,50 +1,36 @@
<?php
namespace App\Extensions\OAuth\Schemas;
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use Illuminate\Foundation\Application;
use SocialiteProviders\Authentik\Provider;
final class AuthentikSchema extends OAuthSchema
final class AuthentikProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'authentik';
}
public function getSocialiteProvider(): string
public function getProviderClass(): string
{
return Provider::class;
}
public function getServiceConfig(): array
{
return array_merge(parent::getServiceConfig(), [
return [
'base_url' => env('OAUTH_AUTHENTIK_BASE_URL'),
]);
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Create Authentik Application')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>On your Authentik dashboard select <b>Applications</b>, then select <b>Create with Provider</b>.</p><p>On the creation step select <b>OAuth2/OpenID Provider</b> and on the configure step set <b>Redirect URIs/Origins</b> to the value below.</p>'))),
TextInput::make('_noenv_callback')
->label('Callback URL')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/authentik')),
]),
], parent::getSetupSteps());
'client_id' => env('OAUTH_AUTHENTIK_CLIENT_ID'),
'client_secret' => env('OAUTH_AUTHENTIK_CLIENT_SECRET'),
];
}
public function getSettingsForm(): array
@ -80,4 +66,9 @@ final class AuthentikSchema extends OAuthSchema
{
return env('OAUTH_AUTHENTIK_DISPLAY_COLOR', '#fd4b2d');
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Illuminate\Foundation\Application;
final class CommonProvider extends OAuthProvider
{
protected function __construct(protected Application $app, private string $id, private ?string $providerClass, private ?string $icon, private ?string $hexColor)
{
parent::__construct($app);
}
public function getId(): string
{
return $this->id;
}
public function getProviderClass(): ?string
{
return $this->providerClass;
}
public function getIcon(): ?string
{
return $this->icon;
}
public function getHexColor(): ?string
{
return $this->hexColor;
}
public static function register(Application $app, string $id, ?string $providerClass = null, ?string $icon = null, ?string $hexColor = null): static
{
return new self($app, $id, $providerClass, $icon, $hexColor);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use SocialiteProviders\Discord\Provider;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
final class DiscordProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'discord';
}
public function getProviderClass(): string
{
return Provider::class;
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Discord OAuth App')
->schema([
Placeholder::make('')
->content(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</x-filament::link> and click on <b>New Application</b>. Enter a <b>Name</b> (e.g. your panel name) and click on <b>Create</b>.</p><p>Copy the <b>Client ID</b> and the <b>Client Secret</b> from the OAuth2 tab, you will need them in the final step.</p>'))),
Placeholder::make('')
->content(new HtmlString('<p>Under <b>Redirects</b> add the below URL.</p>')),
TextInput::make('_noenv_callback')
->label('Redirect URL')
->dehydrated()
->disabled()
->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->formatStateUsing(fn () => url('/auth/oauth/callback/discord')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-discord-f';
}
public function getHexColor(): string
{
return '#5865F2';
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
final class GithubProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'github';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Github OAuth App')
->schema([
Placeholder::make('')
->content(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://github.com/settings/developers" target="_blank">Github Developer Dashboard</x-filament::link>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>'))),
TextInput::make('_noenv_callback')
->label('Authorization callback URL')
->dehydrated()
->disabled()
->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->default(fn () => url('/auth/oauth/callback/github')),
Placeholder::make('')
->content(new HtmlString('<p>When you filled all fields click on <b>Register application</b>.</p>')),
]),
Step::make('Create Client Secret')
->schema([
Placeholder::make('')
->content(new HtmlString('<p>Once you registered your app, generate a new <b>Client Secret</b>.</p><p>You will also need the <b>Client ID</b>.</p>')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-github-f';
}
public function getHexColor(): string
{
return '#4078c0';
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -1,15 +1,22 @@
<?php
namespace App\Extensions\OAuth\Schemas;
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use Webbingbrasil\FilamentCopyActions\Forms\Actions\CopyAction;
final class GitlabSchema extends OAuthSchema
final class GitlabProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'gitlab';
@ -40,14 +47,13 @@ final class GitlabSchema extends OAuthSchema
return array_merge([
Step::make('Register new Gitlab OAuth App')
->schema([
TextEntry::make('register_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('Check out the <x-filament::link href="https://docs.gitlab.com/integration/oauth_provider/" target="_blank">Gitlab docs</x-filament::link> on how to create the oauth app.'))),
Placeholder::make('')
->content(new HtmlString(Blade::render('Check out the <x-filament::link href="https://docs.gitlab.com/integration/oauth_provider/" target="_blank">Gitlab docs</x-filament::link> on how to create the oauth app.'))),
TextInput::make('_noenv_callback')
->label('Redirect URI')
->dehydrated()
->disabled()
->hintCopy()
->hintAction(fn (string $state) => request()->isSecure() ? CopyAction::make()->copyable($state) : null)
->default(fn () => url('/auth/oauth/callback/gitlab')),
]),
], parent::getSetupSteps());
@ -62,4 +68,9 @@ final class GitlabSchema extends OAuthSchema
{
return '#fca326';
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -0,0 +1,131 @@
<?php
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Component;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
use SocialiteProviders\Manager\SocialiteWasCalled;
abstract class OAuthProvider
{
/**
* @var array<string, static>
*/
protected static array $providers = [];
/**
* @return self|static[]
*/
public static function get(?string $id = null): array|self
{
return $id ? static::$providers[$id] : static::$providers;
}
protected function __construct(protected Application $app)
{
if (array_key_exists($this->getId(), static::$providers)) {
if (!$this->app->runningUnitTests()) {
logger()->warning("Tried to create duplicate OAuth provider with id '{$this->getId()}'");
}
return;
}
config()->set('services.' . $this->getId(), array_merge($this->getServiceConfig(), ['redirect' => '/auth/oauth/callback/' . $this->getId()]));
if ($this->getProviderClass()) {
Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite($this->getId(), $this->getProviderClass());
});
}
static::$providers[$this->getId()] = $this;
}
abstract public function getId(): string;
public function getProviderClass(): ?string
{
return null;
}
/**
* @return array<string, string|string[]|bool|null>
*/
public function getServiceConfig(): array
{
$id = Str::upper($this->getId());
return [
'client_id' => env("OAUTH_{$id}_CLIENT_ID"),
'client_secret' => env("OAUTH_{$id}_CLIENT_SECRET"),
];
}
/**
* @return Component[]
*/
public function getSettingsForm(): array
{
$id = Str::upper($this->getId());
return [
TextInput::make("OAUTH_{$id}_CLIENT_ID")
->label('Client ID')
->placeholder('Client ID')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("OAUTH_{$id}_CLIENT_ID")),
TextInput::make("OAUTH_{$id}_CLIENT_SECRET")
->label('Client Secret')
->placeholder('Client Secret')
->columnSpan(2)
->required()
->password()
->revealable()
->autocomplete(false)
->default(env("OAUTH_{$id}_CLIENT_SECRET")),
];
}
/**
* @return Step[]
*/
public function getSetupSteps(): array
{
return [
Step::make('OAuth Config')
->columns(4)
->schema($this->getSettingsForm()),
];
}
public function getName(): string
{
return Str::title($this->getId());
}
public function getIcon(): ?string
{
return null;
}
public function getHexColor(): ?string
{
return null;
}
public function isEnabled(): bool
{
$id = Str::upper($this->getId());
return env("OAUTH_{$id}_ENABLED", false);
}
}

View File

@ -1,22 +1,28 @@
<?php
namespace App\Extensions\OAuth\Schemas;
namespace App\Extensions\OAuth\Providers;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Forms\Components\Wizard\Step;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use SocialiteProviders\Steam\Provider;
final class SteamSchema extends OAuthSchema
final class SteamProvider extends OAuthProvider
{
public function __construct(protected Application $app)
{
parent::__construct($app);
}
public function getId(): string
{
return 'steam';
}
public function getSocialiteProvider(): string
public function getProviderClass(): string
{
return Provider::class;
}
@ -52,9 +58,8 @@ final class SteamSchema extends OAuthSchema
return array_merge([
Step::make('Create API Key')
->schema([
TextEntry::make('create_api_key')
->hiddenLabel()
->state(new HtmlString(Blade::render('Visit <x-filament::link href="https://steamcommunity.com/dev/apikey" target="_blank">https://steamcommunity.com/dev/apikey</x-filament::link> to generate an API key.'))),
Placeholder::make('')
->content(new HtmlString(Blade::render('Visit <x-filament::link href="https://steamcommunity.com/dev/apikey" target="_blank">https://steamcommunity.com/dev/apikey</x-filament::link> to generate an API key.'))),
]),
], parent::getSetupSteps());
}
@ -68,4 +73,9 @@ final class SteamSchema extends OAuthSchema
{
return '#00adee';
}
public static function register(Application $app): self
{
return new self($app);
}
}

View File

@ -1,45 +0,0 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class BitbucketSchema extends OAuthSchema
{
public function getId(): string
{
return 'bitbucket';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Bitbucket Consumer')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud" target="_blank">Bitbucket OAuth Documentation</x-filament::link> and follow the steps in <b>Create a consumer</b>.</p><p>For the <b>Callback URL</b> use the value below.</p>'))),
TextInput::make('_noenv_callback')
->label('Callback URL')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/bitbucket')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-bitbucket-f';
}
public function getHexColor(): string
{
return '#205081';
}
}

View File

@ -1,39 +0,0 @@
<?php
namespace App\Extensions\OAuth\Schemas;
final class CommonSchema extends OAuthSchema
{
public function __construct(
private readonly string $id,
private readonly ?string $name = null,
private readonly ?string $configName = null,
private readonly ?string $icon = null,
private readonly ?string $hexColor = null,
) {}
public function getId(): string
{
return $this->id;
}
public function getName(): string
{
return $this->name ?? parent::getName();
}
public function getConfigKey(): string
{
return $this->configName ?? parent::getConfigKey();
}
public function getIcon(): ?string
{
return $this->icon;
}
public function getHexColor(): ?string
{
return $this->hexColor;
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
use SocialiteProviders\Discord\Provider;
final class DiscordSchema extends OAuthSchema
{
public function getId(): string
{
return 'discord';
}
public function getSocialiteProvider(): string
{
return Provider::class;
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Discord OAuth App')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://discord.com/developers/applications" target="_blank">Discord Developer Portal</x-filament::link> and click on <b>New Application</b>. Enter a <b>Name</b> (e.g. your panel name) and click on <b>Create</b>.</p><p>Copy the <b>Client ID</b> and the <b>Client Secret</b> from the OAuth2 tab, you will need them in the final step.</p>'))),
TextEntry::make('set_redirect')
->hiddenLabel()
->state(new HtmlString('<p>Under <b>Redirects</b> add the below URL.</p>')),
TextInput::make('_noenv_callback')
->label('Redirect URL')
->dehydrated()
->disabled()
->hintCopy()
->formatStateUsing(fn () => url('/auth/oauth/callback/discord')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-discord-f';
}
public function getHexColor(): string
{
return '#5865F2';
}
}

View File

@ -1,48 +0,0 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class FacebookSchema extends OAuthSchema
{
public function getId(): string
{
return 'facebook';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Facebook Application')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://developers.facebook.com/apps" target="_blank">Facebook Developer Dashboard</x-filament::link> and select or create a new app you will use for authentication. Make sure to have "Authenticate and request data from users with Facebook Login" as one of the Use Cases.</p><p>Once selected go to <b>Use Cases</b> and customize "Authenticate and request data from users with Facebook Login", from there go to <b>Settings</b> and add <b>Valid OAuth Redirect URIs</b> using the value below.</p>'))),
TextInput::make('_noenv_callback')
->label('Valid OAuth Redirect URIs')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/facebook')),
TextEntry::make('get_app_info')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>To obtain the OAuth values go to <b>App Settings > Basic</b>.</p>'))),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-facebook-f';
}
public function getHexColor(): string
{
return '#1877f2';
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class GithubSchema extends OAuthSchema
{
public function getId(): string
{
return 'github';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new Github OAuth App')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://github.com/settings/developers" target="_blank">Github Developer Dashboard</x-filament::link>, go to <b>OAuth Apps</b> and click on <b>New OAuth App</b>.</p><p>Enter an <b>Application name</b> (e.g. your panel name), set <b>Homepage URL</b> to your panel url and enter the below url as <b>Authorization callback URL</b>.</p>'))),
TextInput::make('_noenv_callback')
->label('Authorization callback URL')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/github')),
TextEntry::make('register_application')
->hiddenLabel()
->state(new HtmlString('<p>When you filled all fields click on <b>Register application</b>.</p>')),
]),
Step::make('Create Client Secret')
->schema([
TextEntry::make('create_client_secret')
->hiddenLabel()
->state(new HtmlString('<p>Once you registered your app, generate a new <b>Client Secret</b>.</p><p>You will also need the <b>Client ID</b>.</p>')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-github-f';
}
public function getHexColor(): string
{
return '#4078c0';
}
}

View File

@ -1,54 +0,0 @@
<?php
namespace App\Extensions\OAuth\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Wizard\Step;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\HtmlString;
final class GoogleSchema extends OAuthSchema
{
public function getId(): string
{
return 'google';
}
public function getSetupSteps(): array
{
return array_merge([
Step::make('Register new OAuth client')
->schema([
TextEntry::make('create_application')
->hiddenLabel()
->state(new HtmlString(Blade::render('<p>Visit the <x-filament::link href="https://console.developers.google.com/" target="_blank">Google API Console</x-filament::link> and create or select the project you want to use.</p><p>Navigate or search <b>Credentials</b>, click on the <b>Create Credentials</b> button and select <b>OAuth client ID</b>. On the Application type select <b>Web Application</b>.</p><p>On <b>Authorized JavaScript origins</b> and <b>Authorized redirect URIs</b> add and use the values below.</p>'))),
TextInput::make('_noenv_origin')
->label('Authorized JavaScript origins')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('')),
TextInput::make('_noenv_callback')
->label('Authorized redirect URIs')
->dehydrated()
->disabled()
->hintCopy()
->default(fn () => url('/auth/oauth/callback/google')),
TextEntry::make('register_application')
->hiddenLabel()
->state(new HtmlString('<p>When you filled all fields click on <b>Create</b>.</p>')),
]),
], parent::getSetupSteps());
}
public function getIcon(): string
{
return 'tabler-brand-google-f';
}
public function getHexColor(): string
{
return '#4285f4';
}
}

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