Merge branch 'main' into issue/68

# Conflicts:
#	app/Filament/Resources/EggResource/RelationManagers/ServersRelationManager.php
#	app/Filament/Resources/NodeResource/RelationManagers/AllocationsRelationManager.php
#	app/Filament/Resources/NodeResource/RelationManagers/NodesRelationManager.php
#	app/Filament/Resources/ServerResource/Pages/CreateServer.php
#	app/Filament/Resources/ServerResource/Pages/ListServers.php
#	app/Filament/Resources/ServerResource/RelationManagers/AllocationsRelationManager.php
#	app/Filament/Resources/UserResource/RelationManagers/ServersRelationManager.php
#	app/Models/Allocation.php
#	app/Models/ApiKey.php
#	app/Models/Server.php
#	app/Models/User.php
This commit is contained in:
Lance Pioch 2024-06-15 05:21:58 -04:00
commit 0bd2935885
61 changed files with 1329 additions and 481 deletions

View File

@ -1,6 +1,9 @@
name: Build
on:
push:
branches:
- '**'
pull_request:
branches:
- '**'

View File

@ -1,6 +1,9 @@
name: Tests
on:
push:
branches:
- '**'
pull_request:
branches:
- '**'
@ -13,7 +16,7 @@ jobs:
fail-fast: false
matrix:
php: [8.2, 8.3]
database: ["mariadb:10.2", "mysql:8"]
database: ["mysql:8"]
services:
database:
image: ${{ matrix.database }}
@ -78,6 +81,78 @@ jobs:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
mariadb:
name: MariaDB
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: [8.2, 8.3]
database: ["mariadb:10.3", "mariadb:10.11", "mariadb:11.4"]
services:
database:
image: ${{ matrix.database }}
env:
MYSQL_ALLOW_EMPTY_PASSWORD: yes
MYSQL_DATABASE: testing
ports:
- 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/
APP_ENVIRONMENT_ONLY: "true"
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
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 --prefer-dist
- name: Unit tests
run: vendor/bin/phpunit tests/Unit
env:
DB_HOST: UNIT_NO_DB
SKIP_MIGRATIONS: true
- name: Integration tests
run: vendor/bin/phpunit tests/Integration
env:
DB_PORT: ${{ job.services.database.ports[3306] }}
DB_USERNAME: root
sqlite:
name: SQLite
runs-on: ubuntu-latest

View File

@ -6,8 +6,8 @@ on:
- '**'
jobs:
lint:
name: Lint
pint:
name: Pint
runs-on: ubuntu-latest
steps:
- name: Code Checkout
@ -16,7 +16,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.2"
php-version: "8.3"
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
@ -29,3 +29,26 @@ jobs:
- name: Pint
run: vendor/bin/pint --test
phpstan:
name: PHPStan
runs-on: ubuntu-latest
steps:
- name: Code Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
coverage: none
- name: Setup .env
run: cp .env.example .env
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist
- name: PHPStan
run: vendor/bin/phpstan --memory-limit=-1

View File

@ -54,31 +54,12 @@ jobs:
- name: Create release
id: create_release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
draft: true
prerelease: ${{ contains(github.ref, 'rc') || contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
- name: Upload release archive
id: upload-release-archive
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: panel.tar.gz
asset_name: panel.tar.gz
asset_content_type: application/gzip
- name: Upload release checksum
id: upload-release-checksum
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./checksum.txt
asset_name: checksum.txt
asset_content_type: text/plain
files: |
panel.tar.gz
checksum.txt

View File

@ -13,6 +13,7 @@ class DatabaseSettingsCommand extends Command
public const DATABASE_DRIVERS = [
'sqlite' => 'SQLite (recommended)',
'mariadb' => 'MariaDB',
'mysql' => 'MySQL',
];
@ -21,10 +22,10 @@ class DatabaseSettingsCommand extends Command
protected $signature = 'p:environment:database
{--driver= : The database driver backend to use.}
{--database= : The database to use.}
{--host= : The connection address for the MySQL server.}
{--port= : The connection port for the MySQL server.}
{--username= : Username to use when connecting to the MySQL server.}
{--password= : Password to use for the MySQL database.}';
{--host= : The connection address for the MySQL/ MariaDB server.}
{--port= : The connection port for the MySQL/ MariaDB server.}
{--username= : Username to use when connecting to the MySQL/ MariaDB server.}
{--password= : Password to use for the MySQL/ MariaDB database.}';
protected array $variables = [];
@ -82,7 +83,20 @@ class DatabaseSettingsCommand extends Command
}
try {
$this->testMySQLConnection();
// Test connection
config()->set('database.connections._panel_command_test', [
'driver' => 'mysql',
'host' => $this->variables['DB_HOST'],
'port' => $this->variables['DB_PORT'],
'database' => $this->variables['DB_DATABASE'],
'username' => $this->variables['DB_USERNAME'],
'password' => $this->variables['DB_PASSWORD'],
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
]);
$this->database->connection('_panel_command_test')->getPdo();
} 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(__('commands.database_settings.DB_error_2'));
@ -93,6 +107,66 @@ class DatabaseSettingsCommand extends Command
return $this->handle();
}
return 1;
}
} elseif ($this->variables['DB_CONNECTION'] === 'mariadb') {
$this->output->note(__('commands.database_settings.DB_HOST_note'));
$this->variables['DB_HOST'] = $this->option('host') ?? $this->ask(
'Database Host',
config('database.connections.mariadb.host', '127.0.0.1')
);
$this->variables['DB_PORT'] = $this->option('port') ?? $this->ask(
'Database Port',
config('database.connections.mariadb.port', 3306)
);
$this->variables['DB_DATABASE'] = $this->option('database') ?? $this->ask(
'Database Name',
config('database.connections.mariadb.database', 'panel')
);
$this->output->note(__('commands.database_settings.DB_USERNAME_note'));
$this->variables['DB_USERNAME'] = $this->option('username') ?? $this->ask(
'Database Username',
config('database.connections.mariadb.username', 'pelican')
);
$askForMariaDBPassword = true;
if (!empty(config('database.connections.mariadb.password')) && $this->input->isInteractive()) {
$this->variables['DB_PASSWORD'] = config('database.connections.mariadb.password');
$askForMariaDBPassword = $this->confirm(__('commands.database_settings.DB_PASSWORD_note'));
}
if ($askForMariaDBPassword) {
$this->variables['DB_PASSWORD'] = $this->option('password') ?? $this->secret('Database Password');
}
try {
// Test connection
config()->set('database.connections._panel_command_test', [
'driver' => 'mariadb',
'host' => $this->variables['DB_HOST'],
'port' => $this->variables['DB_PORT'],
'database' => $this->variables['DB_DATABASE'],
'username' => $this->variables['DB_USERNAME'],
'password' => $this->variables['DB_PASSWORD'],
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
]);
$this->database->connection('_panel_command_test')->getPdo();
} 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(__('commands.database_settings.DB_error_2'));
if ($this->confirm(__('commands.database_settings.go_back'))) {
$this->database->disconnect('_panel_command_test');
return $this->handle();
}
return 1;
}
} elseif ($this->variables['DB_CONNECTION'] === 'sqlite') {
@ -108,24 +182,4 @@ class DatabaseSettingsCommand extends Command
return 0;
}
/**
* Test that we can connect to the provided MySQL instance and perform a selection.
*/
private function testMySQLConnection()
{
config()->set('database.connections._panel_command_test', [
'driver' => 'mysql',
'host' => $this->variables['DB_HOST'],
'port' => $this->variables['DB_PORT'],
'database' => $this->variables['DB_DATABASE'],
'username' => $this->variables['DB_USERNAME'],
'password' => $this->variables['DB_PASSWORD'],
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'strict' => true,
]);
$this->database->connection('_panel_command_test')->getPdo();
}
}

View File

@ -7,12 +7,12 @@ use App\Services\Helpers\SoftwareVersionService;
class InfoCommand extends Command
{
protected $description = 'Displays the application, database, and email configurations along with the panel version.';
protected $description = 'Displays the application, database, email and backup configurations along with the panel version.';
protected $signature = 'p:info';
/**
* VersionCommand constructor.
* InfoCommand constructor.
*/
public function __construct(private SoftwareVersionService $versionService)
{
@ -33,19 +33,25 @@ class InfoCommand extends Command
$this->output->title('Application Configuration');
$this->table([], [
['Environment', $this->formatText(config('app.env'), config('app.env') === 'production' ?: 'bg=red')],
['Debug Mode', $this->formatText(config('app.debug') ? 'Yes' : 'No', !config('app.debug') ?: 'bg=red')],
['Installation URL', config('app.url')],
['Environment', config('app.env') === 'production' ? config('app.env') : $this->formatText(config('app.env'), 'bg=red')],
['Debug Mode', config('app.debug') ? $this->formatText('Yes', 'bg=red') : 'No'],
['Application Name', config('app.name')],
['Application URL', config('app.url')],
['Installation Directory', base_path()],
['Cache Driver', config('cache.default')],
['Queue Driver', config('queue.default')],
['Queue Driver', config('queue.default') === 'sync' ? $this->formatText(config('queue.default'), 'bg=red') : config('queue.default')],
['Session Driver', config('session.driver')],
['Filesystem Driver', config('filesystems.default')],
['Default Theme', config('themes.active')],
], 'compact');
$this->output->title('Database Configuration');
$driver = config('database.default');
if ($driver === 'sqlite') {
$this->table([], [
['Driver', $driver],
['Database', config("database.connections.$driver.database")],
], 'compact');
} else {
$this->table([], [
['Driver', $driver],
['Host', config("database.connections.$driver.host")],
@ -53,18 +59,43 @@ class InfoCommand extends Command
['Database', config("database.connections.$driver.database")],
['Username', config("database.connections.$driver.username")],
], 'compact');
}
// TODO: Update this to handle other mail drivers
$this->output->title('Email Configuration');
$driver = config('mail.default');
if ($driver === 'smtp') {
$this->table([], [
['Driver', config('mail.default')],
['Host', config('mail.mailers.smtp.host')],
['Port', config('mail.mailers.smtp.port')],
['Username', config('mail.mailers.smtp.username')],
['Driver', $driver],
['Host', config("mail.mailers.$driver.host")],
['Port', config("mail.mailers.$driver.port")],
['Username', config("mail.mailers.$driver.username")],
['Encryption', config("mail.mailers.$driver.encryption")],
['From Address', config('mail.from.address')],
['From Name', config('mail.from.name')],
['Encryption', config('mail.mailers.smtp.encryption')],
], 'compact');
} else {
$this->table([], [
['Driver', $driver],
['From Address', config('mail.from.address')],
['From Name', config('mail.from.name')],
], 'compact');
}
$this->output->title('Backup Configuration');
$driver = config('backups.default');
if ($driver === 's3') {
$this->table([], [
['Driver', $driver],
['Region', config("backups.disks.$driver.region")],
['Bucket', config("backups.disks.$driver.bucket")],
['Endpoint', config("backups.disks.$driver.endpoint")],
['Use path style endpoint', config("backups.disks.$driver.use_path_style_endpoint") ? 'Yes' : 'No'],
], 'compact');
} else {
$this->table([], [
['Driver', $driver],
], 'compact');
}
}
/**

View File

@ -215,7 +215,7 @@ class Handler extends ExceptionHandler
->map(fn ($trace) => Arr::except($trace, ['args']))
->all(),
'previous' => Collection::make($this->extractPrevious($e))
->map(fn ($exception) => $e->getTrace())
->map(fn ($exception) => $exception->getTrace())
->map(fn ($trace) => Arr::except($trace, ['args']))
->all(),
],

View File

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

View File

@ -71,9 +71,7 @@ class CreateApiKey extends CreateRecord
->placeholder('Example: 127.0.0.1 or 192.168.1.1')
->label('Whitelisted IPv4 Addresses')
->helperText('Press enter to add a new IP address or leave blank to allow any IP address')
->columnSpanFull()
->hidden()
->default(null),
->columnSpanFull(),
Forms\Components\Textarea::make('memo')
->required()

View File

@ -7,6 +7,7 @@ use App\Models\Egg;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use AbdelhamidErrahmouni\FilamentMonacoEditor\MonacoEditor;
use App\Services\Eggs\Sharing\EggExporterService;
use Filament\Forms;
use Filament\Forms\Form;
@ -201,12 +202,13 @@ class EditEgg extends EditRecord
Actions\DeleteAction::make()
->disabled(fn (Egg $egg): bool => $egg->servers()->count() > 0)
->label(fn (Egg $egg): string => $egg->servers()->count() <= 0 ? 'Delete Egg' : 'Egg In Use'),
Actions\ExportAction::make()
Actions\Action::make('export')
->icon('tabler-download')
->label('Export Egg')
->color('primary')
// TODO uses old admin panel export service
->url(fn (Egg $egg): string => route('admin.eggs.export', ['egg' => $egg['id']])),
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')),
$this->getSaveFormAction()->formId('form'),
];
}

View File

@ -4,6 +4,7 @@ namespace App\Filament\Resources\EggResource\Pages;
use App\Filament\Resources\EggResource;
use App\Models\Egg;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use Exception;
use Filament\Actions;
@ -22,19 +23,20 @@ class ListEggs extends ListRecords
public function table(Table $table): Table
{
return $table
->searchable(false)
->searchable(true)
->defaultPaginationPageOption(25)
->checkIfRecordIsSelectableUsing(fn (Egg $egg) => $egg->servers_count <= 0)
->columns([
Tables\Columns\TextColumn::make('id')
->label('Id')
->hidden()
->searchable(),
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('name')
->icon('tabler-egg')
->description(fn ($record): ?string => (strlen($record->description) > 120) ? substr($record->description, 0, 120).'...' : $record->description)
->wrap()
->searchable(),
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('servers_count')
->counts('servers')
->icon('tabler-server')
@ -42,12 +44,13 @@ class ListEggs extends ListRecords
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\ExportAction::make()
Tables\Actions\Action::make('export')
->icon('tabler-download')
->label('Export')
->color('primary')
// TODO uses old admin panel export service
->url(fn (Egg $egg): string => route('admin.eggs.export', ['egg' => $egg])),
->action(fn (EggExporterService $service, Egg $egg) => response()->streamDownload(function () use ($service, $egg) {
echo $service->handle($egg->id);
}, 'egg-' . $egg->getKebabName() . '.json')),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
@ -88,7 +91,6 @@ class ListEggs extends ListRecords
])
->action(function (array $data): void {
/** @var EggImporterService $eggImportService */
$eggImportService = resolve(EggImporterService::class);

View File

@ -212,6 +212,7 @@ class CreateNode extends CreateRecord
false => 'success',
]),
Forms\Components\ToggleButtons::make('public')
->default(true)
->columnSpan(1)
->label('Automatic Allocation')->inline()
->options([
@ -238,7 +239,7 @@ class CreateNode extends CreateRecord
->default(256)
->minValue(1)
->maxValue(1024)
->suffix('MiB'),
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
Forms\Components\TextInput::make('daemon_sftp')
->columnSpan(1)
->label('SFTP Port')
@ -274,7 +275,7 @@ class CreateNode extends CreateRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->columnSpan(2)
->numeric()
->minValue(0)
@ -315,7 +316,7 @@ class CreateNode extends CreateRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Limit')->inlineLabel()
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->columnSpan(2)
->numeric()
->minValue(0)

View File

@ -214,7 +214,7 @@ class EditNode extends EditRecord
->numeric()->required()
->minValue(1)
->maxValue(1024)
->suffix('MiB'),
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB'),
Forms\Components\TextInput::make('daemon_sftp')
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 3])
->label('SFTP Port')
@ -274,7 +274,7 @@ class EditNode extends EditRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
->numeric()
@ -314,7 +314,7 @@ class EditNode extends EditRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Limit')->inlineLabel()
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required()
->columnSpan(['default' => 1, 'sm' => 1, 'md' => 1, 'lg' => 2])
->numeric()
@ -395,10 +395,11 @@ class EditNode extends EditRecord
->requiresConfirmation()
->modalHeading('Reset Daemon Token?')
->modalDescription('Resetting the daemon token will void any request coming from the old token. This token is used for all sensitive operations on the daemon including server creation and deletion. We suggest changing this token regularly for security.')
->action(fn (NodeUpdateService $nodeUpdateService, Node $node) => $nodeUpdateService->handle($node, [], true)
&& Notification::make()->success()->title('Daemon Key Reset')->send()
&& $this->fillForm()
),
->action(function (NodeUpdateService $nodeUpdateService, Node $node) {
$nodeUpdateService->handle($node, [], true);
Notification::make()->success()->title('Daemon Key Reset')->send();
$this->fillForm();
}),
]),
]),
]),

View File

@ -42,15 +42,15 @@ class ListNodes extends ListRecords
->visibleFrom('sm')
->icon('tabler-device-desktop-analytics')
->numeric()
->suffix(' GiB')
->formatStateUsing(fn ($state) => number_format($state / 1024, 2))
->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB')
->formatStateUsing(fn ($state) => number_format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), 2))
->sortable(),
Tables\Columns\TextColumn::make('disk')
->visibleFrom('sm')
->icon('tabler-file')
->numeric()
->suffix(' GiB')
->formatStateUsing(fn ($state) => number_format($state / 1024, 2))
->suffix(config('panel.use_binary_prefix') ? ' GiB' : ' GB')
->formatStateUsing(fn ($state) => number_format($state / (config('panel.use_binary_prefix') ? 1024 : 1000), 2))
->sortable(),
Tables\Columns\TextColumn::make('cpu')
->visibleFrom('sm')

View File

@ -290,7 +290,6 @@ class CreateServer extends CreateRecord
$components = [$text, $select];
/** @var Forms\Components\Component $component */
foreach ($components as &$component) {
$component = $component
->live(onBlur: true)
@ -354,7 +353,7 @@ class CreateServer extends CreateRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->default(0)
->required()
->columnSpan(2)
@ -385,7 +384,7 @@ class CreateServer extends CreateRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel()
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->default(0)
->required()
->columnSpan(2)
@ -441,6 +440,7 @@ class CreateServer extends CreateRecord
'unlimited' => -1,
'disabled' => 0,
'limited' => 128,
default => throw new \LogicException('Invalid state'),
};
$set('swap', $value);
@ -460,11 +460,11 @@ class CreateServer extends CreateRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => match ($get('swap_support')) {
'disabled', 'unlimited' => true,
'limited' => false,
default => false,
})
->label('Swap Memory')
->default(0)
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->minValue(-1)
->columnSpan(2)
->inlineLabel()

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources\ServerResource\Pages;
use LogicException;
use App\Filament\Resources\ServerResource;
use App\Http\Controllers\Admin\ServersController;
use App\Services\Servers\RandomWordService;
@ -219,7 +220,7 @@ class EditServer extends EditRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_mem'))
->label('Memory Limit')->inlineLabel()
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required()
->columnSpan(2)
->numeric()
@ -249,7 +250,7 @@ class EditServer extends EditRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => $get('unlimited_disk'))
->label('Disk Space Limit')->inlineLabel()
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->required()
->columnSpan(2)
->numeric()
@ -299,6 +300,7 @@ class EditServer extends EditRecord
'unlimited' => -1,
'disabled' => 0,
'limited' => 128,
default => throw new LogicException('Invalid state')
};
$set('swap', $value);
@ -308,6 +310,7 @@ class EditServer extends EditRecord
$get('swap') > 0 => 'limited',
$get('swap') == 0 => 'disabled',
$get('swap') < 0 => 'unlimited',
default => throw new LogicException('Invalid state')
};
})
->options([
@ -325,10 +328,10 @@ class EditServer extends EditRecord
->dehydratedWhenHidden()
->hidden(fn (Forms\Get $get) => match ($get('swap_support')) {
'disabled', 'unlimited', true => true,
'limited', false => false,
default => false,
})
->label('Swap Memory')->inlineLabel()
->suffix('MiB')
->suffix(config('panel.use_binary_prefix') ? 'MiB' : 'MB')
->minValue(-1)
->columnSpan(2)
->required()
@ -553,7 +556,6 @@ class EditServer extends EditRecord
$components = [$text, $select];
/** @var Forms\Components\Component $component */
foreach ($components as &$component) {
$component = $component
->live(onBlur: true)
@ -606,7 +608,7 @@ class EditServer extends EditRecord
->action(function (ServersController $serversController, Server $server) {
$serversController->toggleInstall($server);
return $this->refreshFormData(['status', 'docker']);
$this->refreshFormData(['status', 'docker']);
}),
])->fullWidth(),
Forms\Components\ToggleButtons::make('')
@ -624,7 +626,7 @@ class EditServer extends EditRecord
$suspensionService->toggle($server, 'suspend');
Notification::make()->success()->title('Server Suspended!')->send();
return $this->refreshFormData(['status', 'docker']);
$this->refreshFormData(['status', 'docker']);
}),
Forms\Components\Actions\Action::make('toggleUnsuspend')
->label('Unsuspend')
@ -634,7 +636,7 @@ class EditServer extends EditRecord
$suspensionService->toggle($server, 'unsuspend');
Notification::make()->success()->title('Server Unsuspended!')->send();
return $this->refreshFormData(['status', 'docker']);
$this->refreshFormData(['status', 'docker']);
}),
])->fullWidth(),
Forms\Components\ToggleButtons::make('')
@ -650,7 +652,7 @@ class EditServer extends EditRecord
Forms\Components\Actions::make([
Forms\Components\Actions\Action::make('transfer')
->label('Transfer Soon™')
->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, $data))
->action(fn (TransferServerService $transfer, Server $server) => $transfer->handle($server, []))
->disabled() //TODO!
->form([ //TODO!
Forms\Components\Select::make('newNode')

View File

@ -22,13 +22,7 @@ class ListServers extends ListRecords
Tables\Columns\TextColumn::make('status')
->default('unknown')
->badge()
->default(function (Server $server) {
if ($server->status !== null) {
return $server->status;
}
return $server->retrieveStatus() ?? 'node_fail';
})
->default(fn (Server $server) => $server->status ?? $server->retrieveStatus())
->icon(fn ($state) => match ($state) {
'node_fail' => 'tabler-server-off',
'running' => 'tabler-heartbeat',
@ -58,11 +52,13 @@ class ListServers extends ListRecords
Tables\Columns\TextColumn::make('node.name')
->icon('tabler-server-2')
->url(fn (Server $server): string => route('filament.admin.resources.nodes.edit', ['record' => $server->node]))
->sortable(),
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('egg.name')
->icon('tabler-egg')
->url(fn (Server $server): string => route('filament.admin.resources.eggs.edit', ['record' => $server->egg]))
->sortable(),
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('user.username')
->icon('tabler-user')
->label('Owner')
@ -77,9 +73,6 @@ class ListServers extends ListRecords
->numeric()
->sortable(),
])
->filters([
//
])
->actions([
Tables\Actions\Action::make('View')
->icon('tabler-terminal')
@ -87,6 +80,7 @@ class ListServers extends ListRecords
Tables\Actions\EditAction::make(),
])
->emptyStateIcon('tabler-brand-docker')
->searchable()
->emptyStateDescription('')
->emptyStateHeading('No Servers')
->emptyStateActions([

View File

@ -193,8 +193,10 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->schema([
Grid::make('asdf')->columns(5)->schema([
Section::make('Create API Key')->columnSpan(3)->schema([
TextInput::make('description')->required(),
TextInput::make('description')
->live(),
TagsInput::make('allowed_ips')
->live()
->splitKeys([',', ' ', 'Tab'])
->placeholder('Example: 127.0.0.1 or 192.168.1.1')
->label('Whitelisted IP\'s')
@ -202,6 +204,7 @@ class EditProfile extends \Filament\Pages\Auth\EditProfile
->columnSpanFull(),
])->headerActions([
Action::make('Create')
->disabled(fn (Get $get) => $get('description') === null)
->successRedirectUrl(route('filament.admin.auth.profile', ['tab' => '-api-keys-tab']))
->action(function (Get $get, Action $action, $user) {
$token = $user->createToken(

View File

@ -2,15 +2,15 @@
namespace App\Http\Controllers\Admin\Eggs;
use App\Exceptions\Service\Egg\NoParentConfigurationFoundException;
use Illuminate\View\View;
use App\Models\Egg;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Illuminate\View\Factory as ViewFactory;
use App\Http\Controllers\Controller;
use App\Services\Eggs\EggUpdateService;
use App\Services\Eggs\EggCreationService;
use App\Http\Requests\Admin\Egg\EggFormRequest;
use Ramsey\Uuid\Uuid;
class EggController extends Controller
{
@ -19,8 +19,6 @@ class EggController extends Controller
*/
public function __construct(
protected AlertsMessageBag $alert,
protected EggCreationService $creationService,
protected EggUpdateService $updateService,
protected ViewFactory $view
) {
}
@ -58,7 +56,16 @@ class EggController extends Controller
$data['docker_images'] = $this->normalizeDockerImages($data['docker_images'] ?? null);
$data['author'] = $request->user()->email;
$egg = $this->creationService->handle($data);
$data['config_from'] = array_get($data, 'config_from');
if (!is_null($data['config_from'])) {
$parentEgg = Egg::query()->find(array_get($data, 'config_from'));
throw_unless($parentEgg, new NoParentConfigurationFoundException(trans('exceptions.egg.invalid_copy_id')));
}
$egg = Egg::query()->create(array_merge($data, [
'uuid' => Uuid::uuid4()->toString(),
]));
$this->alert->success(trans('admin/eggs.notices.egg_created'))->flash();
return redirect()->route('admin.eggs.view', $egg->id);
@ -90,7 +97,13 @@ class EggController extends Controller
$data = $request->validated();
$data['docker_images'] = $this->normalizeDockerImages($data['docker_images'] ?? null);
$this->updateService->handle($egg, $data);
$eggId = array_get($data, 'config_from');
$copiedFromEgg = Egg::query()->find($eggId);
throw_unless($copiedFromEgg, new NoParentConfigurationFoundException(trans('exceptions.egg.invalid_copy_id')));
$egg->update($data);
$this->alert->success(trans('admin/eggs.notices.updated'))->flash();
return redirect()->route('admin.eggs.view', $egg->id);

View File

@ -10,7 +10,6 @@ use Symfony\Component\HttpFoundation\Response;
use App\Services\Eggs\Sharing\EggExporterService;
use App\Services\Eggs\Sharing\EggImporterService;
use App\Http\Requests\Admin\Egg\EggImportFormRequest;
use App\Services\Eggs\Sharing\EggUpdateImporterService;
class EggShareController extends Controller
{
@ -21,7 +20,6 @@ class EggShareController extends Controller
protected AlertsMessageBag $alert,
protected EggExporterService $exporterService,
protected EggImporterService $importerService,
protected EggUpdateImporterService $updateImporterService
) {
}
@ -61,7 +59,7 @@ class EggShareController extends Controller
*/
public function update(EggImportFormRequest $request, Egg $egg): RedirectResponse
{
$this->updateImporterService->fromFile($egg, $request->file('import_file'));
$this->importerService->fromFile($request->file('import_file'), $egg);
$this->alert->success(trans('admin/eggs.notices.updated_via_import'))->flash();
return redirect()->route('admin.eggs.view', ['egg' => $egg]);

View File

@ -73,12 +73,12 @@ class ServerManagementController extends ApplicationApiController
if ($this->transferServerService->handle($server, $validatedData)) {
// Transfer started
$this->returnNoContent();
} else {
return $this->returnNoContent();
}
// Node was not viable
return new Response('', Response::HTTP_NOT_ACCEPTABLE);
}
}
/**
* Cancels a transfer of a server to a new node.

View File

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Auth;
use Illuminate\Auth\AuthManager;
use Illuminate\Http\RedirectResponse;
use Laravel\Socialite\Facades\Socialite;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Users\UserUpdateService;
use Exception;
use Illuminate\Http\Request;
class OAuthController extends Controller
{
/**
* OAuthController constructor.
*/
public function __construct(
private AuthManager $auth,
private UserUpdateService $updateService
) {
}
/**
* Redirect user to the OAuth provider
*/
protected function redirect(string $driver): RedirectResponse
{
return Socialite::with($driver)->redirect();
}
/**
* Callback from OAuth provider.
*/
protected function callback(Request $request, string $driver): RedirectResponse
{
$oauthUser = Socialite::driver($driver)->user();
// User is already logged in and wants to link a new OAuth Provider
if ($request->user()) {
$oauth = $request->user()->oauth;
$oauth[$driver] = $oauthUser->getId();
$this->updateService->handle($request->user(), ['oauth' => $oauth]);
return redirect()->route('account');
}
try {
$user = User::query()->whereJsonContains('oauth->'. $driver, $oauthUser->getId())->firstOrFail();
$this->auth->guard()->login($user, true);
} catch (Exception $e) {
// No user found - redirect to normal login
return redirect()->route('auth.login');
}
return redirect('/');
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Base;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Laravel\Socialite\Facades\Socialite;
use App\Http\Controllers\Controller;
use App\Services\Users\UserUpdateService;
use Illuminate\Http\Response;
class OAuthController extends Controller
{
/**
* OAuthController constructor.
*/
public function __construct(
private UserUpdateService $updateService
) {
}
/**
* Link a new OAuth
*/
protected function link(Request $request): RedirectResponse
{
$driver = $request->get('driver');
return Socialite::with($driver)->redirect();
}
/**
* Remove a OAuth link
*/
protected function unlink(Request $request): Response
{
$oauth = $request->user()->oauth;
unset($oauth[$request->get('driver')]);
$this->updateService->handle($request->user(), ['oauth' => $oauth]);
return new Response('', Response::HTTP_NO_CONTENT);
}
}

View File

@ -7,14 +7,13 @@ use App\Models\Mount;
class UpdateMountRequest extends StoreMountRequest
{
/**
* Apply validation rules to this request. Uses the parent class rules()
* function but passes in the rules for updating rather than creating.
* Apply validation rules to this request.
*/
public function rules(array $rules = null): array
{
/** @var Mount $mount */
$mount = $this->route()->parameter('mount');
return parent::rules(Mount::getRulesForUpdate($mount->id));
return Mount::getRulesForUpdate($mount->id);
}
}

View File

@ -27,8 +27,8 @@ class StoreServerRequest extends ApplicationApiRequest
'description' => array_merge(['nullable'], $rules['description']),
'user' => $rules['owner_id'],
'egg' => $rules['egg_id'],
'docker_image' => $rules['image'],
'startup' => $rules['startup'],
'docker_image' => 'sometimes|string',
'startup' => 'sometimes|string',
'environment' => 'present|array',
'skip_scripts' => 'sometimes|boolean',
'oom_killer' => 'sometimes|boolean',

View File

@ -20,10 +20,10 @@ class UpdateServerStartupRequest extends ApplicationApiRequest
$data = Server::getRulesForUpdate($this->parameter('server', Server::class));
return [
'startup' => $data['startup'],
'startup' => 'sometimes|string',
'environment' => 'present|array',
'egg' => $data['egg_id'],
'image' => $data['image'],
'image' => 'sometimes|string',
'skip_scripts' => 'present|boolean',
];
}

View File

@ -27,6 +27,7 @@ class AssetComposer
'enabled' => config('recaptcha.enabled', false),
'siteKey' => config('recaptcha.website_key') ?? '',
],
'usesSyncDriver' => config('queue.default') === 'sync',
]);
}
}

View File

@ -7,6 +7,55 @@ use Webmozart\Assert\Assert;
use App\Services\Acl\Api\AdminAcl;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* App\Models\ApiKey.
*
* @property int $id
* @property int $user_id
* @property int $key_type
* @property string $identifier
* @property string $token
* @property array $allowed_ips
* @property string|null $memo
* @property \Illuminate\Support\Carbon|null $last_used_at
* @property \Illuminate\Support\Carbon|null $expires_at
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property int $r_servers
* @property int $r_nodes
* @property int $r_allocations
* @property int $r_users
* @property int $r_eggs
* @property int $r_database_hosts
* @property int $r_server_databases
* @property int $r_mounts
* @property \App\Models\User $tokenable
* @property \App\Models\User $user
*
* @method static \Database\Factories\ApiKeyFactory factory(...$parameters)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey query()
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereAllowedIps($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereIdentifier($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereKeyType($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereLastUsedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereMemo($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereRAllocations($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereRDatabaseHosts($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereREggs($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereRNodes($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereRServerDatabases($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereRServers($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereRUsers($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereToken($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|ApiKey whereUserId($value)
*
* @mixin \Eloquent
*/
class ApiKey extends Model
{
/**
@ -64,6 +113,13 @@ class ApiKey extends Model
'r_' . AdminAcl::RESOURCE_MOUNTS,
];
/**
* Default attributes when creating a new model.
*/
protected $attributes = [
'allowed_ips' => '[]',
];
/**
* Fields that should not be included when calling toArray() or toJson()
* on this model.
@ -79,7 +135,7 @@ class ApiKey extends Model
'identifier' => 'required|string|size:16|unique:api_keys,identifier',
'token' => 'required|string',
'memo' => 'required|nullable|string|max:500',
'allowed_ips' => 'nullable|array',
'allowed_ips' => 'array',
'allowed_ips.*' => 'string',
'last_used_at' => 'nullable|date',
'expires_at' => 'nullable|date',

View File

@ -2,10 +2,8 @@
namespace App\Models;
use App\Casts\EndpointCollection;
use App\Enums\ServerState;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Models\Objects\Endpoint;
use GuzzleHttp\Exception\GuzzleException;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Notifications\Notifiable;
@ -18,6 +16,93 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use App\Exceptions\Http\Server\ServerStateConflictException;
/**
* \App\Models\Server.
*
* @property int $id
* @property string|null $external_id
* @property string $uuid
* @property string $uuid_short
* @property int $node_id
* @property string $name
* @property string $description
* @property ServerState|null $status
* @property bool $skip_scripts
* @property int $owner_id
* @property int $memory
* @property int $swap
* @property int $disk
* @property int $io
* @property int $cpu
* @property string|null $threads
* @property bool $oom_killer
* @property int $allocation_id
* @property int $egg_id
* @property string $startup
* @property string $image
* @property int|null $allocation_limit
* @property int|null $database_limit
* @property int $backup_limit
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $installed_at
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\ActivityLog[] $activity
* @property int|null $activity_count
* @property \App\Models\Allocation|null $allocation
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Allocation[] $allocations
* @property int|null $allocations_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Backup[] $backups
* @property int|null $backups_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Database[] $databases
* @property int|null $databases_count
* @property \App\Models\Egg|null $egg
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Mount[] $mounts
* @property int|null $mounts_count
* @property \App\Models\Node $node
* @property \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications
* @property int|null $notifications_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Schedule[] $schedules
* @property int|null $schedules_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Subuser[] $subusers
* @property int|null $subusers_count
* @property \App\Models\ServerTransfer|null $transfer
* @property \App\Models\User $user
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\EggVariable[] $variables
* @property int|null $variables_count
*
* @method static \Database\Factories\ServerFactory factory(...$parameters)
* @method static \Illuminate\Database\Eloquent\Builder|Server newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Server newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|Server query()
* @method static \Illuminate\Database\Eloquent\Builder|Server whereAllocationId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereAllocationLimit($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereBackupLimit($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereCpu($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereCreatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereDatabaseLimit($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereDescription($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereDisk($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereEggId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereExternalId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereImage($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereIo($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereMemory($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereNodeId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereOomKiller($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereOwnerId($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereSkipScripts($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereStartup($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereStatus($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereSwap($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereThreads($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereUuid($value)
* @method static \Illuminate\Database\Eloquent\Builder|Server whereuuid_short($value)
*
* @mixin \Eloquent
*/
class Server extends Model
{
use Notifiable;
@ -43,6 +128,11 @@ class Server extends Model
'installed_at' => null,
];
/**
* The default relationships to load for all server models.
*/
protected $with = ['allocation'];
/**
* Fields that are not mass assignable.
*/
@ -62,6 +152,7 @@ class Server extends Model
'threads' => 'nullable|regex:/^[0-9-,]+$/',
'oom_killer' => 'sometimes|boolean',
'disk' => 'required|numeric|min:0',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'egg_id' => 'required|exists:eggs,id',
'startup' => 'required|string',
'skip_scripts' => 'sometimes|boolean',
@ -84,33 +175,27 @@ class Server extends Model
'io' => 'integer',
'cpu' => 'integer',
'oom_killer' => 'boolean',
'allocation_id' => 'integer',
'egg_id' => 'integer',
'database_limit' => 'integer',
'allocation_limit' => 'integer',
'backup_limit' => 'integer',
self::CREATED_AT => 'datetime',
self::UPDATED_AT => 'datetime',
'deleted_at' => 'datetime',
'installed_at' => 'datetime',
'docker_labels' => 'array',
'ports' => EndpointCollection::class,
];
}
/**
* Returns the format for server allocations when communicating with the Daemon.
*/
public function getPortMappings(): array
public function getAllocationMappings(): array
{
$defaultIp = '0.0.0.0';
$ports = collect($this->ports)
->map(fn ($port) => str_contains($port, ':') ? $port : "$defaultIp:$port")
->mapToGroups(function ($port) {
[$ip, $port] = explode(':', $port);
return [$ip => (int) $port];
});
return $ports->all();
return $this->allocations->where('node_id', $this->node_id)->groupBy('ip')->map(function ($item) {
return $item->pluck('port');
})->toArray();
}
public function isInstalled(): bool
@ -139,6 +224,22 @@ class Server extends Model
return $this->hasMany(Subuser::class, 'server_id', 'id');
}
/**
* Gets the default allocation for a server.
*/
public function allocation(): BelongsTo
{
return $this->belongsTo(Allocation::class);
}
/**
* Gets all allocations associated with this server.
*/
public function allocations(): HasMany
{
return $this->hasMany(Allocation::class);
}
/**
* Gets information for the egg associated with this server.
*/
@ -309,17 +410,4 @@ class Server extends Model
return cache()->get("servers.$this->uuid.container.status") ?? 'missing';
}
public function getPrimaryEndpoint(): ?Endpoint
{
$endpoint = $this->ports->first();
$portEggVariable = $this->variables->firstWhere('env_variable', 'SERVER_PORT');
if ($portEggVariable) {
$portServerVariable = $this->serverVariables->firstWhere('variable_id', $portEggVariable->id);
$endpoint = new Endpoint($portServerVariable->variable_value);
}
return $endpoint;
}
}

View File

@ -25,6 +25,65 @@ use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use App\Notifications\SendPasswordReset as ResetPasswordNotification;
/**
* App\Models\User.
*
* @property int $id
* @property string|null $external_id
* @property string $uuid
* @property string $username
* @property string $email
* @property string|null $name_first
* @property string|null $name_last
* @property string $password
* @property string|null $remember_token
* @property string $language
* @property bool $root_admin
* @property bool $use_totp
* @property string|null $totp_secret
* @property \Illuminate\Support\Carbon|null $totp_authenticated_at
* @property array $oauth
* @property bool $gravatar
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\ApiKey[] $apiKeys
* @property int|null $api_keys_count
* @property string $name
* @property \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\Notifications\DatabaseNotification[] $notifications
* @property int|null $notifications_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\RecoveryToken[] $recoveryTokens
* @property int|null $recovery_tokens_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\Server[] $servers
* @property int|null $servers_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\UserSSHKey[] $sshKeys
* @property int|null $ssh_keys_count
* @property \Illuminate\Database\Eloquent\Collection|\App\Models\ApiKey[] $tokens
* @property int|null $tokens_count
*
* @method static \Database\Factories\UserFactory factory(...$parameters)
* @method static Builder|User newModelQuery()
* @method static Builder|User newQuery()
* @method static Builder|User query()
* @method static Builder|User whereCreatedAt($value)
* @method static Builder|User whereEmail($value)
* @method static Builder|User whereExternalId($value)
* @method static Builder|User whereGravatar($value)
* @method static Builder|User whereId($value)
* @method static Builder|User whereLanguage($value)
* @method static Builder|User whereNameFirst($value)
* @method static Builder|User whereNameLast($value)
* @method static Builder|User wherePassword($value)
* @method static Builder|User whereRememberToken($value)
* @method static Builder|User whereRootAdmin($value)
* @method static Builder|User whereTotpAuthenticatedAt($value)
* @method static Builder|User whereTotpSecret($value)
* @method static Builder|User whereUpdatedAt($value)
* @method static Builder|User whereUseTotp($value)
* @method static Builder|User whereUsername($value)
* @method static Builder|User whereUuid($value)
*
* @mixin \Eloquent
*/
class User extends Model implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, FilamentUser, HasAvatar, HasName
{
use Authenticatable;
@ -69,12 +128,13 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'totp_authenticated_at',
'gravatar',
'root_admin',
'oauth',
];
/**
* The attributes excluded from the model's JSON form.
*/
protected $hidden = ['password', 'remember_token', 'totp_secret', 'totp_authenticated_at'];
protected $hidden = ['password', 'remember_token', 'totp_secret', 'totp_authenticated_at', 'oauth'];
/**
* Default values for specific fields in the database.
@ -87,6 +147,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'totp_secret' => null,
'name_first' => '',
'name_last' => '',
'oauth' => '[]',
];
/**
@ -104,6 +165,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'language' => 'string',
'use_totp' => 'boolean',
'totp_secret' => 'nullable|string',
'oauth' => 'array',
];
protected function casts(): array
@ -114,6 +176,7 @@ class User extends Model implements AuthenticatableContract, AuthorizableContrac
'gravatar' => 'boolean',
'totp_authenticated_at' => 'datetime',
'totp_secret' => 'encrypted',
'oauth' => 'array',
];
}

View File

@ -14,6 +14,7 @@ use Dedoc\Scramble\Support\Generator\SecurityScheme;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Schema;
@ -81,6 +82,10 @@ class AppServiceProvider extends ServiceProvider
Scramble::registerApi('application', ['api_path' => 'api/application', 'info' => ['version' => '1.0']]);
Scramble::registerApi('client', ['api_path' => 'api/client', 'info' => ['version' => '1.0']])->afterOpenApiGenerated($bearerTokens);
Scramble::registerApi('remote', ['api_path' => 'api/remote', 'info' => ['version' => '1.0']])->afterOpenApiGenerated($bearerTokens);
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('discord', \SocialiteProviders\Discord\Provider::class);
});
}
/**

View File

@ -25,9 +25,9 @@ class DaemonServerRepository extends DaemonRepository
Assert::isInstanceOf($this->server, Server::class);
try {
$response = $this->getHttpClient()->get(
return $this->getHttpClient()->get(
sprintf('/api/servers/%s', $this->server->uuid)
)->throw();
)->throw()->json();
} catch (RequestException $exception) {
$cfId = $exception->response->header('Cf-Ray');
$cfCache = $exception->response->header('Cf-Cache-Status');
@ -48,7 +48,7 @@ class DaemonServerRepository extends DaemonRepository
report($exception);
}
return $response?->json() ?? ['state' => ContainerStatus::Missing->value];
return ['state' => ContainerStatus::Missing->value];
}
/**

View File

@ -1,29 +0,0 @@
<?php
namespace App\Services\Eggs;
use Ramsey\Uuid\Uuid;
use App\Models\Egg;
use App\Exceptions\Service\Egg\NoParentConfigurationFoundException;
class EggCreationService
{
/**
* Create a new egg.
*
* @throws \App\Exceptions\Model\DataValidationException
* @throws \App\Exceptions\Service\Egg\NoParentConfigurationFoundException
*/
public function handle(array $data): Egg
{
$data['config_from'] = array_get($data, 'config_from');
if (!is_null($data['config_from'])) {
$parentEgg = Egg::query()->find(array_get($data, 'config_from'));
throw_unless($parentEgg, new NoParentConfigurationFoundException(trans('exceptions.egg.invalid_copy_id')));
}
return Egg::query()->create(array_merge($data, [
'uuid' => Uuid::uuid4()->toString(),
]));
}
}

View File

@ -1,108 +0,0 @@
<?php
namespace App\Services\Eggs;
use Illuminate\Support\Arr;
use App\Models\Egg;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use App\Exceptions\Service\InvalidFileUploadException;
class EggParserService
{
public const UPGRADE_VARIABLES = [
'server.build.env.SERVER_IP' => 'server.allocations.default.ip',
'server.build.default.ip' => 'server.allocations.default.ip',
'server.build.env.SERVER_PORT' => 'server.allocations.default.port',
'server.build.default.port' => 'server.allocations.default.port',
'server.build.env.SERVER_MEMORY' => 'server.build.memory_limit',
'server.build.memory' => 'server.build.memory_limit',
'server.build.env.' => 'server.environment.',
'server.build.environment.' => 'server.environment.',
];
/**
* Takes an uploaded file and parses out the egg configuration from within.
*
* @throws \JsonException
* @throws \App\Exceptions\Service\InvalidFileUploadException
*/
public function handle(UploadedFile $file): array
{
if ($file->getError() !== UPLOAD_ERR_OK) {
throw new InvalidFileUploadException('The selected file was not uploaded successfully');
}
$parsed = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
$version = $parsed['meta']['version'] ?? '';
$parsed = match ($version) {
'PTDL_v1' => $this->convertToV2($parsed),
'PTDL_v2' => $parsed,
default => throw new InvalidFileUploadException('The JSON file provided is not in a format that can be recognized.')
};
// Make sure we only use recent variable format from now on
$parsed['config']['files'] = str_replace(
array_keys(self::UPGRADE_VARIABLES),
array_values(self::UPGRADE_VARIABLES),
$parsed['config']['files'] ?? '',
);
return $parsed;
}
/**
* Fills the provided model with the parsed JSON data.
*/
public function fillFromParsed(Egg $model, array $parsed): Egg
{
return $model->forceFill([
'name' => Arr::get($parsed, 'name'),
'description' => Arr::get($parsed, 'description'),
'features' => Arr::get($parsed, 'features'),
'docker_images' => Arr::get($parsed, 'docker_images'),
'file_denylist' => Collection::make(Arr::get($parsed, 'file_denylist'))
->filter(fn ($value) => !empty($value)),
'update_url' => Arr::get($parsed, 'meta.update_url'),
'config_files' => Arr::get($parsed, 'config.files'),
'config_startup' => Arr::get($parsed, 'config.startup'),
'config_logs' => Arr::get($parsed, 'config.logs'),
'config_stop' => Arr::get($parsed, 'config.stop'),
'startup' => Arr::get($parsed, 'startup'),
'script_install' => Arr::get($parsed, 'scripts.installation.script'),
'script_entry' => Arr::get($parsed, 'scripts.installation.entrypoint'),
'script_container' => Arr::get($parsed, 'scripts.installation.container'),
]);
}
/**
* Converts a PTDL_V1 egg into the expected PTDL_V2 egg format. This just handles
* the "docker_images" field potentially not being present, and not being in the
* expected "key => value" format.
*/
protected function convertToV2(array $parsed): array
{
// Maintain backwards compatability for eggs that are still using the old single image
// string format. New eggs can provide an array of Docker images that can be used.
if (!isset($parsed['images'])) {
$images = [Arr::get($parsed, 'image') ?? 'nil'];
} else {
$images = $parsed['images'];
}
unset($parsed['images'], $parsed['image']);
$parsed['docker_images'] = [];
foreach ($images as $image) {
$parsed['docker_images'][$image] = $image;
}
$parsed['variables'] = array_map(function ($value) {
return array_merge($value, ['field_type' => 'text']);
}, $parsed['variables']);
return $parsed;
}
}

View File

@ -1,26 +0,0 @@
<?php
namespace App\Services\Eggs;
use App\Models\Egg;
use App\Exceptions\Service\Egg\NoParentConfigurationFoundException;
class EggUpdateService
{
/**
* Update an egg.
*/
public function handle(Egg $egg, array $data): void
{
$eggId = array_get($data, 'config_from');
$copiedFromEgg = Egg::query()->find($eggId);
throw_unless($copiedFromEgg, new NoParentConfigurationFoundException(trans('exceptions.egg.invalid_copy_id')));
// TODO: Once the admin UI is done being reworked and this is exposed
// in said UI, remove this so that you can actually update the denylist.
unset($data['file_denylist']);
$egg->update($data);
}
}

View File

@ -2,18 +2,30 @@
namespace App\Services\Eggs\Sharing;
use App\Exceptions\Service\InvalidFileUploadException;
use Ramsey\Uuid\Uuid;
use Illuminate\Support\Arr;
use App\Models\Egg;
use Illuminate\Http\UploadedFile;
use App\Models\EggVariable;
use Illuminate\Database\ConnectionInterface;
use App\Services\Eggs\EggParserService;
use Illuminate\Support\Collection;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class EggImporterService
{
public function __construct(protected ConnectionInterface $connection, protected EggParserService $parser)
public const UPGRADE_VARIABLES = [
'server.build.env.SERVER_IP' => 'server.allocations.default.ip',
'server.build.default.ip' => 'server.allocations.default.ip',
'server.build.env.SERVER_PORT' => 'server.allocations.default.port',
'server.build.default.port' => 'server.allocations.default.port',
'server.build.env.SERVER_MEMORY' => 'server.build.memory_limit',
'server.build.memory' => 'server.build.memory_limit',
'server.build.env.' => 'server.environment.',
'server.build.environment.' => 'server.environment.',
];
public function __construct(protected ConnectionInterface $connection)
{
}
@ -22,13 +34,13 @@ class EggImporterService
*
* @throws \App\Exceptions\Service\InvalidFileUploadException|\Throwable
*/
public function fromFile(UploadedFile $file): Egg
public function fromFile(UploadedFile $file, Egg $egg = null): Egg
{
$parsed = $this->parser->handle($file);
$parsed = $this->parseFile($file);
return $this->connection->transaction(function () use ($parsed) {
return $this->connection->transaction(function () use ($egg, $parsed) {
$uuid = $parsed['uuid'] ?? Uuid::uuid4()->toString();
$egg = Egg::where('uuid', $uuid)->first() ?? new Egg();
$egg = $egg ?? Egg::where('uuid', $uuid)->first() ?? new Egg();
$egg = $egg->forceFill([
'uuid' => $uuid,
@ -36,23 +48,32 @@ class EggImporterService
'copy_script_from' => null,
]);
$egg = $this->parser->fillFromParsed($egg, $parsed);
$egg = $this->fillFromParsed($egg, $parsed);
$egg->save();
// Update existing variables or create new ones.
foreach ($parsed['variables'] ?? [] as $variable) {
EggVariable::query()->forceCreate(array_merge($variable, ['egg_id' => $egg->id]));
EggVariable::unguarded(function () use ($egg, $variable) {
$egg->variables()->updateOrCreate([
'env_variable' => $variable['env_variable'],
], Collection::make($variable)->except(['egg_id', 'env_variable'])->toArray());
});
}
return $egg;
$imported = array_map(fn ($value) => $value['env_variable'], $parsed['variables'] ?? []);
$egg->variables()->whereNotIn('env_variable', $imported)->delete();
return $egg->refresh();
});
}
/**
* Take an url and parse it into a new egg.
* Take an url and parse it into a new egg or update an existing one.
*
* @throws \App\Exceptions\Service\InvalidFileUploadException|\Throwable
*/
public function fromUrl(string $url): Egg
public function fromUrl(string $url, Egg $egg = null): Egg
{
$info = pathinfo($url);
$tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed();
@ -60,6 +81,91 @@ class EggImporterService
file_put_contents($tmpPath, file_get_contents($url));
return $this->fromFile(new UploadedFile($tmpPath, $info['basename'], 'application/json'));
return $this->fromFile(new UploadedFile($tmpPath, $info['basename'], 'application/json'), $egg);
}
/**
* Takes an uploaded file and parses out the egg configuration from within.
*
* @throws \JsonException
* @throws \App\Exceptions\Service\InvalidFileUploadException
*/
protected function parseFile(UploadedFile $file): array
{
if ($file->getError() !== UPLOAD_ERR_OK) {
throw new InvalidFileUploadException('The selected file was not uploaded successfully');
}
$parsed = json_decode($file->getContent(), true, 512, JSON_THROW_ON_ERROR);
$version = $parsed['meta']['version'] ?? '';
$parsed = match ($version) {
'PTDL_v1' => $this->convertToV2($parsed),
'PTDL_v2' => $parsed,
default => throw new InvalidFileUploadException('The JSON file provided is not in a format that can be recognized.')
};
// Make sure we only use recent variable format from now on
$parsed['config']['files'] = str_replace(
array_keys(self::UPGRADE_VARIABLES),
array_values(self::UPGRADE_VARIABLES),
$parsed['config']['files'] ?? '',
);
return $parsed;
}
/**
* Fills the provided model with the parsed JSON data.
*/
protected function fillFromParsed(Egg $model, array $parsed): Egg
{
return $model->forceFill([
'name' => Arr::get($parsed, 'name'),
'description' => Arr::get($parsed, 'description'),
'features' => Arr::get($parsed, 'features'),
'docker_images' => Arr::get($parsed, 'docker_images'),
'file_denylist' => Collection::make(Arr::get($parsed, 'file_denylist'))
->filter(fn ($value) => !empty($value)),
'update_url' => Arr::get($parsed, 'meta.update_url'),
'config_files' => Arr::get($parsed, 'config.files'),
'config_startup' => Arr::get($parsed, 'config.startup'),
'config_logs' => Arr::get($parsed, 'config.logs'),
'config_stop' => Arr::get($parsed, 'config.stop'),
'startup' => Arr::get($parsed, 'startup'),
'script_install' => Arr::get($parsed, 'scripts.installation.script'),
'script_entry' => Arr::get($parsed, 'scripts.installation.entrypoint'),
'script_container' => Arr::get($parsed, 'scripts.installation.container'),
]);
}
/**
* Converts a PTDL_V1 egg into the expected PTDL_V2 egg format. This just handles
* the "docker_images" field potentially not being present, and not being in the
* expected "key => value" format.
*/
protected function convertToV2(array $parsed): array
{
// Maintain backwards compatability for eggs that are still using the old single image
// string format. New eggs can provide an array of Docker images that can be used.
if (!isset($parsed['images'])) {
$images = [Arr::get($parsed, 'image') ?? 'nil'];
} else {
$images = $parsed['images'];
}
unset($parsed['images'], $parsed['image']);
$parsed['docker_images'] = [];
foreach ($images as $image) {
$parsed['docker_images'][$image] = $image;
}
$parsed['variables'] = array_map(function ($value) {
return array_merge($value, ['field_type' => 'text']);
}, $parsed['variables']);
return $parsed;
}
}

View File

@ -1,67 +0,0 @@
<?php
namespace App\Services\Eggs\Sharing;
use App\Models\Egg;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use App\Models\EggVariable;
use Illuminate\Database\ConnectionInterface;
use App\Services\Eggs\EggParserService;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class EggUpdateImporterService
{
/**
* EggUpdateImporterService constructor.
*/
public function __construct(protected ConnectionInterface $connection, protected EggParserService $parser)
{
}
/**
* Update an existing Egg using an uploaded JSON file.
*
* @throws \App\Exceptions\Service\InvalidFileUploadException|\Throwable
*/
public function fromFile(Egg $egg, UploadedFile $file): Egg
{
$parsed = $this->parser->handle($file);
return $this->connection->transaction(function () use ($egg, $parsed) {
$egg = $this->parser->fillFromParsed($egg, $parsed);
$egg->save();
// Update existing variables or create new ones.
foreach ($parsed['variables'] ?? [] as $variable) {
EggVariable::unguarded(function () use ($egg, $variable) {
$egg->variables()->updateOrCreate([
'env_variable' => $variable['env_variable'],
], Collection::make($variable)->except(['egg_id', 'env_variable'])->toArray());
});
}
$imported = array_map(fn ($value) => $value['env_variable'], $parsed['variables'] ?? []);
$egg->variables()->whereNotIn('env_variable', $imported)->delete();
return $egg->refresh();
});
}
/**
* Update an existing Egg using an url.
*
* @throws \App\Exceptions\Service\InvalidFileUploadException|\Throwable
*/
public function fromUrl(Egg $egg, string $url): Egg
{
$info = pathinfo($url);
$tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed();
$tmpPath = $tmpDir->path($info['basename']);
file_put_contents($tmpPath, file_get_contents($url));
return $this->fromFile($egg, new UploadedFile($tmpPath, $info['basename'], 'application/json'));
}
}

View File

@ -2,12 +2,11 @@
namespace App\Services\Helpers;
use Exception;
use GuzzleHttp\Client;
use Carbon\CarbonImmutable;
use GuzzleHttp\Exception\ClientException;
use Illuminate\Support\Arr;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use App\Exceptions\Service\Helper\CdnVersionFetchingException;
class SoftwareVersionService
{
@ -87,17 +86,27 @@ class SoftwareVersionService
protected function cacheVersionData(): array
{
return $this->cache->remember(self::VERSION_CACHE_KEY, CarbonImmutable::now()->addMinutes(config('panel.cdn.cache_time', 60)), function () {
$versionData = [];
try {
$response = $this->client->request('GET', config('panel.cdn.url'));
$response = $this->client->request('GET', 'https://api.github.com/repos/pelican-dev/panel/releases/latest');
if ($response->getStatusCode() === 200) {
return json_decode($response->getBody(), true);
$panelData = json_decode($response->getBody(), true);
$versionData['panel'] = trim($panelData['tag_name'], 'v');
}
throw new CdnVersionFetchingException();
} catch (Exception) {
return [];
$response = $this->client->request('GET', 'https://api.github.com/repos/pelican-dev/wings/releases/latest');
if ($response->getStatusCode() === 200) {
$wingsData = json_decode($response->getBody(), true);
$versionData['daemon'] = trim($wingsData['tag_name'], 'v');
}
} catch (ClientException $e) {
}
$versionData['discord'] = 'https://pelican.dev/discord';
$versionData['donate'] = 'https://pelican.dev/donate';
return $versionData;
});
}

View File

@ -51,12 +51,12 @@ class ServerConfigurationStructureService
'invocation' => $server->startup,
'skip_egg_scripts' => $server->skip_scripts,
'build' => [
'memory_limit' => $server->memory,
'swap' => $server->swap,
'memory_limit' => config('panel.use_binary_prefix') ? $server->memory : $server->memory / 1.048576,
'swap' => config('panel.use_binary_prefix') ? $server->swap : $server->swap / 1.048576,
'io_weight' => $server->io,
'cpu_limit' => $server->cpu,
'threads' => $server->threads,
'disk_space' => $server->disk,
'disk_space' => config('panel.use_binary_prefix') ? $server->disk : $server->disk / 1.048576,
'oom_killer' => $server->oom_killer,
],
'container' => [

View File

@ -14,6 +14,7 @@ use Illuminate\Database\ConnectionInterface;
use App\Models\Objects\DeploymentObject;
use App\Repositories\Daemon\DaemonServerRepository;
use App\Exceptions\Http\Connection\DaemonConnectionException;
use App\Models\Egg;
class ServerCreationService
{
@ -43,6 +44,13 @@ class ServerCreationService
$data['oom_killer'] = !$data['oom_disabled'];
}
/** @var Egg $egg */
$egg = Egg::query()->findOrFail($data['egg_id']);
// Fill missing fields from egg
$data['image'] = $data['image'] ?? collect($egg->docker_images)->first();
$data['startup'] = $data['startup'] ?? $egg->startup;
// If a deployment object has been passed we need to get the allocation
// that the server should use, and assign the node from that allocation.
if ($deployment instanceof DeploymentObject) {

View File

@ -76,6 +76,10 @@ class StartupModificationService
$server = $server->forceFill([
'egg_id' => $egg->id,
]);
// Fill missing fields from egg
$data['docker_image'] = $data['docker_image'] ?? collect($egg->docker_images)->first();
$data['startup'] = $data['startup'] ?? $egg->startup;
}
$server->fill([

View File

@ -55,7 +55,7 @@ class ActivityLogTransformer extends BaseClientTransformer
$properties = $model->properties
->mapWithKeys(function ($value, $key) use ($model) {
if ($key === 'ip' && $model->actor && !$model->actor->is($this->request->user())) {
if ($key === 'ip' && !$model->actor->is($this->request->user())) {
return [$key => '[hidden]'];
}

View File

@ -8,4 +8,6 @@ return [
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\RouteServiceProvider::class,
App\Providers\ViewComposerServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
];

20
bounties.md Normal file
View File

@ -0,0 +1,20 @@
# [Bounties](https://github.com/pelican-dev/panel/issues?q=is%3Aopen+is%3Aissue+label%3A%22%F0%9F%92%B5+bounty%22)
Get paid to improve Pelican!
## Rules
* code must be merged into the main branch
* bounty eligibility is solely at our discretion
* open a ticket at [hub.pelican.dev](https://hub.pelican.dev/tickets) with links to your PRs to claim
* get an extra 25% if you redeem your bounty in Donor credit
* for bounties >=$100, the first PR gets a lock, which times out after a week of no progress
We put up each bounty with the intention that it'll get merged, but occasionally the right resolution is to close the bounty, which only becomes clear once some effort is put in.
This is still valuable work, so we'll pay out $50 for getting any bounty closed with a good explanation.
## Issue bounties
We've tagged bounty-eligible issues across openpilot and the rest of our repos; check out all the open ones [here](https://github.com/pelican-dev/panel/issues?q=is%3Aopen+is%3Aissue+label%3A%22%F0%9F%92%B5+bounty%22).
New bounties can be proposed in the [**#feedback**](https://discord.com/channels/1218730176297439332/1218732581797892220) channel in Discord.

View File

@ -20,6 +20,7 @@
"laravel/framework": "^11.7",
"laravel/helpers": "^1.7",
"laravel/sanctum": "^4.0.2",
"laravel/socialite": "^5.14",
"laravel/tinker": "^2.9",
"laravel/ui": "^4.5.1",
"lcobucci/jwt": "~4.3.0",
@ -31,6 +32,7 @@
"prologue/alerts": "^1.2",
"ryangjchandler/blade-tabler-icons": "^2.3",
"s1lentium/iptools": "~1.2.0",
"socialiteproviders/discord": "^4.2",
"spatie/laravel-fractal": "^6.2",
"spatie/laravel-query-builder": "^5.8.1",
"spatie/temporary-directory": "^2.2",
@ -70,6 +72,7 @@
"scripts": {
"cs:fix": "php-cs-fixer fix",
"cs:check": "php-cs-fixer fix --dry-run --diff --verbose",
"phpstan": "phpstan --memory-limit=-1",
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump"
],

339
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bf44faee3aae2b1d4c1b57893c1aba98",
"content-hash": "443ec1d95b892b261af5481f27b31083",
"packages": [
{
"name": "abdelhamiderrahmouni/filament-monaco-editor",
@ -2069,6 +2069,69 @@
},
"time": "2024-06-05T09:38:52+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v6.10.1",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "500501c2ce893c824c801da135d02661199f60c5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5",
"reference": "500501c2ce893c824c801da135d02661199f60c5",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v6.10.1"
},
"time": "2024-05-18T18:05:11+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.3.0",
@ -3180,6 +3243,78 @@
},
"time": "2023-11-08T14:08:06+00:00"
},
{
"name": "laravel/socialite",
"version": "v5.14.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
"reference": "c7b0193a3753a29aff8ce80aa2f511917e6ed68a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/socialite/zipball/c7b0193a3753a29aff8ce80aa2f511917e6ed68a",
"reference": "c7b0193a3753a29aff8ce80aa2f511917e6ed68a",
"shasum": ""
},
"require": {
"ext-json": "*",
"firebase/php-jwt": "^6.4",
"guzzlehttp/guzzle": "^6.0|^7.0",
"illuminate/contracts": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/http": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"league/oauth1-client": "^1.10.1",
"php": "^7.2|^8.0",
"phpseclib/phpseclib": "^3.0"
},
"require-dev": {
"mockery/mockery": "^1.0",
"orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0|^9.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^8.0|^9.3|^10.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.x-dev"
},
"laravel": {
"providers": [
"Laravel\\Socialite\\SocialiteServiceProvider"
],
"aliases": {
"Socialite": "Laravel\\Socialite\\Facades\\Socialite"
}
}
},
"autoload": {
"psr-4": {
"Laravel\\Socialite\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel wrapper around OAuth 1 & OAuth 2 libraries.",
"homepage": "https://laravel.com",
"keywords": [
"laravel",
"oauth"
],
"support": {
"issues": "https://github.com/laravel/socialite/issues",
"source": "https://github.com/laravel/socialite"
},
"time": "2024-05-03T20:31:38+00:00"
},
{
"name": "laravel/tinker",
"version": "v2.9.0",
@ -4114,6 +4249,82 @@
],
"time": "2024-01-28T23:22:08+00:00"
},
{
"name": "league/oauth1-client",
"version": "v1.10.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth1-client.git",
"reference": "d6365b901b5c287dd41f143033315e2f777e1167"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth1-client/zipball/d6365b901b5c287dd41f143033315e2f777e1167",
"reference": "d6365b901b5c287dd41f143033315e2f777e1167",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-openssl": "*",
"guzzlehttp/guzzle": "^6.0|^7.0",
"guzzlehttp/psr7": "^1.7|^2.0",
"php": ">=7.1||>=8.0"
},
"require-dev": {
"ext-simplexml": "*",
"friendsofphp/php-cs-fixer": "^2.17",
"mockery/mockery": "^1.3.3",
"phpstan/phpstan": "^0.12.42",
"phpunit/phpunit": "^7.5||9.5"
},
"suggest": {
"ext-simplexml": "For decoding XML-based responses."
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0-dev",
"dev-develop": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"League\\OAuth1\\Client\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ben Corlett",
"email": "bencorlett@me.com",
"homepage": "http://www.webcomm.com.au",
"role": "Developer"
}
],
"description": "OAuth 1.0 Client Library",
"keywords": [
"Authentication",
"SSO",
"authorization",
"bitbucket",
"identity",
"idp",
"oauth",
"oauth1",
"single sign on",
"trello",
"tumblr",
"twitter"
],
"support": {
"issues": "https://github.com/thephpleague/oauth1-client/issues",
"source": "https://github.com/thephpleague/oauth1-client/tree/v1.10.1"
},
"time": "2022-04-15T14:02:14+00:00"
},
{
"name": "league/uri",
"version": "7.4.1",
@ -6579,6 +6790,130 @@
},
"time": "2022-08-17T14:28:59+00:00"
},
{
"name": "socialiteproviders/discord",
"version": "4.2.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Discord.git",
"reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Discord/zipball/c71c379acfdca5ba4aa65a3db5ae5222852a919c",
"reference": "c71c379acfdca5ba4aa65a3db5ae5222852a919c",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": "^7.4 || ^8.0",
"socialiteproviders/manager": "~4.0"
},
"type": "library",
"autoload": {
"psr-4": {
"SocialiteProviders\\Discord\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Christopher Eklund",
"email": "eklundchristopher@gmail.com"
}
],
"description": "Discord OAuth2 Provider for Laravel Socialite",
"keywords": [
"discord",
"laravel",
"oauth",
"provider",
"socialite"
],
"support": {
"docs": "https://socialiteproviders.com/discord",
"issues": "https://github.com/socialiteproviders/providers/issues",
"source": "https://github.com/socialiteproviders/providers"
},
"time": "2023-07-24T23:28:47+00:00"
},
{
"name": "socialiteproviders/manager",
"version": "v4.6.0",
"source": {
"type": "git",
"url": "https://github.com/SocialiteProviders/Manager.git",
"reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/dea5190981c31b89e52259da9ab1ca4e2b258b21",
"reference": "dea5190981c31b89e52259da9ab1ca4e2b258b21",
"shasum": ""
},
"require": {
"illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0",
"laravel/socialite": "^5.5",
"php": "^8.0"
},
"require-dev": {
"mockery/mockery": "^1.2",
"phpunit/phpunit": "^9.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"SocialiteProviders\\Manager\\ServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"SocialiteProviders\\Manager\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Andy Wendt",
"email": "andy@awendt.com"
},
{
"name": "Anton Komarev",
"email": "a.komarev@cybercog.su"
},
{
"name": "Miguel Piedrafita",
"email": "soy@miguelpiedrafita.com"
},
{
"name": "atymic",
"email": "atymicq@gmail.com",
"homepage": "https://atymic.dev"
}
],
"description": "Easily add new or override built-in providers in Laravel Socialite.",
"homepage": "https://socialiteproviders.com",
"keywords": [
"laravel",
"manager",
"oauth",
"providers",
"socialite"
],
"support": {
"issues": "https://github.com/socialiteproviders/manager/issues",
"source": "https://github.com/socialiteproviders/manager"
},
"time": "2024-05-04T07:57:39+00:00"
},
{
"name": "spatie/color",
"version": "1.5.3",
@ -13141,5 +13476,5 @@
"ext-zip": "*"
},
"platform-dev": [],
"plugin-api-version": "2.3.0"
"plugin-api-version": "2.6.0"
}

View File

@ -1,7 +1,5 @@
<?php
use App\Helpers\Time;
return [
'default' => env('DB_CONNECTION', 'sqlite'),
@ -17,25 +15,41 @@ return [
'mysql' => [
'driver' => 'mysql',
'url' => env('DB_URL', env('DATABASE_URL')),
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'panel'),
'username' => env('DB_USERNAME', 'pelican'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => env('DB_PREFIX', ''),
'prefix_indexes' => true,
'strict' => env('DB_STRICT_MODE', false),
'timezone' => env('DB_TIMEZONE', Time::getMySQLTimezoneOffset(env('APP_TIMEZONE', 'UTC'))),
'sslmode' => env('DB_SSLMODE', 'prefer'),
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [
'driver' => 'mariadb',
'url' => env('DB_URL'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'panel'),
'username' => env('DB_USERNAME', 'pelican'),
'password' => env('DB_PASSWORD', ''),
'unix_socket' => env('DB_SOCKET', ''),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => env('DB_PREFIX', ''),
'prefix_indexes' => true,
'strict' => env('DB_STRICT_MODE', false),
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
PDO::MYSQL_ATTR_SSL_CERT => env('MYSQL_ATTR_SSL_CERT'),
PDO::MYSQL_ATTR_SSL_KEY => env('MYSQL_ATTR_SSL_KEY'),
PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT => env('MYSQL_ATTR_SSL_VERIFY_SERVER_CERT', true),
]) : [],
],
],

View File

@ -175,4 +175,6 @@ return [
'filament' => [
'top-navigation' => env('FILAMENT_TOP_NAVIGATION', false),
],
'use_binary_prefix' => env('PANEL_USE_BINARY_PREFIX', true),
];

View File

@ -9,4 +9,16 @@ return [
'scheme' => 'https',
],
'github' => [
'client_id' => env('OAUTH_GITHUB_CLIENT_ID'),
'client_secret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
'redirect' => '/auth/oauth/callback/github',
],
'discord' => [
'client_id' => env('OAUTH_DISCORD_CLIENT_ID'),
'client_secret' => env('OAUTH_DISCORD_CLIENT_SECRET'),
'redirect' => '/auth/oauth/callback/discord',
],
];

View File

@ -27,7 +27,7 @@ class ApiKeyFactory extends Factory
'key_type' => ApiKey::TYPE_APPLICATION,
'identifier' => ApiKey::generateTokenIdentifier(ApiKey::TYPE_APPLICATION),
'token' => $token ?: $token = Str::random(ApiKey::KEY_LENGTH),
'allowed_ips' => null,
'allowed_ips' => [],
'memo' => 'Test Function Key',
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),

View File

@ -35,6 +35,7 @@ class UserFactory extends Factory
'language' => 'en',
'root_admin' => false,
'use_totp' => false,
'oauth' => [],
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];

View File

@ -7,14 +7,11 @@ use Exception;
use Illuminate\Database\Seeder;
use Illuminate\Http\UploadedFile;
use App\Services\Eggs\Sharing\EggImporterService;
use App\Services\Eggs\Sharing\EggUpdateImporterService;
class EggSeeder extends Seeder
{
protected EggImporterService $importerService;
protected EggUpdateImporterService $updateImporterService;
/**
* @var string[]
*/
@ -29,11 +26,9 @@ class EggSeeder extends Seeder
* EggSeeder constructor.
*/
public function __construct(
EggImporterService $importerService,
EggUpdateImporterService $updateImporterService
EggImporterService $importerService
) {
$this->importerService = $importerService;
$this->updateImporterService = $updateImporterService;
}
/**
@ -75,7 +70,7 @@ class EggSeeder extends Seeder
->first();
if ($egg instanceof Egg) {
$this->updateImporterService->fromFile($egg, $file);
$this->importerService->fromFile($file, $egg);
$this->command->info('Updated ' . $decoded['name']);
} else {
$this->importerService->fromFile($file);

View File

@ -13,7 +13,7 @@ return new class extends Migration
{
Schema::create('activity_logs', function (Blueprint $table) {
$table->id();
$table->uuid('batch')->nullable();
$table->char('batch', 36)->nullable();
$table->string('event')->index();
$table->string('ip');
$table->text('description')->nullable();

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('api_keys')->whereNull('allowed_ips')->update([
'allowed_ips' => '[]',
]);
Schema::table('api_keys', function (Blueprint $table) {
$table->text('allowed_ips')->nullable(false)->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('api_keys', function (Blueprint $table) {
$table->text('allowed_ips')->nullable()->change();
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->json('oauth')->after('totp_authenticated_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('oauth');
});
}
};

View File

@ -20,6 +20,7 @@ import { ServerContext } from '@/state/server';
import tw from 'twin.macro';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Icon from '@/components/elements/Icon';
import { useStoreState } from 'easy-peasy';
interface Props {
schedule: Schedule;
@ -46,6 +47,7 @@ export default ({ schedule, task }: Props) => {
const [isLoading, setIsLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const appendSchedule = ServerContext.useStoreActions((actions) => actions.schedules.appendSchedule);
const usesSyncDriver = useStoreState((state) => state.settings.data!.usesSyncDriver);
const onConfirmDeletion = () => {
setIsLoading(true);
@ -109,7 +111,7 @@ export default ({ schedule, task }: Props) => {
</div>
</div>
)}
{task.sequenceId > 1 && task.timeOffset > 0 && (
{!usesSyncDriver && task.sequenceId > 1 && task.timeOffset > 0 && (
<div css={tw`mr-6`}>
<div css={tw`flex items-center px-2 py-1 bg-neutral-500 text-sm rounded-full`}>
<Icon icon={faClock} css={tw`w-3 h-3 mr-2`} />

View File

@ -17,6 +17,7 @@ import Select from '@/components/elements/Select';
import ModalContext from '@/context/ModalContext';
import asModal from '@/hoc/asModal';
import FormikSwitch from '@/components/elements/FormikSwitch';
import { useStoreState } from 'easy-peasy';
interface Props {
schedule: Schedule;
@ -71,6 +72,7 @@ const TaskDetailsModal = ({ schedule, task }: Props) => {
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
const appendSchedule = ServerContext.useStoreActions((actions) => actions.schedules.appendSchedule);
const backupLimit = ServerContext.useStoreState((state) => state.server.data!.featureLimits.backups);
const usesSyncDriver = useStoreState((state) => state.settings.data!.usesSyncDriver);
useEffect(() => {
return () => {
@ -121,7 +123,7 @@ const TaskDetailsModal = ({ schedule, task }: Props) => {
<FlashMessageRender byKey={'schedule:task'} css={tw`mb-4`} />
<h2 css={tw`text-2xl mb-6`}>{task ? 'Edit Task' : 'Create Task'}</h2>
<div css={tw`flex`}>
<div css={tw`mr-2 w-1/3`}>
<div className={!usesSyncDriver ? 'mr-2 w-1/3' : 'w-full'}>
<Label>Action</Label>
<ActionListener />
<FormikFieldWrapper name={'action'}>
@ -132,6 +134,7 @@ const TaskDetailsModal = ({ schedule, task }: Props) => {
</FormikField>
</FormikFieldWrapper>
</div>
{!usesSyncDriver && (
<div css={tw`flex-1 ml-6`}>
<Field
name={'timeOffset'}
@ -141,6 +144,7 @@ const TaskDetailsModal = ({ schedule, task }: Props) => {
}
/>
</div>
)}
</div>
<div css={tw`mt-6`}>
{values.action === 'command' ? (

View File

@ -55,7 +55,8 @@ export default () => {
</div>
<div css={tw`ml-4`}>
<a
href={`sftp://${username}.${id}@${sftp.alias ? sftp.alias : ip(sftp.ip)}:${sftp.port
href={`sftp://${username}.${id}@${sftp.alias ? sftp.alias : ip(sftp.ip)}:${
sftp.port
}`}
>
<Button.Text variant={Button.Variants.Secondary}>Launch SFTP</Button.Text>

View File

@ -7,6 +7,7 @@ export interface SiteSettings {
enabled: boolean;
siteKey: string;
};
usesSyncDriver: boolean;
}
export interface SettingsStore {

View File

@ -18,6 +18,10 @@ Route::get('/login', [Auth\LoginController::class, 'index'])->name('auth.login')
Route::get('/password', [Auth\LoginController::class, 'index'])->name('auth.forgot-password');
Route::get('/password/reset/{token}', [Auth\LoginController::class, 'index'])->name('auth.reset');
// Endpoints for OAuth
Route::get('/oauth/redirect/{driver}', [Auth\OAuthController::class, 'redirect'])->name('auth.oauth.redirect');
Route::get('/oauth/callback/{driver}', [Auth\OAuthController::class, 'callback'])->name('auth.oauth.callback');
// Apply a throttle to authentication action endpoints, in addition to the
// recaptcha endpoints to slow down manual attack spammers even more. 🤷‍
//

View File

@ -9,6 +9,9 @@ Route::get('/account', [Base\IndexController::class, 'index'])
->withoutMiddleware(RequireTwoFactorAuthentication::class)
->name('account');
Route::get('/account/oauth/link', [Base\OAuthController::class, 'link'])->name('account.oauth.link');
Route::get('/account/oauth/unlink', [Base\OAuthController::class, 'unlink'])->name('account.oauth.unlink');
Route::get('/locales/locale.json', Base\LocaleController::class)
->withoutMiddleware(['auth', RequireTwoFactorAuthentication::class])
->where('namespace', '.*');