Compare commits

..

7 commits

Author SHA1 Message Date
Matthew Penner
ba7ff571e5
cleanup, switch to attributes 2023-01-17 16:09:28 -07:00
Matthew Penner
f631ac1946
Merge branch 'develop' into matthewpi/security-keys-backport 2023-01-17 15:33:53 -07:00
Matthew Penner
aa380d4c0d
fix SecurityKeyFactory 2022-10-31 13:42:49 -06:00
Matthew Penner
940c899eab
run php-cs-fixer 2022-10-31 13:22:00 -06:00
Matthew Penner
ce7c913e18
tests(unit): fix RequireTwoFactorAuthenticationTest 2022-10-31 13:20:06 -06:00
Matthew Penner
d7d5da6beb
ci(tests): only run on PHP 8.1 2022-10-31 12:27:36 -06:00
Matthew Penner
06f692e649
Add controllers and packages for security keys 2022-10-31 12:18:25 -06:00
131 changed files with 9048 additions and 7679 deletions

View file

@ -22,7 +22,7 @@ REDIS_PASSWORD=null
REDIS_PORT=6379
CACHE_DRIVER=file
QUEUE_CONNECTION=redis
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
HASHIDS_SALT=

View file

@ -68,8 +68,8 @@ body:
Run the following command to collect logs on your system.
Wings: `sudo wings diagnostics`
Panel: `tail -n 150 /var/www/pterodactyl/storage/logs/laravel-$(date +%F).log | nc pteropaste.com 99`
placeholder: "https://pteropaste.com/a1h6z"
Panel: `tail -n 100 /var/www/pterodactyl/storage/logs/laravel-$(date +%F).log | nc bin.ptdl.co 99`
placeholder: "https://bin.ptdl.co/a1h6z"
render: bash
validations:
required: false

View file

@ -16,13 +16,14 @@ on:
jobs:
push:
name: Push
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
if: "!contains(github.ref, 'develop') || (!contains(github.event.head_commit.message, 'skip docker') && !contains(github.event.head_commit.message, 'docker skip'))"
steps:
- name: Code checkout
uses: actions/checkout@v3
- name: Fetch metadata
id: metadata
- name: Docker metadata
id: docker_meta
uses: docker/metadata-action@v4
with:
images: ghcr.io/pterodactyl/panel
@ -41,7 +42,6 @@ jobs:
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
if: "github.event_name != 'pull_request'"
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -55,13 +55,13 @@ jobs:
sed -i "s/ 'version' => 'canary',/ 'version' => '${REF:1}',/" config/app.php
- name: Build and Push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v3
with:
context: .
file: ./Containerfile
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
labels: ${{ steps.metadata.outputs.labels }}
tags: ${{ steps.metadata.outputs.tags }}
platforms: linux/amd64 #,linux/arm64
labels: ${{ steps.docker_meta.outputs.labels }}
tags: ${{ steps.docker_meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -13,7 +13,7 @@ on:
jobs:
analysis:
name: Static Analysis
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
env:
APP_ENV: testing
APP_DEBUG: "true"
@ -42,7 +42,7 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Code checkout
uses: actions/checkout@v3
@ -50,7 +50,6 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
# TODO: Update to 8.2 once php-cs-fixer supports it
php-version: 8.1
extensions: bcmath, curl, gd, mbstring, mysql, openssl, pdo, tokenizer, xml, zip
tools: composer:v2
@ -64,11 +63,11 @@ jobs:
mysql:
name: Tests
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
php: [8.1, 8.2]
php: [8.1]
database: ["mariadb:10.2", "mariadb:10.9", "mysql:8"]
services:
database:
@ -136,12 +135,12 @@ jobs:
postgres:
name: Tests
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
if: "!contains(github.event.head_commit.message, 'skip ci') && !contains(github.event.head_commit.message, 'ci skip')"
strategy:
fail-fast: false
matrix:
php: [8.1, 8.2]
php: [8.1]
database: ["postgres:13", "postgres:14", "postgres:15"]
services:
database:

View file

@ -8,25 +8,22 @@ on:
jobs:
release:
name: Release
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Code checkout
uses: actions/checkout@v3
- name: Install pnpm
uses: pnpm/action-setup@v2
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
cache: pnpm
cache: yarn
- name: Install dependencies
run: pnpm install
run: yarn install --frozen-lockfile
- name: Build
run: pnpm run build
run: yarn build
- name: Create release branch and bump version
env:

View file

@ -13,29 +13,26 @@ on:
jobs:
lint:
name: Lint
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- name: Code checkout
uses: actions/checkout@v3
- name: Install pnpm
uses: pnpm/action-setup@v2
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: 18
cache: pnpm
cache: yarn
- name: Install dependencies
run: pnpm install
run: yarn install --frozen-lockfile
- name: Lint
run: pnpm run lint
run: yarn run lint
tests:
name: Tests
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
@ -44,20 +41,17 @@ jobs:
- name: Code checkout
uses: actions/checkout@v3
- name: Install pnpm
uses: pnpm/action-setup@v2
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
cache: yarn
- name: Install dependencies
run: pnpm install
run: yarn install --frozen-lockfile
- name: Build
run: pnpm run build
run: yarn run build
- name: Tests
run: pnpm run test
run: yarn run test

1
.npmrc
View file

@ -1 +0,0 @@
shamefully-hoist=true

63
BUILDING.md Normal file
View file

@ -0,0 +1,63 @@
# Local Development
Pterodactyl is now powered by React, Typescript, and Tailwindcss using webpack at its core to generate compiled assets.
Release versions of Pterodactyl will include pre-compiled, minified, and hashed assets ready-to-go.
However, if you are interested in running custom themes or making modifications to the React files you'll need a build
system in place to generate these compiled assets. To get your environment setup you'll need at minimum:
* [Node.js](https://nodejs.org/en/) v14.x.x
* [Yarn](https://classic.yarnpkg.com/lang/en/) v1.x.x
* [Go](https://golang.org/) 1.17.x
### Install Dependencies
```bash
yarn install
```
The command above will download all of the dependencies necessary to get Pterodactyl assets building. After that, its as
simple as running the command below to generate assets while you're developing. Until you've run this command at least
once you'll likely see a 500 error on your Panel about a missing `manifest.json` file. This is generated by the commands
below.
```bash
# Build the compiled set of assets for development.
yarn run build
# Build the assets automatically as they are changed. This allows you to refresh
# the page and see the changes immediately.
yarn run watch
```
### Hot Module Reloading
For more advanced users, we also support 'Hot Module Reloading', allowing you to quickly see changes you're making
to the Vue template files without having to reload the page you're on. To Get started with this, you just need
to run the command below.
```bash
PUBLIC_PATH=http://192.168.1.1:8080 yarn run serve --host 192.168.1.1
```
There are two _very important_ parts of this command to take note of and change for your specific environment. The first
is the `--host` flag, which is required and should point to the machine where the `webpack-serve` server will be running.
The second is the `PUBLIC_PATH` environment variable which is the URL pointing to the HMR server and is appended to all of
the asset URLs used in Pterodactyl.
#### Development Environment
If you're using the [`pterodactyl/development`](https://github.com/pterodactyl/development) environments, which are
highly recommended, you can just run `yarn run serve` to run the HMR server, no additional configuration is necessary.
### Building for Production
Once you have your files squared away and ready for the live server, you'll be needing to generate compiled, minified,
and hashed assets to push live. To do so, run the command below:
```bash
yarn run build:production
```
This will generate a production JS bundle and associated assets, all located in `public/assets/` which will need to
be uploaded to your server or CDN for clients to use.
### Running Wings
To run `wings` in development all you need to do is set up the configuration file as normal when adding a new node, and
then you can build and run a local version of Wings by executing `make debug` in the Wings code directory. This must
be run on a Linux VM of some sort, you cannot run this locally on macOS or Windows.

View file

@ -3,17 +3,6 @@ This file is a running track of new features and fixes to each version of the pa
This project follows [Semantic Versioning](http://semver.org) guidelines.
## v1.11.3
### Changed
* When updating a server's description through the client API, if no value is specified, the description will now remain unchanged.
* When installing the Panel for the first time, the queue driver will now all default to `redis` instead of `sync`.
### Fixed
* `php artisan p:environment:mail` not correctly setting the right variable for `MAIL_FROM_ADDRESS`.
* Fixed the conflict state rendering on the UI for a server showing `reinstall_failed` as `restoring_backup`.
* Fixed the unknown column `uuid` error when jobs fail, causing them not to get stored correctly.
* Fixed the server task endpoints in the client API not allowing `sequence_id` and `continue_on_failure` to be set.
## v1.11.2
### Changed
* Telemetry no longer sends a map of Egg and Nest UUIDs to the number of servers using them.

View file

@ -1,20 +1,20 @@
# Stage 1 - Builder
FROM --platform=$TARGETOS/$TARGETARCH registry.access.redhat.com/ubi9/nodejs-18-minimal AS builder
# Stage 0 - Caddy
FROM --platform=$TARGETOS/$TARGETARCH docker.io/library/caddy:latest AS caddy
USER 0
RUN npm install -g pnpm
# Stage 1 - Builder
FROM --platform=$TARGETOS/$TARGETARCH registry.access.redhat.com/ubi9/nodejs-16-minimal AS builder
RUN npm install -g yarn
WORKDIR /var/www/pterodactyl
COPY --chown=1001:0 public ./public
COPY --chown=1001:0 resources/scripts ./resources/scripts
COPY --chown=1001:0 .eslintignore .eslintrc.js .npmrc .prettierrc.json package.json pnpm-lock.yaml tailwind.config.js tsconfig.json vite.config.ts .
COPY --chown=1001:0 .eslintignore .eslintrc.js .prettierrc.json package.json tailwind.config.js tsconfig.json vite.config.ts yarn.lock .
RUN /opt/app-root/src/.npm-global/bin/pnpm install \
&& /opt/app-root/src/.npm-global/bin/pnpm build \
&& rm -rf resources/scripts .eslintignore .eslintrc.yml .npmrc package.json pnpm-lock.yaml tailwind.config.js tsconfig.json vite.config.ts node_modules
USER 1001
RUN /opt/app-root/src/.npm-global/bin/yarn install --frozen-lockfile \
&& /opt/app-root/src/.npm-global/bin/yarn build \
&& rm -rf resources/scripts .eslintignore .eslintrc.yml .yarnrc.yml package.json tailwind.config.js tsconfig.json vite.config.ts yarn.lock node_modules
COPY --chown=1001:0 app ./app
COPY --chown=1001:0 bootstrap ./bootstrap
@ -34,11 +34,10 @@ RUN microdnf update -y \
&& rpm --install https://rpms.remirepo.net/enterprise/remi-release-9.rpm \
&& microdnf update -y \
&& microdnf install -y ca-certificates shadow-utils tar tzdata unzip wget \
# ref; https://bugzilla.redhat.com/show_bug.cgi?id=1870814
&& microdnf reinstall -y tzdata \
&& microdnf module -y reset php \
&& microdnf module -y enable php:remi-8.2 \
&& microdnf install -y composer cronie php-{bcmath,cli,common,fpm,gd,gmp,intl,json,mbstring,mysqlnd,opcache,pdo,pecl-redis5,pecl-zip,phpiredis,pgsql,process,sodium,xml,zstd} supervisor \
&& microdnf module -y enable php:remi-8.1 \
&& microdnf install -y cronie php-{bcmath,cli,common,fpm,gd,gmp,intl,json,mbstring,mysqlnd,opcache,pdo,pecl-redis5,pecl-zip,phpiredis,pgsql,process,sodium,xml,zstd} supervisor \
&& curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& rm /etc/php-fpm.d/www.conf \
&& useradd --home-dir /var/lib/caddy --create-home caddy \
&& mkdir /etc/caddy \
@ -67,7 +66,7 @@ RUN composer install --no-dev --optimize-autoloader \
&& rm -rf bootstrap/cache/*.php \
&& rm -rf .env storage/logs/*.log
COPY --from=docker.io/library/caddy:latest /usr/bin/caddy /usr/local/bin/caddy
COPY --from=caddy /usr/bin/caddy /usr/local/bin/caddy
COPY .github/docker/Caddyfile /etc/caddy/Caddyfile
COPY .github/docker/php-fpm.conf /etc/php-fpm.conf
COPY .github/docker/supervisord.conf /etc/supervisord.conf

View file

@ -1,9 +0,0 @@
MIT License
Copyright (c) 2024 Skynet
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -24,20 +24,21 @@ Stop settling for less. Make game servers a first class citizen on your platform
## Sponsors
I would like to extend my sincere thanks to the following sponsors for helping fund Pterodactyl's development.
I would like to extend my sincere thanks to the following sponsors for helping fund Pterodactyl's developement.
[Interested in becoming a sponsor?](https://github.com/sponsors/matthewpi)
| Company | About |
|-----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|-----------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [**WISP**](https://wisp.gg) | Extra features. |
| [**RocketNode**](https://rocketnode.com/) | Innovative game server hosting combined with a straightforward control panel, affordable prices, and Rocket-Fast support. |
| [**Aussie Server Hosts**](https://aussieserverhosts.com/) | No frills Australian Owned and operated High Performance Server hosting for some of the most demanding games serving Australia and New Zealand. |
| [**WemX**](https://wemx.net/) | WemX helps automate your hosting company or SaaS business by automating billing, user management, authentication, and much more. |
| [**BisectHosting**](https://www.bisecthosting.com/) | BisectHosting provides Minecraft, Valheim and other server hosting services with the highest reliability and lightning fast support since 2012. |
| [**MineStrator**](https://minestrator.com/) | Looking for the most highend French hosting company for your minecraft server? More than 24,000 members on our discord trust us. Give us a try! |
| [**Skynode**](https://www.skynode.pro/) | Skynode provides blazing fast game servers along with a top-notch user experience. Whatever our clients are looking for, we're able to provide it! |
| [**VibeGAMES**](https://vibegames.net/) | VibeGAMES is a game server provider that specializes in DDOS protection for the games we offer. We have multiple locations in the US, Brazil, France, Germany, Singapore, Australia and South Africa. |
| [**DutchIS**](https://dutchis.net?ref=pterodactyl) | DutchIS provides instant infrastructure such as pay per use VPS hosting. Start your game hosting journey on DutchIS. |
| [**Skoali**](https://skoali.com/) | Skoali is a French company that hosts game servers and other types of services (VPS, WEB, Dedicated servers, ...). We also have a free plan for Minecraft and Garry's Mod. |
| [**Rabbit Computing**](https://www.rabbitcomputing.com/link.php?id=5) | Rabbit Computing offers powerful VPS servers, highly available game hosting, and fully unlimited web hosting. Use code README for 20% off your first three months! |
| [**Pterodactyl Market**](https://pterodactylmarket.com/) | Pterodactyl Market is a one-and-stop shop for Pterodactyl. In our market, you can find Add-ons, Themes, Eggs, and more for Pterodactyl. |
| [**UltraServers**](https://ultraservers.com/) | Deploy premium games hosting with the click of a button. Manage and swap games with ease and let us take care of the rest. We currently support Minecraft, Rust, ARK, 7 Days to Die, Garys MOD, CS:GO, Satisfactory and others. |
| [**Realms Hosting**](https://realmshosting.com/) | Want to build your Gaming Empire? Use Realms Hosting today to kick start your game server hosting with outstanding DDOS Protection, 24/7 Support, Cheap Prices and a Custom Control Panel. | |
### Supported Games

View file

@ -44,7 +44,7 @@ class EmailSettingsCommand extends Command
trans('command/messages.environment.mail.ask_driver'),
[
'smtp' => 'SMTP Server',
'sendmail' => 'sendmail Binary',
'mail' => 'PHP\'s Internal Mail Function',
'mailgun' => 'Mailgun Transactional Email',
'mandrill' => 'Mandrill Transactional Email',
'postmark' => 'Postmark Transactional Email',

View file

@ -10,7 +10,7 @@ class PruneOrphanedBackupsCommand extends Command
{
protected $signature = 'p:maintenance:prune-backups {--prune-age=}';
protected $description = 'Marks all backups older than "n" minutes that have not yet completed as being failed.';
protected $description = 'Marks all backups that have not completed in the last "n" minutes as being failed.';
/**
* PruneOrphanedBackupsCommand constructor.
@ -38,7 +38,7 @@ class PruneOrphanedBackupsCommand extends Command
return;
}
$this->warn("Marking $count uncompleted backups that are older than $since minutes as failed.");
$this->warn("Marking $count backups that have not been marked as completed in the last $since minutes as failed.");
$query->update([
'is_successful' => false,

View file

@ -18,7 +18,7 @@ class Kernel extends ConsoleKernel
/**
* Register the commands for the application.
*/
protected function commands(): void
protected function commands()
{
$this->load(__DIR__ . '/Commands');
}
@ -26,11 +26,8 @@ class Kernel extends ConsoleKernel
/**
* Define the application's command schedule.
*/
protected function schedule(Schedule $schedule): void
protected function schedule(Schedule $schedule)
{
// https://laravel.com/docs/10.x/upgrade#redis-cache-tags
$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();

View file

@ -73,7 +73,7 @@ final class Handler extends ExceptionHandler
*
* @noinspection PhpUnusedLocalVariableInspection
*/
public function register(): void
public function register()
{
if (config('app.exceptions.report_all', false)) {
$this->dontReport = [];

View file

@ -15,6 +15,8 @@ final class Time
*/
public static function getMySQLTimezoneOffset(string $timezone): string
{
return CarbonImmutable::now($timezone)->getTimezone()->toOffsetName();
$offset = round(CarbonImmutable::now($timezone)->getTimezone()->getOffset(CarbonImmutable::now('UTC')) / 3600);
return sprintf('%s%s:00', $offset > 0 ? '+' : '-', str_pad((string) abs($offset), 2, '0', STR_PAD_LEFT));
}
}

View file

@ -27,6 +27,8 @@ class EggController extends ApplicationApiController
public function __construct(private EggExporterService $eggExporterService)
{
parent::__construct();
$this->eggExporterService = $eggExporterService;
}
/**

View file

@ -0,0 +1,105 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client;
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\SecurityKey;
use Pterodactyl\Exceptions\DisplayException;
use Webauthn\PublicKeyCredentialCreationOptions;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Pterodactyl\Transformers\Api\Client\SecurityKeyTransformer;
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
use Pterodactyl\Services\Users\SecurityKeys\StoreSecurityKeyService;
use Pterodactyl\Http\Requests\Api\Client\Account\RegisterSecurityKeyRequest;
use Pterodactyl\Services\Users\SecurityKeys\CreatePublicKeyCredentialService;
class SecurityKeyController extends ClientApiController
{
public function __construct(
protected CreatePublicKeyCredentialService $createPublicKeyCredentialService,
protected CacheRepository $cache,
protected WebauthnServerRepository $webauthnServerRepository,
protected StoreSecurityKeyService $storeSecurityKeyService
) {
parent::__construct();
}
/**
* Returns all the hardware security keys (WebAuthn) that exists for a user.
*/
public function index(Request $request): array
{
return $this->fractal->collection($request->user()->securityKeys)
->transformWith(SecurityKeyTransformer::class)
->toArray();
}
/**
* Returns the data necessary for creating a new hardware security key for the
* user.
*
* @throws \Webauthn\Exception\InvalidDataException
*/
public function create(Request $request): JsonResponse
{
$tokenId = Str::random(64);
$credentials = $this->createPublicKeyCredentialService->handle($request->user());
// TODO: session
$this->cache->put(
"register-security-key:$tokenId",
serialize($credentials),
CarbonImmutable::now()->addMinutes(10)
);
return new JsonResponse([
'data' => [
'token_id' => $tokenId,
'credentials' => $credentials->jsonSerialize(),
],
]);
}
/**
* Stores a new key for a user account.
*
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Throwable
*/
public function store(RegisterSecurityKeyRequest $request): array
{
$credentials = unserialize(
$this->cache->pull("register-security-key:{$request->input('token_id')}", serialize(null))
);
if (
!is_object($credentials) ||
!$credentials instanceof PublicKeyCredentialCreationOptions ||
$credentials->getUser()->getId() !== $request->user()->uuid
) {
throw new DisplayException('Could not register security key: invalid data present in session, please try again.');
}
$key = $this->storeSecurityKeyService
->setRequest(SecurityKey::getPsrRequestFactory($request))
->setKeyName($request->input('name'))
->handle($request->user(), $request->input('registration'), $credentials);
return $this->fractal->item($key)
->transformWith(SecurityKeyTransformer::class)
->toArray();
}
/**
* Removes a WebAuthn key from a user's account.
*/
public function delete(Request $request, string $securityKey): JsonResponse
{
$request->user()->securityKeys()->where('uuid', $securityKey)->delete();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
}

View file

@ -18,7 +18,6 @@ use Pterodactyl\Transformers\Api\Client\BackupTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\RestoreBackupRequest;
class BackupController extends ClientApiController
{
@ -189,8 +188,12 @@ class BackupController extends ClientApiController
*
* @throws \Throwable
*/
public function restore(RestoreBackupRequest $request, Server $server, Backup $backup): JsonResponse
public function restore(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_RESTORE, $server)) {
throw new AuthorizationException();
}
// Cannot restore a backup unless a server is fully installed and not currently
// processing a different backup restoration request.
if (!is_null($server->status)) {

View file

@ -9,7 +9,6 @@ use Pterodactyl\Models\Schedule;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Pterodactyl\Models\Permission;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Eloquent\TaskRepository;
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Transformers\Api\Client\TaskTransformer;
@ -24,10 +23,8 @@ class ScheduleTaskController extends ClientApiController
/**
* ScheduleTaskController constructor.
*/
public function __construct(
private ConnectionInterface $connection,
private TaskRepository $repository
) {
public function __construct(private TaskRepository $repository)
{
parent::__construct();
}
@ -52,35 +49,14 @@ class ScheduleTaskController extends ClientApiController
$lastTask = $schedule->tasks()->orderByDesc('sequence_id')->first();
/** @var \Pterodactyl\Models\Task $task */
$task = $this->connection->transaction(function () use ($request, $schedule, $lastTask) {
$sequenceId = ($lastTask->sequence_id ?? 0) + 1;
$requestSequenceId = $request->integer('sequence_id', $sequenceId);
// Ensure that the sequence id is at least 1.
if ($requestSequenceId < 1) {
$requestSequenceId = 1;
}
// If the sequence id from the request is greater than or equal to the next available
// sequence id, we don't need to do anything special. Otherwise, we need to update
// the sequence id of all tasks that are greater than or equal to the request sequence
// id to be one greater than the current value.
if ($requestSequenceId < $sequenceId) {
$schedule->tasks()
->where('sequence_id', '>=', $requestSequenceId)
->increment('sequence_id');
$sequenceId = $requestSequenceId;
}
return $this->repository->create([
$task = $this->repository->create([
'schedule_id' => $schedule->id,
'sequence_id' => $sequenceId,
'sequence_id' => ($lastTask->sequence_id ?? 0) + 1,
'action' => $request->input('action'),
'payload' => $request->input('payload') ?? '',
'time_offset' => $request->input('time_offset'),
'continue_on_failure' => $request->boolean('continue_on_failure'),
'continue_on_failure' => (bool) $request->input('continue_on_failure'),
]);
});
Activity::event('server:task.create')
->subject($schedule, $task)
@ -108,34 +84,12 @@ class ScheduleTaskController extends ClientApiController
throw new HttpForbiddenException("A backup task cannot be created when the server's backup limit is set to 0.");
}
$this->connection->transaction(function () use ($request, $schedule, $task) {
$sequenceId = $request->integer('sequence_id', $task->sequence_id);
// Ensure that the sequence id is at least 1.
if ($sequenceId < 1) {
$sequenceId = 1;
}
// Shift all other tasks in the schedule up or down to make room for the new task.
if ($sequenceId < $task->sequence_id) {
$schedule->tasks()
->where('sequence_id', '>=', $sequenceId)
->where('sequence_id', '<', $task->sequence_id)
->increment('sequence_id');
} elseif ($sequenceId > $task->sequence_id) {
$schedule->tasks()
->where('sequence_id', '>', $task->sequence_id)
->where('sequence_id', '<=', $sequenceId)
->decrement('sequence_id');
}
$this->repository->update($task->id, [
'sequence_id' => $sequenceId,
'action' => $request->input('action'),
'payload' => $request->input('payload') ?? '',
'time_offset' => $request->input('time_offset'),
'continue_on_failure' => $request->boolean('continue_on_failure'),
'continue_on_failure' => (bool) $request->input('continue_on_failure'),
]);
});
Activity::event('server:task.update')
->subject($schedule, $task)
@ -163,9 +117,10 @@ class ScheduleTaskController extends ClientApiController
throw new HttpForbiddenException('You do not have permission to perform this action.');
}
$schedule->tasks()
->where('sequence_id', '>', $task->sequence_id)
->decrement('sequence_id');
$schedule->tasks()->where('sequence_id', '>', $task->sequence_id)->update([
'sequence_id' => $schedule->tasks()->getConnection()->raw('(sequence_id - 1)'),
]);
$task->delete();
Activity::event('server:task.delete')->subject($schedule, $task)->property('name', $schedule->name)->log();

View file

@ -35,7 +35,7 @@ class SettingsController extends ClientApiController
public function rename(RenameServerRequest $request, Server $server): JsonResponse
{
$name = $request->input('name');
$description = $request->has('description') ? (string) $request->input('description') : $server->description;
$description = $request->input('description') ?? $server->description;
$this->repository->update($server->id, [
'name' => $name,
'description' => $description,

View file

@ -8,7 +8,6 @@ use Illuminate\Auth\AuthManager;
use Illuminate\Http\JsonResponse;
use Illuminate\Auth\Events\Failed;
use Illuminate\Container\Container;
use Illuminate\Support\Facades\Event;
use Pterodactyl\Events\Auth\DirectLogin;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Http\Controllers\Controller;
@ -37,7 +36,7 @@ abstract class AbstractLoginController extends Controller
protected string $redirectTo = '/';
/**
* LoginController constructor.
* AbstractLoginController constructor.
*/
public function __construct()
{
@ -60,7 +59,7 @@ abstract class AbstractLoginController extends Controller
$this->getField($request->input('user')) => $request->input('user'),
]);
if ($request->route()->named('auth.login-checkpoint')) {
if ($request->route()->named('auth.checkpoint') || $request->route()->named('auth.checkpoint.key')) {
throw new DisplayException($message ?? trans('auth.two_factor.checkpoint_failed'));
}
@ -79,14 +78,13 @@ abstract class AbstractLoginController extends Controller
$this->auth->guard()->login($user, true);
Event::dispatch(new DirectLogin($user, true));
event(new DirectLogin($user, true));
return new JsonResponse([
'data' => [
'complete' => true,
'methods' => [],
'intended' => $this->redirectPath(),
'user' => $user->toReactObject(),
],
]);
}
@ -103,6 +101,6 @@ abstract class AbstractLoginController extends Controller
*/
protected function fireFailedLoginEvent(Authenticatable $user = null, array $credentials = [])
{
Event::dispatch(new Failed('auth', $user, $credentials));
event(new Failed('auth', $user, $credentials));
}
}

View file

@ -4,15 +4,18 @@ namespace Pterodactyl\Http\Controllers\Auth;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Http\Request;
use Pterodactyl\Models\User;
use Illuminate\Http\JsonResponse;
use PragmaRX\Google2FA\Google2FA;
use Illuminate\Support\Facades\Event;
use Pterodactyl\Models\SecurityKey;
use Illuminate\Contracts\Encryption\Encrypter;
use Webauthn\PublicKeyCredentialRequestOptions;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Pterodactyl\Events\Auth\ProvidedAuthenticationToken;
use Pterodactyl\Http\Requests\Auth\LoginCheckpointRequest;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class LoginCheckpointController extends AbstractLoginController
{
@ -24,6 +27,7 @@ class LoginCheckpointController extends AbstractLoginController
public function __construct(
private Encrypter $encrypter,
private Google2FA $google2FA,
private WebauthnServerRepository $webauthnServerRepository,
private ValidationFactory $validation
) {
parent::__construct();
@ -34,13 +38,80 @@ class LoginCheckpointController extends AbstractLoginController
* token. Once a user has reached this stage it is assumed that they have already
* provided a valid username and password.
*
* @return \Illuminate\Http\JsonResponse|void
*
* @throws \PragmaRX\Google2FA\Exceptions\IncompatibleWithGoogleAuthenticatorException
* @throws \PragmaRX\Google2FA\Exceptions\InvalidCharactersException
* @throws \PragmaRX\Google2FA\Exceptions\SecretKeyTooShortException
* @throws \Exception
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Exception
*/
public function __invoke(LoginCheckpointRequest $request): JsonResponse
public function token(LoginCheckpointRequest $request)
{
$user = $this->extractUserFromRequest($request);
// Recovery tokens go through a slightly different pathway for usage.
if (!is_null($recoveryToken = $request->input('recovery_token'))) {
if ($this->isValidRecoveryToken($user, $recoveryToken)) {
return $this->sendLoginResponse($user, $request);
}
} else {
if (!$user->use_totp) {
$this->sendFailedLoginResponse($request, $user);
}
$decrypted = $this->encrypter->decrypt($user->totp_secret);
if ($this->google2FA->verifyKey($decrypted, $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) {
return $this->sendLoginResponse($user, $request);
}
}
$this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
}
/**
* Authenticates a login request using a security key for a user.
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\DisplayException
*/
public function key(Request $request): JsonResponse
{
$options = $request->session()->get(SecurityKey::PK_SESSION_NAME);
if (!$options instanceof PublicKeyCredentialRequestOptions) {
throw new BadRequestHttpException('No security keys configured in session.');
}
$user = $this->extractUserFromRequest($request);
try {
$source = $this->webauthnServerRepository->loadAndCheckAssertionResponse(
$user,
// TODO: we may have to `json_encode` this so it will be decoded properly.
$request->input('data'),
$options,
SecurityKey::getPsrRequestFactory($request)
);
} catch (\Exception|\Throwable $e) {
throw $e;
}
if (hash_equals($user->uuid, $source->getUserHandle())) {
return $this->sendLoginResponse($user, $request);
}
throw new BadRequestHttpException('An unexpected error was encountered while validating that security key.');
}
/**
* Extracts the user from the session data using the provided confirmation token.
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\DisplayException
*/
protected function extractUserFromRequest(Request $request): User
{
if ($this->hasTooManyLoginAttempts($request)) {
$this->sendLockoutResponse($request);
@ -62,24 +133,7 @@ class LoginCheckpointController extends AbstractLoginController
$this->sendFailedLoginResponse($request, null, self::TOKEN_EXPIRED_MESSAGE);
}
// Recovery tokens go through a slightly different pathway for usage.
if (!is_null($recoveryToken = $request->input('recovery_token'))) {
if ($this->isValidRecoveryToken($user, $recoveryToken)) {
Event::dispatch(new ProvidedAuthenticationToken($user, true));
return $this->sendLoginResponse($user, $request);
}
} else {
$decrypted = $this->encrypter->decrypt($user->totp_secret);
if ($this->google2FA->verifyKey($decrypted, $request->input('authentication_code') ?? '', config('pterodactyl.auth.2fa.window'))) {
Event::dispatch(new ProvidedAuthenticationToken($user));
return $this->sendLoginResponse($user, $request);
}
}
$this->sendFailedLoginResponse($request, $user, !empty($recoveryToken) ? 'The recovery token provided is not valid.' : null);
return $user;
}
/**
@ -101,14 +155,19 @@ class LoginCheckpointController extends AbstractLoginController
return false;
}
protected function hasValidSessionData(array $data): bool
{
return static::isValidSessionData($this->validation, $data);
}
/**
* Determines if the data provided from the session is valid or not. This
* will return false if the data is invalid, or if more time has passed than
* was configured when the session was written.
*/
protected function hasValidSessionData(array $data): bool
protected static function isValidSessionData(ValidationFactory $validation, array $data): bool
{
$validator = $this->validation->make($data, [
$validator = $validation->make($data, [
'user_id' => 'required|integer|min:1',
'token_value' => 'required|string',
'expires_at' => 'required',

View file

@ -9,10 +9,23 @@ use Pterodactyl\Models\User;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Illuminate\Contracts\View\View;
use Pterodactyl\Models\SecurityKey;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
class LoginController extends AbstractLoginController
{
private const METHOD_TOTP = 'totp';
private const METHOD_WEBAUTHN = 'webauthn';
/**
* LoginController constructor.
*/
public function __construct(protected WebauthnServerRepository $webauthnServerRepository)
{
parent::__construct();
}
/**
* Handle all incoming requests for the authentication routes and render the
* base authentication view component. React will take over at this point and
@ -28,6 +41,7 @@ class LoginController extends AbstractLoginController
*
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Illuminate\Validation\ValidationException
* @throws \Webauthn\Exception\InvalidDataException
*/
public function login(Request $request): JsonResponse
{
@ -53,7 +67,9 @@ class LoginController extends AbstractLoginController
$this->sendFailedLoginResponse($request, $user);
}
if (!$user->use_totp) {
// Return early if the user does not have 2FA enabled, otherwise we will require them
// to complete a secondary challenge before they can log in.
if (!$user->has2FAEnabled()) {
return $this->sendLoginResponse($user, $request);
}
@ -65,11 +81,23 @@ class LoginController extends AbstractLoginController
'expires_at' => CarbonImmutable::now()->addMinutes(5),
]);
return new JsonResponse([
'data' => [
$response = [
'complete' => false,
'confirmation_token' => $token,
],
]);
'methods' => array_values(array_filter([
$user->use_totp ? self::METHOD_TOTP : null,
$user->securityKeys->isNotEmpty() ? self::METHOD_WEBAUTHN : null,
])),
'confirm_token' => $token,
];
if ($user->securityKeys->isNotEmpty()) {
$key = $this->webauthnServerRepository->generatePublicKeyCredentialRequestOptions($user);
$request->session()->put(SecurityKey::PK_SESSION_NAME, $key);
$request['webauthn'] = ['public_key' => $key];
}
return new JsonResponse($response);
}
}

View file

@ -86,7 +86,7 @@ class Kernel extends HttpKernel
/**
* The application's route middleware.
*/
protected $middlewareAliases = [
protected $routeMiddleware = [
'auth' => Authenticate::class,
'auth.basic' => AuthenticateWithBasicAuth::class,
'auth.session' => AuthenticateSession::class,

View file

@ -47,9 +47,7 @@ class RequireTwoFactorAuthentication
// send them right through, nothing else needs to be checked.
//
// If the level is set as admin and the user is not an admin, pass them through as well.
if ($level === self::LEVEL_NONE || $user->use_totp) {
return $next($request);
} elseif ($level === self::LEVEL_ADMIN && !$user->root_admin) {
if ($level === self::LEVEL_NONE || $user->has2FAEnabled() || ($level === self::LEVEL_ADMIN && !$user->root_admin)) {
return $next($request);
}

View file

@ -0,0 +1,21 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Account;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class RegisterSecurityKeyRequest extends ClientApiRequest
{
public function rules(): array
{
return [
'name' => ['string', 'required'],
'token_id' => ['required', 'string'],
'registration' => ['required', 'array'],
'registration.id' => ['required', 'string'],
'registration.type' => ['required', 'in:public-key'],
'registration.response.attestationObject' => ['required', 'string'],
'registration.response.clientDataJSON' => ['required', 'string'],
];
}
}

View file

@ -23,7 +23,7 @@ class ClientApiRequest extends ApplicationApiRequest
return $this->user()->can($this->permission(), $server);
}
// If there is no server available on the reqest, trigger a failure since
// If there is no server available on the request, trigger a failure since
// we expect there to be one at this point.
return false;
}

View file

@ -1,19 +0,0 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Backups;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class RestoreBackupRequest extends ClientApiRequest
{
public function permission(): string
{
return Permission::ACTION_BACKUP_RESTORE;
}
public function rules(): array
{
return ['truncate' => 'required|boolean'];
}
}

View file

@ -23,7 +23,6 @@ class StoreTaskRequest extends ViewScheduleRequest
'payload' => 'required_unless:action,backup|string|nullable',
'time_offset' => 'required|numeric|min:0|max:900',
'sequence_id' => 'sometimes|required|numeric|min:1',
'continue_on_failure' => 'sometimes|required|boolean',
];
}
}

View file

@ -18,7 +18,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
* @property array|null $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
@ -98,10 +97,6 @@ class ApiKey extends Model
protected $casts = [
'allowed_ips' => 'array',
'user_id' => 'int',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
self::CREATED_AT => 'datetime',
self::UPDATED_AT => 'datetime',
'r_' . AdminAcl::RESOURCE_USERS => 'int',
'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'int',
'r_' . AdminAcl::RESOURCE_DATABASE_HOSTS => 'int',
@ -122,7 +117,6 @@ class ApiKey extends Model
'allowed_ips',
'memo',
'last_used_at',
'expires_at',
];
/**
@ -143,7 +137,6 @@ class ApiKey extends Model
'allowed_ips' => 'nullable|array',
'allowed_ips.*' => 'string',
'last_used_at' => 'nullable|date',
'expires_at' => 'nullable|date',
'r_' . AdminAcl::RESOURCE_USERS => 'integer|min:0|max:3',
'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'integer|min:0|max:3',
'r_' . AdminAcl::RESOURCE_DATABASE_HOSTS => 'integer|min:0|max:3',
@ -155,6 +148,12 @@ class ApiKey extends Model
'r_' . AdminAcl::RESOURCE_SERVERS => 'integer|min:0|max:3',
];
protected $dates = [
self::CREATED_AT,
self::UPDATED_AT,
'last_used_at',
];
/**
* Returns the user this token is assigned to.
*/

View file

@ -42,7 +42,10 @@ class Backup extends Model
'is_locked' => 'bool',
'ignored_files' => 'array',
'bytes' => 'int',
'completed_at' => 'datetime',
];
protected $dates = [
'completed_at',
];
protected $attributes = [

View file

@ -78,9 +78,6 @@ class Egg extends Model
* Fields that are not mass assignable.
*/
protected $fillable = [
'nest_id',
'author',
'uuid',
'name',
'description',
'features',

View file

@ -71,8 +71,14 @@ class Schedule extends Model
'is_active' => 'boolean',
'is_processing' => 'boolean',
'only_when_online' => 'boolean',
'last_run_at' => 'datetime',
'next_run_at' => 'datetime',
];
/**
* Columns to mutate to a date.
*/
protected $dates = [
'last_run_at',
'next_run_at',
];
protected $attributes = [

125
app/Models/SecurityKey.php Normal file
View file

@ -0,0 +1,125 @@
<?php
namespace Pterodactyl\Models;
use Illuminate\Http\Request;
use Symfony\Component\Uid\Uuid;
use Webauthn\TrustPath\TrustPath;
use Symfony\Component\Uid\NilUuid;
use Nyholm\Psr7\Factory\Psr17Factory;
use Symfony\Component\Uid\AbstractUid;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\TrustPath\TrustPathLoader;
use Webauthn\PublicKeyCredentialDescriptor;
use Psr\Http\Message\ServerRequestInterface;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Illuminate\Database\Eloquent\Casts\Attribute;
/**
* @property int $id
* @property string $uuid
* @property int $user_id
* @property string $name
* @property string $public_key_id
* @property string $public_key
* @property AbstractUid $aaguid
* @property string $type
* @property string[] $transports
* @property string $attestation_type
* @property \Webauthn\TrustPath\TrustPath $trust_path
* @property string $user_handle
* @property int $counter
* @property array<string, mixed>|null $other_ui
*
* @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at
*/
class SecurityKey extends Model
{
use HasFactory;
public const RESOURCE_NAME = 'security_key';
public const PK_SESSION_NAME = 'security_key_pk_request';
protected $casts = [
'user_id' => 'int',
'transports' => 'array',
'other_ui' => 'array',
];
protected $guarded = [
'uuid',
'user_id',
];
public function publicKey(): Attribute
{
return new Attribute(
get: fn (string $value) => base64_decode($value),
set: fn (string $value) => base64_encode($value),
);
}
public function publicKeyId(): Attribute
{
return new Attribute(
get: fn (string $value) => base64_decode($value),
set: fn (string $value) => base64_encode($value),
);
}
public function aaguid(): Attribute
{
return Attribute::make(
get: fn (string|null $value): AbstractUid => is_null($value) ? new NilUuid() : Uuid::fromString($value),
set: fn (AbstractUid|null $value): string|null => (is_null($value) || $value instanceof NilUuid) ? null : $value->__toString(),
);
}
public function trustPath(): Attribute
{
return new Attribute(
get: fn (mixed $value) => is_null($value) ? null : TrustPathLoader::loadTrustPath(json_decode($value, true)),
set: fn (TrustPath|null $value) => json_encode($value),
);
}
public function getPublicKeyCredentialDescriptor(): PublicKeyCredentialDescriptor
{
return new PublicKeyCredentialDescriptor($this->type, $this->public_key_id, $this->transports);
}
public function getPublicKeyCredentialSource(): PublicKeyCredentialSource
{
return new PublicKeyCredentialSource(
$this->public_key_id,
$this->type,
$this->transports,
$this->attestation_type,
$this->trust_path,
$this->aaguid,
$this->public_key,
$this->user_handle,
$this->counter
);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Returns a PSR17 Request factory to be used by different Webauthn tooling.
*/
public static function getPsrRequestFactory(Request $request): ServerRequestInterface
{
$factory = new Psr17Factory();
$httpFactory = new PsrHttpFactory($factory, $factory, $factory, $factory);
return $httpFactory->createRequest($request);
}
}

View file

@ -140,6 +140,11 @@ class Server extends Model
*/
protected $with = ['allocation'];
/**
* The attributes that should be mutated to dates.
*/
protected $dates = [self::CREATED_AT, self::UPDATED_AT, 'deleted_at', 'installed_at'];
/**
* Fields that are not mass assignable.
*/
@ -162,7 +167,7 @@ class Server extends Model
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'nest_id' => 'required|exists:nests,id',
'egg_id' => 'required|exists:eggs,id',
'startup' => 'nullable|string',
'startup' => 'required|string',
'skip_scripts' => 'sometimes|boolean',
'image' => 'required|string|max:191',
'database_limit' => 'present|nullable|integer|min:0',
@ -189,10 +194,6 @@ class Server extends Model
'database_limit' => 'integer',
'allocation_limit' => 'integer',
'backup_limit' => 'integer',
self::CREATED_AT => 'datetime',
self::UPDATED_AT => 'datetime',
'deleted_at' => 'datetime',
'installed_at' => 'datetime',
];
/**

View file

@ -23,8 +23,10 @@ class TaskLog extends Model
'id' => 'integer',
'task_id' => 'integer',
'run_status' => 'integer',
'run_time' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/**
* The attributes that should be mutated to dates.
*/
protected $dates = ['run_time', 'created_at', 'updated_at'];
}

View file

@ -9,6 +9,7 @@ use Illuminate\Validation\Rules\In;
use Illuminate\Auth\Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Eloquent\Builder;
use Webauthn\PublicKeyCredentialUserEntity;
use Pterodactyl\Models\Traits\HasAccessTokens;
use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Database\Eloquent\Casts\Attribute;
@ -51,6 +52,8 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
* @property int|null $notifications_count
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\RecoveryToken[] $recoveryTokens
* @property int|null $recovery_tokens_count
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\SecurityKey[] $securityKeys
* @property int|null $security_keys_count
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers
* @property int|null $servers_count
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\UserSSHKey[] $sshKeys
@ -138,9 +141,10 @@ class User extends Model implements
'root_admin' => 'boolean',
'use_totp' => 'boolean',
'gravatar' => 'boolean',
'totp_authenticated_at' => 'datetime',
];
protected $dates = ['totp_authenticated_at'];
/**
* The attributes excluded from the model's JSON form.
*/
@ -275,6 +279,11 @@ class User extends Model implements
return $this->hasMany(UserSSHKey::class);
}
public function securityKeys(): HasMany
{
return $this->hasMany(SecurityKey::class);
}
/**
* Returns all the servers that a user can access by way of being the owner of the
* server, or because they are assigned as a subuser for that server.
@ -289,4 +298,17 @@ class User extends Model implements
})
->groupBy('servers.id');
}
public function toPublicKeyCredentialEntity(): PublicKeyCredentialUserEntity
{
return PublicKeyCredentialUserEntity::create($this->username, $this->uuid, $this->email);
}
/**
* Returns true if the user has two-factor authentication enabled.
*/
public function has2FAEnabled(): bool
{
return $this->use_totp || $this->securityKeys->isNotEmpty();
}
}

View file

@ -15,7 +15,7 @@ class AppServiceProvider extends ServiceProvider
/**
* Bootstrap any application services.
*/
public function boot(): void
public function boot()
{
Schema::defaultStringLength(191);
@ -48,7 +48,7 @@ class AppServiceProvider extends ServiceProvider
/**
* Register application service providers.
*/
public function register(): void
public function register()
{
// Only load the settings service provider if the environment
// is configured to allow it.

View file

@ -17,12 +17,14 @@ class AuthServiceProvider extends ServiceProvider
Server::class => ServerPolicy::class,
];
public function boot(): void
public function boot()
{
Sanctum::usePersonalAccessTokenModel(ApiKey::class);
$this->registerPolicies();
}
public function register(): void
public function register()
{
Sanctum::ignoreMigrations();
}

View file

@ -11,7 +11,7 @@ class BackupsServiceProvider extends ServiceProvider implements DeferrableProvid
/**
* Register the S3 backup disk.
*/
public function register(): void
public function register()
{
$this->app->singleton(BackupManager::class, function ($app) {
return new BackupManager($app);

View file

@ -9,7 +9,7 @@ class BladeServiceProvider extends ServiceProvider
/**
* Perform post-registration booting of services.
*/
public function boot(): void
public function boot()
{
$this->app->make('blade.compiler')
->directive('datetimeHuman', function ($expression) {

View file

@ -10,7 +10,7 @@ class BroadcastServiceProvider extends ServiceProvider
/**
* Bootstrap any application services.
*/
public function boot(): void
public function boot()
{
Broadcast::routes();

View file

@ -11,7 +11,7 @@ class HashidsServiceProvider extends ServiceProvider
/**
* Register the ability to use Hashids.
*/
public function register(): void
public function register()
{
$this->app->singleton(HashidsInterface::class, function () {
return new Hashids(

View file

@ -43,7 +43,7 @@ class RepositoryServiceProvider extends ServiceProvider
/**
* Register all the repository bindings.
*/
public function register(): void
public function register()
{
// Eloquent Repositories
$this->app->bind(AllocationRepositoryInterface::class, AllocationRepository::class);

View file

@ -19,7 +19,7 @@ class RouteServiceProvider extends ServiceProvider
/**
* Define your route model bindings, pattern filters, etc.
*/
public function boot(): void
public function boot()
{
$this->configureRateLimiting();
@ -68,7 +68,7 @@ class RouteServiceProvider extends ServiceProvider
/**
* Configure the rate limiters for the application.
*/
protected function configureRateLimiting(): void
protected function configureRateLimiting()
{
// Authentication rate limiting. For login and checkpoint endpoints we'll apply
// a limit of 10 requests per minute, for the forgot password endpoint apply a

View file

@ -57,7 +57,7 @@ class SettingsServiceProvider extends ServiceProvider
/**
* Boot the service provider.
*/
public function boot(ConfigRepository $config, Encrypter $encrypter, Log $log, SettingsRepositoryInterface $settings): void
public function boot(ConfigRepository $config, Encrypter $encrypter, Log $log, SettingsRepositoryInterface $settings)
{
// Only set the email driver settings from the database if we
// are configured using SMTP as the driver.

View file

@ -10,7 +10,7 @@ class ViewComposerServiceProvider extends ServiceProvider
/**
* Register bindings in the container.
*/
public function boot(): void
public function boot()
{
$this->app->make('view')->composer('*', AssetComposer::class);
}

View file

@ -0,0 +1,68 @@
<?php
namespace Pterodactyl\Repositories\SecurityKeys;
use Pterodactyl\Models\User;
use Illuminate\Container\Container;
use Pterodactyl\Models\SecurityKey;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialUserEntity;
use Webauthn\PublicKeyCredentialSourceRepository as PublicKeyRepositoryInterface;
class PublicKeyCredentialSourceRepository implements PublicKeyRepositoryInterface
{
protected User $user;
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Find a single hardware security token for a user by using the credential ID.
*/
public function findOneByCredentialId(string $id): ?PublicKeyCredentialSource
{
/** @var \Pterodactyl\Models\SecurityKey $key */
$key = $this->user->securityKeys()
->where('public_key_id', base64_encode($id))
->first();
return optional($key)->getPublicKeyCredentialSource();
}
/**
* Find all the hardware tokens that exist for the user using the given
* entity handle.
*/
public function findAllForUserEntity(PublicKeyCredentialUserEntity $entity): array
{
$results = $this->user->securityKeys()
->where('user_handle', $entity->getId())
->get();
return $results->map(function (SecurityKey $key) {
return $key->getPublicKeyCredentialSource();
})->values()->toArray();
}
/**
* Save a credential to the database and link it with the user.
*
* @throws \Throwable
*/
public function saveCredentialSource(PublicKeyCredentialSource $source): void
{
// no-op — we handle creation of the keys in StoreSecurityKeyService
//
// If you put logic in here it is triggered on each login.
}
/**
* Returns a new instance of the repository with the provided user attached.
*/
public static function factory(User $user): self
{
return Container::getInstance()->make(static::class, ['user' => $user]);
}
}

View file

@ -0,0 +1,161 @@
<?php
namespace Pterodactyl\Repositories\SecurityKeys;
use Cose\Algorithms;
use Illuminate\Support\Str;
use Pterodactyl\Models\User;
use Pterodactyl\Models\SecurityKey;
use Webauthn\PublicKeyCredentialLoader;
use Webauthn\PublicKeyCredentialSource;
use Webauthn\PublicKeyCredentialRpEntity;
use Webauthn\PublicKeyCredentialParameters;
use Psr\Http\Message\ServerRequestInterface;
use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorSelectionCriteria;
use Webauthn\AuthenticatorAttestationResponse;
use Cose\Algorithm\Manager as AlgorithmManager;
use Webauthn\PublicKeyCredentialRequestOptions;
use Webauthn\PublicKeyCredentialCreationOptions;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\AttestationStatement\AttestationObjectLoader;
use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler;
use Webauthn\AttestationStatement\AttestationStatementSupportManager;
final class WebauthnServerRepository
{
private PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository;
private PublicKeyCredentialRpEntity $rpEntity;
private PublicKeyCredentialLoader $credentialLoader;
private AuthenticatorAssertionResponseValidator $assertionValidator;
private AuthenticatorAttestationResponseValidator $attestationValidator;
public function __construct(PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository)
{
$url = str_replace(['http://', 'https://'], '', config('app.url'));
$this->publicKeyCredentialSourceRepository = $publicKeyCredentialSourceRepository;
$this->rpEntity = new PublicKeyCredentialRpEntity(config('app.name'), trim($url, '/'));
$this->credentialLoader = new PublicKeyCredentialLoader(new AttestationObjectLoader(new AttestationStatementSupportManager()));
$this->assertionValidator = new AuthenticatorAssertionResponseValidator(
$this->publicKeyCredentialSourceRepository,
null,
ExtensionOutputCheckerHandler::create(),
AlgorithmManager::create(),
);
$this->attestationValidator = new AuthenticatorAttestationResponseValidator(
new AttestationStatementSupportManager(),
$this->publicKeyCredentialSourceRepository,
null,
new ExtensionOutputCheckerHandler(),
);
}
/**
* @throws \Webauthn\Exception\InvalidDataException
*/
public function getPublicKeyCredentialCreationOptions(User $user): PublicKeyCredentialCreationOptions
{
$excluded = $user->securityKeys->map(function (SecurityKey $key) {
return $key->getPublicKeyCredentialDescriptor();
})->values()->toArray();
$challenge = Str::random(16);
return (new PublicKeyCredentialCreationOptions(
$this->rpEntity,
$user->toPublicKeyCredentialEntity(),
$challenge,
[
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES256),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES256K),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES384),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ES512),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_RS256),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_RS384),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_RS512),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_PS256),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_PS384),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_PS512),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ED256),
PublicKeyCredentialParameters::create('public-key', Algorithms::COSE_ALGORITHM_ED512),
],
))
->setTimeout(30_000)
->excludeCredentials(...$excluded)
->setAttestation(PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE)
->setAuthenticatorSelection(AuthenticatorSelectionCriteria::create());
}
/**
* @throws \Webauthn\Exception\InvalidDataException
*/
public function generatePublicKeyCredentialRequestOptions(User $user): PublicKeyCredentialRequestOptions
{
$allowedCredentials = $user->securityKeys->map(function (SecurityKey $key) {
return $key->getPublicKeyCredentialDescriptor();
})->values()->toArray();
return (new PublicKeyCredentialRequestOptions(Str::random(32)))
->allowCredentials(...$allowedCredentials)
->setUserVerification(PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED);
}
/**
* @throws \Throwable
* @throws \JsonException
*/
public function loadAndCheckAssertionResponse(
User $user,
array $data,
PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions,
ServerRequestInterface $request
): PublicKeyCredentialSource {
$credential = $this->credentialLoader->loadArray($data);
$authenticatorAssertionResponse = $credential->getResponse();
if (!$authenticatorAssertionResponse instanceof AuthenticatorAssertionResponse) {
// TODO
throw new \Exception('');
}
return $this->assertionValidator->check(
$credential->getRawId(),
$authenticatorAssertionResponse,
$publicKeyCredentialRequestOptions,
$request,
null, // TODO: use handle?
// $user->toPublicKeyCredentialEntity()
);
}
/**
* Register a new security key for a user.
*
* @throws \Throwable
* @throws \JsonException
*/
public function loadAndCheckAttestationResponse(
User $user,
array $data,
PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions,
ServerRequestInterface $request
): PublicKeyCredentialSource {
$credential = $this->credentialLoader->loadArray($data);
$authenticatorAttestationResponse = $credential->getResponse();
if (!$authenticatorAttestationResponse instanceof AuthenticatorAttestationResponse) {
// TODO
throw new \Exception('');
}
return $this->attestationValidator->check(
$authenticatorAttestationResponse,
$publicKeyCredentialCreationOptions,
$request,
);
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace Pterodactyl\Services\Users\SecurityKeys;
use Pterodactyl\Models\User;
use Webauthn\PublicKeyCredentialCreationOptions;
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
class CreatePublicKeyCredentialService
{
protected WebauthnServerRepository $webauthnServerRepository;
public function __construct(WebauthnServerRepository $webauthnServerRepository)
{
$this->webauthnServerRepository = $webauthnServerRepository;
}
/**
* @throws \Webauthn\Exception\InvalidDataException
*/
public function handle(User $user): PublicKeyCredentialCreationOptions
{
return $this->webauthnServerRepository->getPublicKeyCredentialCreationOptions($user);
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace Pterodactyl\Services\Users\SecurityKeys;
use Ramsey\Uuid\Uuid;
use Illuminate\Support\Str;
use Pterodactyl\Models\User;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\SecurityKey;
use Psr\Http\Message\ServerRequestInterface;
use Webauthn\PublicKeyCredentialCreationOptions;
use Pterodactyl\Repositories\SecurityKeys\WebauthnServerRepository;
class StoreSecurityKeyService
{
protected ?ServerRequestInterface $request = null;
protected ?string $keyName = null;
public function __construct(protected WebauthnServerRepository $webauthnServerRepository)
{
}
/**
* Sets the server request interface on the service, this is needed by the attestation
* checking service on the Webauthn server.
*/
public function setRequest(ServerRequestInterface $request): self
{
$this->request = $request;
return $this;
}
/**
* Sets the security key's name. If not provided a random string will be used.
*/
public function setKeyName(?string $name): self
{
$this->keyName = $name;
return $this;
}
/**
* Validates and stores a new hardware security key on a user's account.
*
* @throws \Throwable
*/
public function handle(User $user, array $registration, PublicKeyCredentialCreationOptions $options): SecurityKey
{
Assert::notNull($this->request, 'A request interface must be set on the service before it can be called.');
$source = $this->webauthnServerRepository->loadAndCheckAttestationResponse($user, $registration, $options, $this->request);
// Unfortunately this repository interface doesn't define a response — it is explicitly
// void — so we need to just query the database immediately after this to pull the information
// we just stored to return to the caller.
/** @var \Pterodactyl\Models\SecurityKey $key */
$key = $user->securityKeys()->make()->forceFill([
'uuid' => Uuid::uuid4(),
'name' => $this->keyName ?? 'Security Key (' . Str::random() . ')',
'public_key_id' => $source->getPublicKeyCredentialId(),
'public_key' => $source->getCredentialPublicKey(),
'aaguid' => $source->getAaguid(),
'type' => $source->getType(),
'transports' => $source->getTransports(),
'attestation_type' => $source->getAttestationType(),
'trust_path' => $source->getTrustPath(),
'user_handle' => $user->uuid,
'counter' => $source->getCounter(),
'other_ui' => $source->getOtherUI(),
]);
$key->saveOrFail();
return $key;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\SecurityKey;
use Pterodactyl\Transformers\Api\Transformer;
class SecurityKeyTransformer extends Transformer
{
public function getResourceName(): string
{
return SecurityKey::RESOURCE_NAME;
}
public function transform(SecurityKey $model): array
{
return [
'uuid' => $model->uuid,
'name' => $model->name,
'type' => $model->type,
'public_key_id' => base64_encode($model->public_key_id),
'created_at' => self::formatTimestamp($model->created_at),
'updated_at' => self::formatTimestamp($model->updated_at),
];
}
}

View file

@ -24,47 +24,50 @@
"ext-pdo_mysql": "*",
"ext-posix": "*",
"ext-zip": "*",
"aws/aws-sdk-php": "~3.260.1",
"doctrine/dbal": "~3.6.0",
"guzzlehttp/guzzle": "~7.5.0",
"hashids/hashids": "~5.0.0",
"laracasts/utilities": "~3.2.2",
"laravel/framework": "~10.1.3",
"laravel/helpers": "~1.6.0",
"laravel/sanctum": "~3.2.1",
"laravel/tinker": "~2.8.1",
"laravel/ui": "~4.2.1",
"lcobucci/jwt": "~4.3.0",
"league/flysystem-aws-s3-v3": "~3.12.2",
"league/flysystem-memory": "~3.10.3",
"aws/aws-sdk-php": "~3.253",
"doctrine/dbal": "~3.5",
"guzzlehttp/guzzle": "~7.5",
"hashids/hashids": "~4.1",
"laracasts/utilities": "~3.2",
"laravel/framework": "~9.43",
"laravel/helpers": "~1.5",
"laravel/sanctum": "~3.0",
"laravel/tinker": "~2.7",
"laravel/ui": "~4.1",
"lcobucci/jwt": "~4.2",
"league/flysystem-aws-s3-v3": "~3.10",
"league/flysystem-memory": "~3.10",
"matriphe/iso-639": "~1.2",
"phpseclib/phpseclib": "~3.0.18",
"pragmarx/google2fa": "~8.0.0",
"predis/predis": "~2.1.1",
"prologue/alerts": "~1.1.0",
"psr/cache": "~3.0.0",
"s1lentium/iptools": "~1.2.0",
"spatie/laravel-fractal": "~6.0.3",
"spatie/laravel-query-builder": "~5.1.2",
"staudenmeir/belongs-to-through": "~2.13",
"symfony/http-client": "~6.2.6",
"symfony/mailgun-mailer": "~6.2.5",
"symfony/postmark-mailer": "~6.2.5",
"symfony/yaml": "~6.2.5",
"webmozart/assert": "~1.11.0"
"nyholm/psr7": "~1.5",
"phpseclib/phpseclib": "~3.0",
"pragmarx/google2fa": "~8.0",
"predis/predis": "~2.0",
"psr/cache": "~3.0",
"s1lentium/iptools": "~1.1",
"spatie/laravel-fractal": "~6.0",
"spatie/laravel-query-builder": "~5.1",
"staudenmeir/belongs-to-through": "~2.12",
"symfony/http-client": "~6.0",
"symfony/mailgun-mailer": "~6.0",
"symfony/postmark-mailer": "~6.0",
"symfony/psr-http-message-bridge": "~2.1",
"symfony/yaml": "~6.0",
"web-auth/webauthn-lib": "~4.3",
"webmozart/assert": "~1.11"
},
"require-dev": {
"barryvdh/laravel-ide-helper": "~2.13.0",
"fakerphp/faker": "~1.21.0",
"friendsofphp/php-cs-fixer": "~3.14.4",
"itsgoingd/clockwork": "~5.1.12",
"laravel/sail": "~1.21.0",
"mockery/mockery": "~1.5.1",
"nunomaduro/collision": "~7.0.5",
"nunomaduro/larastan": "~2.4.1",
"phpstan/phpstan": "~1.10.1",
"phpunit/phpunit": "~10.0.11",
"spatie/laravel-ignition": "~2.0.0"
"barryvdh/laravel-ide-helper": "~2.12.3",
"fakerphp/faker": "~1.20",
"friendsofphp/php-cs-fixer": "~3.11",
"itsgoingd/clockwork": "~5.1",
"laravel/sail": "~1.16",
"mockery/mockery": "~1.5",
"nunomaduro/collision": "~6.3",
"nunomaduro/larastan": "^2.0",
"phpstan/phpstan": "~1.9",
"php-mock/php-mock-phpunit": "~2.6",
"phpunit/phpunit": "~9.5",
"spatie/laravel-ignition": "~1.5"
},
"autoload": {
"files": [
@ -84,17 +87,18 @@
"scripts": {
"cs:fix": "php-cs-fixer fix",
"cs:check": "php-cs-fixer fix --dry-run --diff --verbose",
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi || true"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
"@php artisan key:generate"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover || true"
]
},
"prefer-stable": true,
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
@ -102,7 +106,5 @@
"platform": {
"php": "8.1.0"
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
}

2778
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -27,7 +27,7 @@ return [
| sending an e-mail. You will specify which one you are using for your
| mailers below. You are free to add additional mailers as required.
|
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
| Supported: "smtp", "sendmail", "mailgun", "ses",
| "postmark", "log", "array", "failover"
|
*/

View file

@ -34,7 +34,7 @@ class EggVariableFactory extends Factory
/**
* Indicate that the egg variable is viewable.
*/
public function viewable(): static
public function viewable(): Factory
{
return $this->state(function (array $attributes) {
return [
@ -46,7 +46,7 @@ class EggVariableFactory extends Factory
/**
* Indicate that the egg variable is editable.
*/
public function editable(): static
public function editable(): Factory
{
return $this->state(function (array $attributes) {
return [

View file

@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use Ramsey\Uuid\Uuid;
use Pterodactyl\Models\SecurityKey;
use Webauthn\TrustPath\EmptyTrustPath;
use Illuminate\Database\Eloquent\Factories\Factory;
class SecurityKeyFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = SecurityKey::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'uuid' => Uuid::uuid4()->toString(),
'name' => $this->faker->word,
'type' => 'public-key',
'transports' => [],
'attestation_type' => 'none',
'trust_path' => new EmptyTrustPath(),
'counter' => 0,
];
}
}

View file

@ -41,7 +41,7 @@ class UserFactory extends Factory
/**
* Indicate that the user is an admin.
*/
public function admin(): static
public function admin(): Factory
{
return $this->state(['root_admin' => true]);
}

View file

@ -1,21 +1,21 @@
{
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PTERODACTYL PANEL - PTERODACTYL.IO",
"meta": {
"version": "PTDL_v2",
"version": "PTDL_v1",
"update_url": null
},
"exported_at": "2023-03-25T13:37:00+00:00",
"exported_at": "2022-01-18T11:44:55-05:00",
"name": "Rust",
"author": "support@pterodactyl.io",
"description": "The only aim in Rust is to survive. To do this you will need to overcome struggles such as hunger, thirst and cold. Build a fire. Build a shelter. Kill animals for meat. Protect yourself from other players, and kill them for meat. Create alliances with other players and form a town. Do whatever it takes to survive.",
"features": [
"steam_disk_space"
],
"docker_images": {
"quay.io\/pterodactyl\/core:rust": "quay.io\/pterodactyl\/core:rust"
},
"images": [
"quay.io\/pterodactyl\/core:rust"
],
"file_denylist": [],
"startup": ".\/RustDedicated -batchmode +server.port {{SERVER_PORT}} +server.queryport {{QUERY_PORT}} +server.identity \"rust\" +rcon.port {{RCON_PORT}} +rcon.web true +server.hostname \\\"{{HOSTNAME}}\\\" +server.level \\\"{{LEVEL}}\\\" +server.description \\\"{{DESCRIPTION}}\\\" +server.url \\\"{{SERVER_URL}}\\\" +server.headerimage \\\"{{SERVER_IMG}}\\\" +server.logoimage \\\"{{SERVER_LOGO}}\\\" +server.maxplayers {{MAX_PLAYERS}} +rcon.password \\\"{{RCON_PASS}}\\\" +server.saveinterval {{SAVEINTERVAL}} +app.port {{APP_PORT}} $( [ -z ${MAP_URL} ] && printf %s \"+server.worldsize \\\"{{WORLD_SIZE}}\\\" +server.seed \\\"{{WORLD_SEED}}\\\"\" || printf %s \"+server.levelurl {{MAP_URL}}\" ) {{ADDITIONAL_ARGS}}",
"startup": ".\/RustDedicated -batchmode +server.port {{SERVER_PORT}} +server.identity \"rust\" +rcon.port {{RCON_PORT}} +rcon.web true +server.hostname \\\"{{HOSTNAME}}\\\" +server.level \\\"{{LEVEL}}\\\" +server.description \\\"{{DESCRIPTION}}\\\" +server.url \\\"{{SERVER_URL}}\\\" +server.headerimage \\\"{{SERVER_IMG}}\\\" +server.logoimage \\\"{{SERVER_LOGO}}\\\" +server.maxplayers {{MAX_PLAYERS}} +rcon.password \\\"{{RCON_PASS}}\\\" +server.saveinterval {{SAVEINTERVAL}} +app.port {{APP_PORT}} $( [ -z ${MAP_URL} ] && printf %s \"+server.worldsize \\\"{{WORLD_SIZE}}\\\" +server.seed \\\"{{WORLD_SEED}}\\\"\" || printf %s \"+server.levelurl {{MAP_URL}}\" ) {{ADDITIONAL_ARGS}}",
"config": {
"files": "{}",
"startup": "{\r\n \"done\": \"Server startup complete\"\r\n}",
@ -37,18 +37,16 @@
"default_value": "A Rust Server",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:60",
"field_type": "text"
"rules": "required|string|max:60"
},
{
"name": "Modding Framework",
"description": "The modding framework to be used: carbon, oxide, vanilla.\r\nDefaults to \"vanilla\" for a non-modded server installation.",
"env_variable": "FRAMEWORK",
"default_value": "vanilla",
"name": "OxideMod",
"description": "Set whether you want the server to use and auto update OxideMod or not. Valid options are \"1\" for true and \"0\" for false.",
"env_variable": "OXIDE",
"default_value": "0",
"user_viewable": true,
"user_editable": true,
"rules": "required|in:carbon,oxide,vanilla",
"field_type": "text"
"rules": "required|boolean"
},
{
"name": "Level",
@ -57,8 +55,7 @@
"default_value": "Procedural Map",
"user_viewable": true,
"user_editable": true,
"rules": "required|string|max:20",
"field_type": "text"
"rules": "required|string|max:20"
},
{
"name": "Description",
@ -67,8 +64,7 @@
"default_value": "Powered by Pterodactyl",
"user_viewable": true,
"user_editable": true,
"rules": "required|string",
"field_type": "text"
"rules": "required|string"
},
{
"name": "URL",
@ -77,8 +73,7 @@
"default_value": "http:\/\/pterodactyl.io",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
"rules": "nullable|url"
},
{
"name": "World Size",
@ -87,8 +82,7 @@
"default_value": "3000",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
"rules": "required|integer"
},
{
"name": "World Seed",
@ -97,8 +91,7 @@
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string",
"field_type": "text"
"rules": "nullable|string"
},
{
"name": "Max Players",
@ -107,8 +100,7 @@
"default_value": "40",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
"rules": "required|integer"
},
{
"name": "Server Image",
@ -117,18 +109,7 @@
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
},
{
"name": "Query Port",
"description": "Server Query Port. Can't be the same as Game's primary port.",
"env_variable": "QUERY_PORT",
"default_value": "27017",
"user_viewable": true,
"user_editable": false,
"rules": "required|integer",
"field_type": "text"
"rules": "nullable|url"
},
{
"name": "RCON Port",
@ -137,18 +118,16 @@
"default_value": "28016",
"user_viewable": true,
"user_editable": false,
"rules": "required|integer",
"field_type": "text"
"rules": "required|integer"
},
{
"name": "RCON Password",
"description": "RCON access password.",
"env_variable": "RCON_PASS",
"default_value": "",
"default_value": "CHANGEME",
"user_viewable": true,
"user_editable": true,
"rules": "required|regex:\/^[\\w.-]*$\/|max:64",
"field_type": "text"
"rules": "required|regex:\/^[\\w.-]*$\/|max:64"
},
{
"name": "Save Interval",
@ -157,8 +136,7 @@
"default_value": "60",
"user_viewable": true,
"user_editable": true,
"rules": "required|integer",
"field_type": "text"
"rules": "required|integer"
},
{
"name": "Additional Arguments",
@ -167,8 +145,7 @@
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|string",
"field_type": "text"
"rules": "nullable|string"
},
{
"name": "App Port",
@ -177,8 +154,7 @@
"default_value": "28082",
"user_viewable": true,
"user_editable": false,
"rules": "required|integer",
"field_type": "text"
"rules": "required|integer"
},
{
"name": "Server Logo",
@ -187,8 +163,7 @@
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
"rules": "nullable|url"
},
{
"name": "Custom Map URL",
@ -197,8 +172,7 @@
"default_value": "",
"user_viewable": true,
"user_editable": true,
"rules": "nullable|url",
"field_type": "text"
"rules": "nullable|url"
}
]
}

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateSecurityKeysTable extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('security_keys', function (Blueprint $table) {
$table->id();
$table->char('uuid', 36)->unique();
$table->unsignedInteger('user_id');
$table->string('name');
$table->text('public_key_id');
$table->text('public_key');
$table->char('aaguid', 36)->nullable();
$table->string('type');
$table->json('transports');
$table->string('attestation_type');
$table->json('trust_path');
$table->text('user_handle');
$table->unsignedInteger('counter');
$table->json('other_ui')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('security_keys');
}
}

View file

@ -1,34 +0,0 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('failed_jobs', function (Blueprint $table) {
$table->string('uuid')->after('id')->nullable()->unique();
});
DB::table('failed_jobs')->whereNull('uuid')->cursor()->each(function ($job) {
DB::table('failed_jobs')
->where('id', $job->id)
->update(['uuid' => (string) Illuminate\Support\Str::uuid()]);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('failed_jobs', function (Blueprint $table) {
$table->dropColumn('uuid');
});
}
};

View file

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

View file

@ -1,5 +1,61 @@
{
"nodes": {
"alejandra": {
"inputs": {
"fenix": "fenix",
"flakeCompat": "flakeCompat",
"nixpkgs": [
"dream2nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1658427149,
"narHash": "sha256-ToD/1z/q5VHsLMrS2h96vjJoLho59eNRtknOUd19ey8=",
"owner": "kamadorueda",
"repo": "alejandra",
"rev": "f5a22afd2adfb249b4e68e0b33aa1f0fb73fb1be",
"type": "github"
},
"original": {
"owner": "kamadorueda",
"repo": "alejandra",
"type": "github"
}
},
"all-cabal-json": {
"flake": false,
"locked": {
"lastModified": 1665552503,
"narHash": "sha256-r14RmRSwzv5c+bWKUDaze6pXM7nOsiz1H8nvFHJvufc=",
"owner": "nix-community",
"repo": "all-cabal-json",
"rev": "d7c0434eebffb305071404edcf9d5cd99703878e",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "hackage",
"repo": "all-cabal-json",
"type": "github"
}
},
"crane": {
"flake": false,
"locked": {
"lastModified": 1661875961,
"narHash": "sha256-f1h/2c6Teeu1ofAHWzrS8TwBPcnN+EEu+z1sRVmMQTk=",
"owner": "ipetkov",
"repo": "crane",
"rev": "d9f394e4e20e97c2a60c3ad82c2b6ef99be19e24",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"devshell": {
"flake": false,
"locked": {
@ -18,21 +74,28 @@
},
"dream2nix": {
"inputs": {
"alejandra": "alejandra",
"all-cabal-json": "all-cabal-json",
"crane": "crane",
"devshell": "devshell",
"flake-compat": "flake-compat",
"flake-parts": "flake-parts",
"nix-unit": "nix-unit",
"flake-utils-pre-commit": "flake-utils-pre-commit",
"ghc-utils": "ghc-utils",
"gomod2nix": "gomod2nix",
"mach-nix": "mach-nix",
"nix-pypi-fetcher": "nix-pypi-fetcher",
"nixpkgs": [
"nixpkgs"
],
"poetry2nix": "poetry2nix",
"pre-commit-hooks": "pre-commit-hooks"
},
"locked": {
"lastModified": 1695717405,
"narHash": "sha256-MvHrU3h0Bw57s2p+wCUnSZliR4wvvPi3xkW+MRWB5HU=",
"lastModified": 1669743839,
"narHash": "sha256-zxnaRaWfCJxy0JlORD4Kmtzd0pfpcGLnyaCIJY8OlIo=",
"owner": "nix-community",
"repo": "dream2nix",
"rev": "6dbd59e4a47bd916a655c4425a3e730c6aeae033",
"rev": "b6af93946130748f72671dfd2ab84a5aeaf1f191",
"type": "github"
},
"original": {
@ -41,35 +104,39 @@
"type": "github"
}
},
"flake-compat": {
"flake": false,
"fenix": {
"inputs": {
"nixpkgs": [
"dream2nix",
"alejandra",
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1673956053,
"narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
"lastModified": 1657607339,
"narHash": "sha256-HaqoAwlbVVZH2n4P3jN2FFPMpVuhxDy1poNOR7kzODc=",
"owner": "nix-community",
"repo": "fenix",
"rev": "b814c83d9e6aa5a28d0cf356ecfdafb2505ad37d",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"dream2nix",
"nixpkgs"
]
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1675933616,
"narHash": "sha256-/rczJkJHtx16IFxMmAWu5nNYcSXNg1YYXTHoGjLrLUA=",
"lastModified": 1668450977,
"narHash": "sha256-cfLhMhnvXn6x1vPm+Jow3RiFAUSCw/l1utktCw5rVA4=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "47478a4a003e745402acf63be7f9a092d51b83d7",
"rev": "d591857e9d7dd9ddbfba0ea02b43b927c3c0f1fa",
"type": "github"
},
"original": {
@ -79,15 +146,12 @@
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"lastModified": 1667395993,
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f",
"type": "github"
},
"original": {
@ -96,16 +160,13 @@
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"flake-utils-pre-commit": {
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"lastModified": 1644229661,
"narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797",
"type": "github"
},
"original": {
@ -114,6 +175,69 @@
"type": "github"
}
},
"flakeCompat": {
"flake": false,
"locked": {
"lastModified": 1650374568,
"narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "b4a34015c698c7793d592d66adbab377907a2be8",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"ghc-utils": {
"flake": false,
"locked": {
"lastModified": 1662774800,
"narHash": "sha256-1Rd2eohGUw/s1tfvkepeYpg8kCEXiIot0RijapUjAkE=",
"ref": "refs/heads/master",
"rev": "bb3a2d3dc52ff0253fb9c2812bd7aa2da03e0fea",
"revCount": 1072,
"type": "git",
"url": "https://gitlab.haskell.org/bgamari/ghc-utils"
},
"original": {
"type": "git",
"url": "https://gitlab.haskell.org/bgamari/ghc-utils"
}
},
"gomod2nix": {
"flake": false,
"locked": {
"lastModified": 1627572165,
"narHash": "sha256-MFpwnkvQpauj799b4QTBJQFEddbD02+Ln5k92QyHOSk=",
"owner": "tweag",
"repo": "gomod2nix",
"rev": "67f22dd738d092c6ba88e420350ada0ed4992ae8",
"type": "github"
},
"original": {
"owner": "tweag",
"repo": "gomod2nix",
"type": "github"
}
},
"mach-nix": {
"flake": false,
"locked": {
"lastModified": 1634711045,
"narHash": "sha256-m5A2Ty88NChLyFhXucECj6+AuiMZPHXNbw+9Kcs7F6Y=",
"owner": "DavHau",
"repo": "mach-nix",
"rev": "4433f74a97b94b596fa6cd9b9c0402104aceef5d",
"type": "github"
},
"original": {
"id": "mach-nix",
"type": "indirect"
}
},
"mk-node-package": {
"inputs": {
"flake-utils": [
@ -139,62 +263,29 @@
"type": "github"
}
},
"nix-github-actions": {
"inputs": {
"nixpkgs": [
"dream2nix",
"nix-unit",
"nixpkgs"
]
},
"nix-pypi-fetcher": {
"flake": false,
"locked": {
"lastModified": 1688870561,
"narHash": "sha256-4UYkifnPEw1nAzqqPOTL2MvWtm3sNGw1UTYTalkTcGY=",
"owner": "nix-community",
"repo": "nix-github-actions",
"rev": "165b1650b753316aa7f1787f3005a8d2da0f5301",
"lastModified": 1669065297,
"narHash": "sha256-UStjXjNIuIm7SzMOWvuYWIHBkPUKQ8Id63BMJjnIDoA=",
"owner": "DavHau",
"repo": "nix-pypi-fetcher",
"rev": "a9885ac6a091576b5195d547ac743d45a2a615ac",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nix-github-actions",
"type": "github"
}
},
"nix-unit": {
"inputs": {
"flake-parts": [
"dream2nix",
"flake-parts"
],
"nix-github-actions": "nix-github-actions",
"nixpkgs": [
"dream2nix",
"nixpkgs"
],
"treefmt-nix": "treefmt-nix"
},
"locked": {
"lastModified": 1690289081,
"narHash": "sha256-PCXQAQt8+i2pkUym9P1JY4JGoeZJLzzxWBhprHDdItM=",
"owner": "adisbladis",
"repo": "nix-unit",
"rev": "a9d6f33e50d4dcd9cfc0c92253340437bbae282b",
"type": "github"
},
"original": {
"owner": "adisbladis",
"repo": "nix-unit",
"owner": "DavHau",
"repo": "nix-pypi-fetcher",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1695644571,
"narHash": "sha256-asS9dCCdlt1lPq0DLwkVBbVoEKuEuz+Zi3DG7pR/RxA=",
"lastModified": 1669542132,
"narHash": "sha256-DRlg++NJAwPh8io3ExBJdNW7Djs3plVI5jgYQ+iXAZQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6500b4580c2a1f3d0f980d32d285739d8e156d92",
"rev": "a115bb9bd56831941be3776c8a94005867f316a7",
"type": "github"
},
"original": {
@ -204,6 +295,24 @@
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"dir": "lib",
"lastModified": 1665349835,
"narHash": "sha256-UK4urM3iN80UXQ7EaOappDzcisYIuEURFRoGQ/yPkug=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "34c5293a71ffdb2fe054eb5288adc1882c1eb0b1",
"type": "github"
},
"original": {
"dir": "lib",
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"npmlock2nix": {
"flake": false,
"locked": {
@ -238,9 +347,29 @@
"type": "github"
}
},
"poetry2nix": {
"flake": false,
"locked": {
"lastModified": 1666918719,
"narHash": "sha256-BkK42fjAku+2WgCOv2/1NrPa754eQPV7gPBmoKQBWlc=",
"owner": "nix-community",
"repo": "poetry2nix",
"rev": "289efb187123656a116b915206e66852f038720e",
"type": "github"
},
"original": {
"owner": "nix-community",
"ref": "1.36.0",
"repo": "poetry2nix",
"type": "github"
}
},
"pre-commit-hooks": {
"inputs": {
"flake-utils": "flake-utils",
"flake-utils": [
"dream2nix",
"flake-utils-pre-commit"
],
"nixpkgs": [
"dream2nix",
"nixpkgs"
@ -263,60 +392,25 @@
"root": {
"inputs": {
"dream2nix": "dream2nix",
"flake-utils": "flake-utils_2",
"flake-utils": "flake-utils",
"mk-node-package": "mk-node-package",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"lastModified": 1657557289,
"narHash": "sha256-PRW+nUwuqNTRAEa83SfX+7g+g8nQ+2MMbasQ9nt6+UM=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "caf23f29144b371035b864a1017dbc32573ad56d",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"dream2nix",
"nix-unit",
"nixpkgs"
]
},
"locked": {
"lastModified": 1689620039,
"narHash": "sha256-BtNwghr05z7k5YMdq+6nbue+nEalvDepuA7qdQMAKoQ=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "719c2977f958c41fa60a928e2fbc50af14844114",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
}

View file

@ -159,7 +159,6 @@
}
];
systems = [system];
autoProjects = true;
})
.packages
."${system}"
@ -172,7 +171,7 @@
buildInputs = [];
buildPhase = ''
pnpm run build
yarn run build
'';
installPhase = ''
@ -214,7 +213,6 @@
copyToRoot = pkgs.buildEnv {
name = "image-root";
paths = [
bash
dockerTools.fakeNss
caCertificates
caddy
@ -223,9 +221,11 @@
coreutils
mysql80
nodejs-18_x
nodePackages.npm
nodePackages.pnpm
nodePackages.yarn
php81WithExtensions
postgresql_15
postgresql_14
];
pathsToLink = ["/bin" "/etc"];
};

View file

@ -1,11 +1,9 @@
{
"name": "@pterodactyl/panel",
"version": "1.0.0",
"license": "MIT",
"private": true,
"packageManager": "pnpm@8.7.6",
"engines": {
"node": ">=16.13"
"node": ">=16.0"
},
"scripts": {
"build": "vite build",
@ -41,15 +39,15 @@
"@codemirror/view": "^6.0.0",
"@floating-ui/react-dom-interactions": "0.13.3",
"@flyyer/use-fit-text": "3.0.1",
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-brands-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/fontawesome-svg-core": "6.2.1",
"@fortawesome/free-brands-svg-icons": "6.2.1",
"@fortawesome/free-solid-svg-icons": "6.2.1",
"@fortawesome/react-fontawesome": "0.2.0",
"@headlessui/react": "1.7.11",
"@headlessui/react": "1.7.5",
"@heroicons/react": "1.0.6",
"@lezer/common": "1.0.2",
"@lezer/highlight": "1.1.3",
"@preact/signals-react": "1.2.2",
"@preact/signals-react": "1.2.1",
"axios": "0.27.2",
"boring-avatars": "1.7.0",
"chart.js": "3.9.1",
@ -57,76 +55,75 @@
"copy-to-clipboard": "3.3.3",
"date-fns": "2.29.3",
"debounce": "1.2.1",
"deepmerge-ts": "4.3.0",
"deepmerge-ts": "4.2.2",
"easy-peasy": "5.2.0",
"events": "3.3.0",
"formik": "2.2.9",
"framer-motion": "9.1.6",
"i18next": "22.4.10",
"i18next-http-backend": "2.1.1",
"framer-motion": "7.7.2",
"i18next": "22.4.3",
"i18next-http-backend": "2.1.0",
"i18next-multiload-backend-adapter": "2.2.0",
"nanoid": "4.0.1",
"nanoid": "4.0.0",
"qrcode.react": "3.1.0",
"react": "18.2.0",
"react-chartjs-2": "4.3.1",
"react-dom": "18.2.0",
"react-fast-compare": "3.2.0",
"react-i18next": "12.2.0",
"react-router-dom": "6.8.1",
"react-i18next": "12.1.1",
"react-router-dom": "6.4.5",
"react-select": "5.7.0",
"reaptcha": "1.12.1",
"sockette": "2.0.6",
"styled-components": "5.3.6",
"styled-components-breakpoint": "3.0.0-preview.20",
"swr": "2.0.3",
"xterm": "5.1.0",
"xterm-addon-fit": "0.7.0",
"xterm-addon-search": "0.11.0",
"swr": "1.3.0",
"xterm": "5.0.0",
"xterm-addon-fit": "0.6.0",
"xterm-addon-search": "0.10.0",
"xterm-addon-search-bar": "0.2.0",
"xterm-addon-web-links": "0.8.0",
"yup": "1.0.0"
"xterm-addon-web-links": "0.7.0",
"yup": "0.32.11"
},
"devDependencies": {
"@tailwindcss/forms": "0.5.3",
"@tailwindcss/line-clamp": "0.4.2",
"@testing-library/dom": "9.0.0",
"@testing-library/react": "14.0.0",
"@testing-library/dom": "8.19.0",
"@testing-library/react": "13.4.0",
"@testing-library/user-event": "14.4.3",
"@types/debounce": "1.2.1",
"@types/events": "3.0.0",
"@types/node": "18.14.1",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/node": "18.11.13",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"@types/styled-components": "5.1.26",
"@typescript-eslint/eslint-plugin": "5.53.0",
"@typescript-eslint/parser": "5.53.0",
"@vitejs/plugin-react": "3.1.0",
"@typescript-eslint/eslint-plugin": "5.46.1",
"@typescript-eslint/parser": "5.46.1",
"@vitejs/plugin-react": "3.0.0",
"autoprefixer": "10.4.13",
"babel-plugin-styled-components": "2.0.7",
"babel-plugin-twin": "1.1.0",
"cross-env": "7.0.3",
"eslint": "8.34.0",
"eslint-config-prettier": "8.6.0",
"eslint": "8.29.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-react": "7.31.11",
"eslint-plugin-react-hooks": "4.6.0",
"happy-dom": "8.7.2",
"laravel-vite-plugin": "0.7.4",
"pathe": "1.1.0",
"postcss": "8.4.21",
"postcss-import": "15.1.0",
"postcss-nesting": "11.2.1",
"postcss-preset-env": "8.0.1",
"prettier": "2.8.4",
"prettier-plugin-tailwindcss": "0.2.3",
"happy-dom": "8.1.1",
"laravel-vite-plugin": "0.7.3",
"pathe": "1.0.0",
"postcss": "8.4.20",
"postcss-nesting": "10.2.0",
"postcss-preset-env": "7.8.3",
"prettier": "2.8.1",
"prettier-plugin-tailwindcss": "0.2.1",
"rimraf": "3.0.2",
"tailwindcss": "3.2.7",
"tailwindcss": "3.2.4",
"ts-essentials": "9.3.0",
"twin.macro": "2.8.2",
"typescript": "4.9.5",
"vite": "4.1.4",
"vitest": "0.28.5"
"typescript": "4.9.4",
"vite": "4.0.3",
"vitest": "0.26.2"
},
"browserslist": [
"> 0.5%",

View file

@ -4,8 +4,9 @@
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="bootstrap/tests.php"
colors="true"
printerClass="NunoMaduro\Collision\Adapters\Phpunit\Printer"
>
<coverage>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./app</directory>
</include>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,95 @@
import type { AxiosError } from 'axios';
import type { SWRConfiguration } from 'swr';
import useSWR from 'swr';
import type { SecurityKey } from '@definitions/user';
import { Transformers } from '@definitions/user';
import { LoginResponse } from '@/api/auth/login';
import type { FractalResponseList } from '@/api/http';
import http from '@/api/http';
import { decodeBase64 } from '@/lib/base64';
import { decodeBuffer, encodeBuffer } from '@/lib/buffer';
import { useUserSWRKey } from '@/plugins/useSWRKey';
function decodeSecurityKeyCredentials(credentials: PublicKeyCredentialDescriptor[]) {
return credentials.map(c => ({
id: decodeBuffer(decodeBase64(c.id.toString())),
type: c.type,
transports: c.transports,
}));
}
function useSecurityKeys(config?: SWRConfiguration<SecurityKey[], AxiosError>) {
const key = useUserSWRKey(['account', 'security-keys']);
return useSWR<SecurityKey[], AxiosError>(
key,
async (): Promise<SecurityKey[]> => {
const { data } = await http.get('/api/client/account/security-keys');
return (data as FractalResponseList).data.map(datum => Transformers.toSecurityKey(datum.attributes));
},
{ revalidateOnMount: false, ...(config ?? {}) },
);
}
async function deleteSecurityKey(uuid: string): Promise<void> {
await http.delete(`/api/client/account/security-keys/${uuid}`);
}
async function registerCredentialForAccount(
name: string,
tokenId: string,
credential: PublicKeyCredential,
): Promise<SecurityKey> {
const { data } = await http.post('/api/client/account/security-keys/register', {
name,
token_id: tokenId,
registration: {
id: credential.id,
type: credential.type,
rawId: encodeBuffer(credential.rawId),
response: {
attestationObject: encodeBuffer(
(credential.response as AuthenticatorAttestationResponse).attestationObject,
),
clientDataJSON: encodeBuffer(credential.response.clientDataJSON),
},
},
});
return Transformers.toSecurityKey(data.attributes);
}
async function registerSecurityKey(name: string): Promise<SecurityKey> {
const { data } = await http.get('/api/client/account/security-keys/register');
const publicKey = data.data.credentials;
publicKey.challenge = decodeBuffer(decodeBase64(publicKey.challenge));
publicKey.user.id = decodeBuffer(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = decodeSecurityKeyCredentials(publicKey.excludeCredentials);
}
const credentials = await navigator.credentials.create({ publicKey });
if (!credentials || credentials.type !== 'public-key') {
throw new Error(
`Unexpected type returned by navigator.credentials.create(): expected "public-key", got "${credentials?.type}"`,
);
}
return await registerCredentialForAccount(name, data.data.token_id, credentials as PublicKeyCredential);
}
// eslint-disable-next-line camelcase
async function authenticateSecurityKey(data: { confirmation_token: string; data: string }): Promise<LoginResponse> {
const response = await http.post('/auth/login/checkpoint/key', data);
return {
complete: response.data.complete,
intended: response.data.data?.intended || null,
};
}
export { useSecurityKeys, deleteSecurityKey, registerSecurityKey, authenticateSecurityKey };

View file

@ -56,7 +56,7 @@ export interface EggVariable extends Model {
* A standard API response with the minimum viable details for the frontend
* to correctly render a egg.
*/
export type LoadedEgg = WithRelationships<Egg, 'nest' | 'variables'>;
type LoadedEgg = WithRelationships<Egg, 'nest' | 'variables'>;
/**
* Gets a single egg from the database and returns it.

View file

@ -14,13 +14,13 @@ export default (egg: Partial<Egg2>): Promise<Egg> => {
config_files: egg.configFiles,
config_startup: egg.configStartup,
config_stop: egg.configStop,
config_from: egg.configFrom,
startup: egg.startup,
script_container: egg.scriptContainer,
copy_script_from: egg.copyScriptFrom,
script_entry: egg.scriptEntry,
script_is_privileged: egg.scriptIsPrivileged,
script_install: egg.scriptInstall,
// config_from: egg.configFrom,
// copy_script_from: egg.copyScriptFrom,
// script_is_privileged: egg.scriptIsPrivileged,
})
.then(({ data }) => resolve(rawDataToEgg(data)))
.catch(reject);

View file

@ -43,15 +43,15 @@ export interface Egg {
configFiles: Record<string, any> | null;
configStartup: Record<string, any> | null;
configStop: string | null;
configFrom: number | null;
startup: string;
scriptContainer: string;
copyScriptFrom: number | null;
scriptEntry: string;
scriptIsPrivileged: boolean;
scriptInstall: string | null;
createdAt: Date;
updatedAt: Date;
// configFrom: number | null;
// copyScriptFrom: number | null;
// scriptIsPrivileged: boolean;
relations: {
nest?: Nest;

View file

@ -17,7 +17,7 @@ export interface UpdateUserValues {
}
const filters = ['id', 'uuid', 'external_id', 'username', 'email'] as const;
type Filters = (typeof filters)[number];
type Filters = typeof filters[number];
const useGetUsers = (
params?: QueryBuilderParams<Filters>,

View file

@ -1,24 +1,6 @@
import { Model, UUID } from '@/api/definitions';
import { SubuserPermission } from '@/state/server/subusers';
interface User extends Model {
uuid: string;
username: string;
email: string;
image: string;
twoFactorEnabled: boolean;
createdAt: Date;
permissions: SubuserPermission[];
can(permission: SubuserPermission): boolean;
}
interface SSHKey extends Model {
name: string;
publicKey: string;
fingerprint: string;
createdAt: Date;
}
interface ActivityLog extends Model<'actor'> {
id: string;
batch: UUID | null;
@ -33,3 +15,30 @@ interface ActivityLog extends Model<'actor'> {
actor: User | null;
};
}
interface User extends Model {
uuid: string;
username: string;
email: string;
image: string;
twoFactorEnabled: boolean;
createdAt: Date;
permissions: SubuserPermission[];
can(permission: SubuserPermission): boolean;
}
interface SecurityKey extends Model {
uuid: UUID;
name: string;
type: 'public-key';
publicKeyId: string;
createdAt: Date;
updatedAt: Date;
}
interface SSHKey extends Model {
name: string;
publicKey: string;
fingerprint: string;
createdAt: Date;
}

View file

@ -3,6 +3,36 @@ import { FractalResponseData } from '@/api/http';
import { transform } from '@definitions/helpers';
export default class Transformers {
static toActivityLog = ({ attributes }: FractalResponseData): Models.ActivityLog => {
const { actor } = attributes.relationships || {};
return {
id: attributes.id,
batch: attributes.batch,
event: attributes.event,
ip: attributes.ip,
isApi: attributes.is_api,
description: attributes.description,
properties: attributes.properties,
hasAdditionalMetadata: attributes.has_additional_metadata ?? false,
timestamp: new Date(attributes.timestamp),
relationships: {
actor: transform(actor as FractalResponseData, this.toUser, null),
},
};
};
static toSecurityKey(data: Record<string, any>): Models.SecurityKey {
return {
uuid: data.uuid,
name: data.name,
type: data.type,
publicKeyId: data.public_key_id,
createdAt: new Date(data.created_at),
updatedAt: new Date(data.updated_at),
};
}
static toSSHKey = (data: Record<any, any>): Models.SSHKey => {
return {
name: data.name,
@ -26,25 +56,6 @@ export default class Transformers {
},
};
};
static toActivityLog = ({ attributes }: FractalResponseData): Models.ActivityLog => {
const { actor } = attributes.relationships || {};
return {
id: attributes.id,
batch: attributes.batch,
event: attributes.event,
ip: attributes.ip,
isApi: attributes.is_api,
description: attributes.description,
properties: attributes.properties,
hasAdditionalMetadata: attributes.has_additional_metadata ?? false,
timestamp: new Date(attributes.timestamp),
relationships: {
actor: transform(actor as FractalResponseData, this.toUser, null),
},
};
};
}
export class MetaTransformers {}

View file

@ -47,7 +47,7 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
isEditable: function () {
if (this.isArchiveType() || !this.isFile) return false;
const matches = ['application/jar', 'application/octet-stream', 'inode/directory', /^image\/(?!svg\+xml)/];
const matches = ['application/jar', 'application/octet-stream', 'inode/directory', /^image\//];
return matches.every(m => !this.mimetype.match(m));
},

View file

@ -11,16 +11,16 @@ import Select from '@/components/elements/Select';
interface Props {
nestId?: number;
selectedEggId?: number;
onEggSelect: (egg: WithRelationships<Egg, 'variables'> | undefined) => void;
onEggSelect: (egg: Egg | null) => void;
}
export default ({ nestId, selectedEggId, onEggSelect }: Props) => {
const [, , { setValue, setTouched }] = useField<Record<string, string | undefined>>('environment');
const [eggs, setEggs] = useState<WithRelationships<Egg, 'variables'>[] | undefined>(undefined);
const [eggs, setEggs] = useState<WithRelationships<Egg, 'variables'>[] | null>(null);
const selectEgg = (egg: WithRelationships<Egg, 'variables'> | undefined) => {
if (egg === undefined) {
onEggSelect(undefined);
const selectEgg = (egg: Egg | null) => {
if (egg === null) {
onEggSelect(null);
return;
}
@ -40,29 +40,26 @@ export default ({ nestId, selectedEggId, onEggSelect }: Props) => {
useEffect(() => {
if (!nestId) {
setEggs(undefined);
setEggs(null);
return;
}
searchEggs(nestId, {})
.then(_eggs => {
setEggs(_eggs);
// If the currently selected egg is in the selected nest, use it instead of picking the first egg on the nest.
const egg = _eggs.find(egg => egg.id === selectedEggId) ?? _eggs[0];
selectEgg(egg);
.then(eggs => {
setEggs(eggs);
selectEgg(eggs[0] || null);
})
.catch(error => console.error(error));
}, [nestId]);
const onSelectChange = (event: ChangeEvent<HTMLSelectElement>) => {
selectEgg(eggs?.find(egg => egg.id.toString() === event.currentTarget.value) ?? undefined);
const onSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
selectEgg(eggs?.find(egg => egg.id.toString() === e.currentTarget.value) || null);
};
return (
<>
<Label>Egg</Label>
<Select id={'eggId'} name={'eggId'} value={selectedEggId} onChange={onSelectChange}>
<Select id={'eggId'} name={'eggId'} defaultValue={selectedEggId} onChange={onSelectChange}>
{!eggs ? (
<option disabled>Loading...</option>
) : (

View file

@ -30,7 +30,6 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import AdminContentBlock from '@/components/admin/AdminContentBlock';
import { WithRelationships } from '@/api/admin';
function InternalForm() {
const {
@ -40,12 +39,12 @@ function InternalForm() {
values: { environment },
} = useFormikContext<CreateServerRequest>();
const [egg, setEgg] = useState<WithRelationships<Egg, 'variables'> | undefined>(undefined);
const [node, setNode] = useState<Node | undefined>(undefined);
const [allocations, setAllocations] = useState<Allocation[] | undefined>(undefined);
const [egg, setEgg] = useState<Egg | null>(null);
const [node, setNode] = useState<Node | null>(null);
const [allocations, setAllocations] = useState<Allocation[] | null>(null);
useEffect(() => {
if (egg === undefined) {
if (egg === null) {
return;
}
@ -55,7 +54,7 @@ function InternalForm() {
}, [egg]);
useEffect(() => {
if (node === undefined) {
if (node === null) {
return;
}
@ -65,11 +64,11 @@ function InternalForm() {
return (
<Form>
<div className="grid grid-cols-2 gap-y-6 gap-x-8 mb-16">
<div className="grid grid-cols-1 gap-y-6 col-span-2 md:col-span-1">
<div css={tw`grid grid-cols-2 gap-y-6 gap-x-8 mb-16`}>
<div css={tw`grid grid-cols-1 gap-y-6 col-span-2 md:col-span-1`}>
<BaseSettingsBox>
<NodeSelect node={node} setNode={setNode} />
<div className="xl:col-span-2 bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded">
<div css={tw`xl:col-span-2 bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded`}>
<FormikSwitch
name={'startOnCompletion'}
label={'Start after installation'}
@ -78,20 +77,20 @@ function InternalForm() {
</div>
</BaseSettingsBox>
<FeatureLimitsBox />
<ServerServiceContainer selectedEggId={egg?.id} setEgg={setEgg} nestId={0} />
<ServerServiceContainer egg={egg} setEgg={setEgg} nestId={0} />
</div>
<div className="grid grid-cols-1 gap-y-6 col-span-2 md:col-span-1">
<AdminBox icon={faNetworkWired} title="Networking" isLoading={isSubmitting}>
<div className="grid grid-cols-1 gap-4 lg:gap-6">
<div css={tw`grid grid-cols-1 gap-y-6 col-span-2 md:col-span-1`}>
<AdminBox icon={faNetworkWired} title={'Networking'} isLoading={isSubmitting}>
<div css={tw`grid grid-cols-1 gap-4 lg:gap-6`}>
<div>
<Label htmlFor={'allocation.default'}>Primary Allocation</Label>
<Select
id={'allocation.default'}
name={'allocation.default'}
disabled={node === undefined}
disabled={node === null}
onChange={e => setFieldValue('allocation.default', Number(e.currentTarget.value))}
>
{node === undefined ? (
{node === null ? (
<option value="">Select a node...</option>
) : (
<option value="">Select an allocation...</option>
@ -117,7 +116,7 @@ function InternalForm() {
<ServerImageContainer />
</div>
<AdminBox title={'Startup Command'} className="relative w-full col-span-2">
<AdminBox title={'Startup Command'} css={tw`relative w-full col-span-2`}>
<SpinnerOverlay visible={isSubmitting} />
<Field
@ -132,7 +131,7 @@ function InternalForm() {
/>
</AdminBox>
<div className="col-span-2 grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8">
<div css={tw`col-span-2 grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8`}>
{/* This ensures that no variables are rendered unless the environment has a value for the variable. */}
{egg?.relationships.variables
?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined)
@ -141,9 +140,9 @@ function InternalForm() {
))}
</div>
<div className="bg-neutral-700 rounded shadow-md px-4 py-3 col-span-2">
<div className="flex flex-row">
<Button type="submit" size="small" className="ml-auto" disabled={isSubmitting || !isValid}>
<div css={tw`bg-neutral-700 rounded shadow-md px-4 py-3 col-span-2`}>
<div css={tw`flex flex-row`}>
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
Create Server
</Button>
</div>

View file

@ -3,9 +3,11 @@ import { useStoreActions } from 'easy-peasy';
import type { FormikHelpers } from 'formik';
import { Form, Formik, useField, useFormikContext } from 'formik';
import { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { object } from 'yup';
import type { Egg, EggVariable, LoadedEgg } from '@/api/admin/egg';
import type { InferModel } from '@/api/admin';
import type { Egg, EggVariable } from '@/api/admin/egg';
import { getEgg } from '@/api/admin/egg';
import type { Server } from '@/api/admin/server';
import { useServerFromRoute } from '@/api/admin/server';
@ -21,13 +23,12 @@ import Field from '@/components/elements/Field';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import Label from '@/components/elements/Label';
import type { ApplicationStore } from '@/state';
import { WithRelationships } from '@/api/admin';
function ServerStartupLineContainer({ egg, server }: { egg?: Egg; server: Server }) {
function ServerStartupLineContainer({ egg, server }: { egg: Egg | null; server: Server }) {
const { isSubmitting, setFieldValue } = useFormikContext();
useEffect(() => {
if (egg === undefined) {
if (egg === null) {
return;
}
@ -43,10 +44,10 @@ function ServerStartupLineContainer({ egg, server }: { egg?: Egg; server: Server
}, [egg]);
return (
<AdminBox title={'Startup Command'} className="relative w-full">
<AdminBox title={'Startup Command'} css={tw`relative w-full`}>
<SpinnerOverlay visible={isSubmitting} />
<div className="mb-6">
<div css={tw`mb-6`}>
<Field
id={'startup'}
name={'startup'}
@ -68,12 +69,12 @@ function ServerStartupLineContainer({ egg, server }: { egg?: Egg; server: Server
}
export function ServerServiceContainer({
selectedEggId,
egg,
setEgg,
nestId: _nestId,
}: {
selectedEggId?: number;
setEgg: (value: WithRelationships<Egg, 'variables'> | undefined) => void;
egg: Egg | null;
setEgg: (value: Egg | null) => void;
nestId: number;
}) {
const { isSubmitting } = useFormikContext();
@ -81,14 +82,14 @@ export function ServerServiceContainer({
const [nestId, setNestId] = useState<number>(_nestId);
return (
<AdminBox title={'Service Configuration'} isLoading={isSubmitting} className="w-full">
<div className="mb-6">
<AdminBox title={'Service Configuration'} isLoading={isSubmitting} css={tw`w-full`}>
<div css={tw`mb-6`}>
<NestSelector selectedNestId={nestId} onNestSelect={setNestId} />
</div>
<div className="mb-6">
<EggSelect nestId={nestId} selectedEggId={selectedEggId} onEggSelect={setEgg} />
<div css={tw`mb-6`}>
<EggSelect nestId={nestId} selectedEggId={egg?.id} onEggSelect={setEgg} />
</div>
<div className="bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded">
<div css={tw`bg-neutral-800 border border-neutral-900 shadow-inner p-4 rounded`}>
<FormikSwitch name={'skipScripts'} label={'Skip Egg Install Script'} description={'Soon™'} />
</div>
</AdminBox>
@ -99,10 +100,10 @@ export function ServerImageContainer() {
const { isSubmitting } = useFormikContext();
return (
<AdminBox title={'Image Configuration'} className="relative w-full">
<AdminBox title={'Image Configuration'} css={tw`relative w-full`}>
<SpinnerOverlay visible={isSubmitting} />
<div className="md:w-full md:flex md:flex-col">
<div css={tw`md:w-full md:flex md:flex-col`}>
<div>
{/* TODO: make this a proper select but allow a custom image to be specified if needed. */}
<Field id={'image'} name={'image'} label={'Docker Image'} type={'text'} />
@ -129,7 +130,7 @@ export function ServerVariableContainer({ variable, value }: { variable: EggVari
}, [value]);
return (
<AdminBox className="relative w-full" title={<p className="text-sm uppercase">{variable.name}</p>}>
<AdminBox css={tw`relative w-full`} title={<p css={tw`text-sm uppercase`}>{variable.name}</p>}>
<SpinnerOverlay visible={isSubmitting} />
<Field
@ -144,14 +145,12 @@ export function ServerVariableContainer({ variable, value }: { variable: EggVari
}
function ServerStartupForm({
selectedEggId,
egg,
setEgg,
server,
}: {
selectedEggId?: number;
egg?: LoadedEgg;
setEgg: (value: LoadedEgg | undefined) => void;
egg: Egg | null;
setEgg: (value: Egg | null) => void;
server: Server;
}) {
const {
@ -162,22 +161,22 @@ function ServerStartupForm({
return (
<Form>
<div className="flex flex-col mb-16">
<div className="flex flex-row mb-6">
<div css={tw`flex flex-col mb-16`}>
<div css={tw`flex flex-row mb-6`}>
<ServerStartupLineContainer egg={egg} server={server} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6">
<div className="flex">
<ServerServiceContainer selectedEggId={selectedEggId} setEgg={setEgg} nestId={server.nestId} />
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6 mb-6`}>
<div css={tw`flex`}>
<ServerServiceContainer egg={egg} setEgg={setEgg} nestId={server.nestId} />
</div>
<div className="flex">
<div css={tw`flex`}>
<ServerImageContainer />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8">
<div css={tw`grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-8`}>
{/* This ensures that no variables are rendered unless the environment has a value for the variable. */}
{egg?.relationships.variables
?.filter(v => Object.keys(environment).find(e => e === v.environmentVariable) !== undefined)
@ -194,9 +193,9 @@ function ServerStartupForm({
))}
</div>
<div className="bg-neutral-700 rounded shadow-md py-2 pr-6 mt-6">
<div className="flex flex-row">
<Button type="submit" size="small" className="ml-auto" disabled={isSubmitting || !isValid}>
<div css={tw`bg-neutral-700 rounded shadow-md py-2 pr-6 mt-6`}>
<div css={tw`flex flex-row`}>
<Button type="submit" size="small" css={tw`ml-auto`} disabled={isSubmitting || !isValid}>
Save Changes
</Button>
</div>
@ -211,12 +210,10 @@ export default () => {
const { clearFlashes, clearAndAddHttpError } = useStoreActions(
(actions: Actions<ApplicationStore>) => actions.flashes,
);
const [egg, setEgg] = useState<LoadedEgg | undefined>(undefined);
const [egg, setEgg] = useState<InferModel<typeof getEgg> | null>(null);
useEffect(() => {
if (!server) {
return;
}
if (!server) return;
getEgg(server.eggId)
.then(egg => setEgg(egg))
@ -252,10 +249,10 @@ export default () => {
validationSchema={object().shape({})}
>
<ServerStartupForm
selectedEggId={egg?.id ?? server.eggId}
egg={egg}
// @ts-expect-error fix this
setEgg={setEgg}
server={server as Server}
server={server}
/>
</Formik>
);

View file

@ -4,7 +4,7 @@ import { useState } from 'react';
import Checkbox from '@/components/elements/inputs/Checkbox';
import { Dropdown } from '@/components/elements/dropdown';
import { Dialog } from '@/components/elements/dialog';
import type { User } from '@definitions/admin';
import { User } from '@definitions/admin';
interface Props {
user: User;
@ -12,7 +12,7 @@ interface Props {
onRowChange: (user: User, selected: boolean) => void;
}
function UserTableRow({ user, selected, onRowChange }: Props) {
const UserTableRow = ({ user, selected, onRowChange }: Props) => {
const [visible, setVisible] = useState(false);
return (
@ -56,14 +56,12 @@ function UserTableRow({ user, selected, onRowChange }: Props) {
</span>
)}
</td>
<td className="whitespace-nowrap px-6 py-4">
<td className={'whitespace-nowrap px-6 py-4'}>
<Dropdown>
<Dropdown.Button className="px-2">
<Dropdown.Button className={'px-2'}>
<DotsVerticalIcon />
</Dropdown.Button>
<Dropdown.Item to={`/admin/users/${user.id}`} icon={<PencilIcon />}>
Edit
</Dropdown.Item>
<Dropdown.Item icon={<PencilIcon />}>Edit</Dropdown.Item>
<Dropdown.Item icon={<SupportIcon />}>Reset Password</Dropdown.Item>
<Dropdown.Item icon={<LockOpenIcon />} disabled={!user.isUsingTwoFactor}>
Disable 2-FA
@ -78,6 +76,6 @@ function UserTableRow({ user, selected, onRowChange }: Props) {
</tr>
</>
);
}
};
export default UserTableRow;

View file

@ -1,6 +1,5 @@
import { LockOpenIcon, PlusIcon, SupportIcon, TrashIcon } from '@heroicons/react/solid';
import { Fragment, useEffect, useState } from 'react';
import { NavLink } from 'react-router-dom';
import { useGetUsers } from '@/api/admin/users';
import type { UUID } from '@/api/definitions';
@ -17,7 +16,7 @@ import { Shape } from '@/components/elements/button/types';
const filters = ['id', 'uuid', 'external_id', 'username', 'email'] as const;
function UsersContainer() {
const UsersContainer = () => {
const [search, setSearch] = useDebouncedState('', 500);
const [selected, setSelected] = useState<UUID[]>([]);
const { data: users } = useGetUsers(
@ -43,16 +42,13 @@ function UsersContainer() {
return (
<div>
<div className="mb-4 flex justify-end">
<NavLink to="/admin/users/new">
<Button className="shadow focus:ring-offset-2 focus:ring-offset-neutral-800">
Add User <PlusIcon className="ml-2 h-5 w-5" />
<div className={'mb-4 flex justify-end'}>
<Button className={'shadow focus:ring-offset-2 focus:ring-offset-neutral-800'}>
Add User <PlusIcon className={'ml-2 h-5 w-5'} />
</Button>
</NavLink>
</div>
<div className="relative flex items-center rounded-t bg-neutral-700 px-4 py-2">
<div className="mr-6">
<div className={'relative flex items-center rounded-t bg-neutral-700 px-4 py-2'}>
<div className={'mr-6'}>
<Checkbox
checked={selectAllChecked}
disabled={!users?.items.length}
@ -60,18 +56,22 @@ function UsersContainer() {
onChange={onSelectAll}
/>
</div>
<div className="flex-1">
<div className={'flex-1'}>
<InputField
type="text"
name="filter"
placeholder="Begin typing to filter..."
className="w-56 focus:w-96"
type={'text'}
name={'filter'}
placeholder={'Begin typing to filter...'}
className={'w-56 focus:w-96'}
onChange={e => setSearch(e.currentTarget.value)}
/>
</div>
<Transition.Fade as={Fragment} show={selected.length > 0} duration="duration-75">
<div className="absolute top-0 left-0 flex h-full w-full items-center justify-end space-x-4 rounded-t bg-neutral-700 px-4">
<div className="flex-1">
<Transition.Fade as={Fragment} show={selected.length > 0} duration={'duration-75'}>
<div
className={
'absolute top-0 left-0 flex h-full w-full items-center justify-end space-x-4 rounded-t bg-neutral-700 px-4'
}
>
<div className={'flex-1'}>
<Checkbox
checked={selectAllChecked}
indeterminate={selected.length !== users?.items.length}
@ -79,26 +79,26 @@ function UsersContainer() {
/>
</div>
<Button.Text shape={Shape.IconSquare}>
<SupportIcon className="h-4 w-4" />
<SupportIcon className={'h-4 w-4'} />
</Button.Text>
<Button.Text shape={Shape.IconSquare}>
<LockOpenIcon className="h-4 w-4" />
<LockOpenIcon className={'h-4 w-4'} />
</Button.Text>
<Button.Text shape={Shape.IconSquare}>
<TrashIcon className="h-4 w-4" />
<TrashIcon className={'h-4 w-4'} />
</Button.Text>
</div>
</Transition.Fade>
</div>
<table className="min-w-full rounded bg-neutral-700">
<thead className="bg-neutral-900">
<table className={'min-w-full rounded bg-neutral-700'}>
<thead className={'bg-neutral-900'}>
<tr>
<th scope="col" className="w-8" />
<th scope="col" className="w-full px-6 py-2 text-left">
<th scope={'col'} className={'w-8'} />
<th scope={'col'} className={'w-full px-6 py-2 text-left'}>
Email
</th>
<th scope="col" />
<th scope="col" />
<th scope={'col'} />
<th scope={'col'} />
</tr>
</thead>
<tbody>
@ -111,10 +111,10 @@ function UsersContainer() {
/>
))}
</tbody>
{users ? <TFootPaginated span={4} pagination={users.pagination} /> : null}
{users && <TFootPaginated span={4} pagination={users.pagination} />}
</table>
</div>
);
}
};
export default UsersContainer;

View file

@ -40,7 +40,7 @@ const inputStyle = css<Props>`
// Reset to normal styling.
resize: none;
${tw`appearance-none outline-none w-full min-w-0`};
${tw`py-2.5 px-3 border-2 rounded text-sm transition-all duration-150`};
${tw`p-3 border-2 rounded text-sm transition-all duration-150`};
${tw`bg-neutral-600 border-neutral-500 hover:border-neutral-400 text-neutral-200 shadow-none focus:ring-0`};
& + .input-help {

View file

@ -1,29 +1,27 @@
import { ElementType, forwardRef, useMemo } from 'react';
import * as React from 'react';
import { Menu, Transition } from '@headlessui/react';
import classNames from 'classnames';
import type { ElementType, ReactNode } from 'react';
import { Children as ReactChildren } from 'react';
import { forwardRef, useMemo } from 'react';
import { DropdownButton } from '@/components/elements/dropdown/DropdownButton';
import { DropdownItem } from '@/components/elements/dropdown/DropdownItem';
import styles from './style.module.css';
import classNames from 'classnames';
import DropdownItem from '@/components/elements/dropdown/DropdownItem';
import DropdownButton from '@/components/elements/dropdown/DropdownButton';
interface Props {
as?: ElementType;
children: ReactNode;
children: React.ReactNode;
}
const DropdownGap = ({ invisible }: { invisible?: boolean }) => (
<div className={classNames('m-2 border', { 'border-neutral-700': !invisible, 'border-transparent': invisible })} />
);
type TypedChild = ReactNode & {
type TypedChild = (React.ReactChild | React.ReactFragment | React.ReactPortal) & {
type?: JSX.Element;
};
const Dropdown = forwardRef<typeof Menu, Props>(({ as, children }, ref) => {
const [Button, items] = useMemo(() => {
const list = ReactChildren.toArray(children) as unknown as TypedChild[];
const list = React.Children.toArray(children) as unknown as TypedChild[];
return [
list.filter(child => child.type === DropdownButton),
@ -36,18 +34,18 @@ const Dropdown = forwardRef<typeof Menu, Props>(({ as, children }, ref) => {
}
return (
<Menu as={as ?? 'div'} className={styles.menu} ref={ref}>
<Menu as={as || 'div'} className={styles.menu} ref={ref}>
{Button}
<Transition
enter="transition duration-100 ease-out"
enterFrom="transition scale-95 opacity-0"
enterTo="transform scale-100 opacity-100"
leave="transition duration-75 ease-out"
leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0"
enter={'transition duration-100 ease-out'}
enterFrom={'transition scale-95 opacity-0'}
enterTo={'transform scale-100 opacity-100'}
leave={'transition duration-75 ease-out'}
leaveFrom={'transform scale-100 opacity-100'}
leaveTo={'transform scale-95 opacity-0'}
>
<Menu.Items className={classNames(styles.items_container, 'w-56')}>
<div className="px-1 py-1">{items}</div>
<div className={'px-1 py-1'}>{items}</div>
</Menu.Items>
</Transition>
</Menu>

View file

@ -1,29 +1,24 @@
import { Menu } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/solid';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import styles from './style.module.css';
import styles from '@/components/elements/dropdown/style.module.css';
import { ChevronDownIcon } from '@heroicons/react/solid';
import { Menu } from '@headlessui/react';
import * as React from 'react';
interface Props {
className?: string;
animate?: boolean;
children: ReactNode;
children: React.ReactNode;
}
function DropdownButton({ className, animate = true, children }: Props) {
return (
<Menu.Button className={classNames(styles.button, className ?? 'px-4')}>
export default ({ className, animate = true, children }: Props) => (
<Menu.Button className={classNames(styles.button, className || 'px-4')}>
{typeof children === 'string' ? (
<>
<span className="mr-2">{children}</span>
<ChevronDownIcon aria-hidden="true" data-animated={animate.toString()} />
<span className={'mr-2'}>{children}</span>
<ChevronDownIcon aria-hidden={'true'} data-animated={animate.toString()} />
</>
) : (
children
)}
</Menu.Button>
);
}
export { DropdownButton };
);

View file

@ -1,32 +1,26 @@
import { Menu } from '@headlessui/react';
import classNames from 'classnames';
import type { MouseEvent, ReactNode, Ref } from 'react';
import { forwardRef } from 'react';
import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
import * as React from 'react';
import { Menu } from '@headlessui/react';
import styles from './style.module.css';
import classNames from 'classnames';
interface Props {
children: ReactNode | ((opts: { active: boolean; disabled: boolean }) => JSX.Element);
children: React.ReactNode | ((opts: { active: boolean; disabled: boolean }) => JSX.Element);
danger?: boolean;
disabled?: boolean;
className?: string;
icon?: JSX.Element;
onClick?: (e: MouseEvent) => void;
onClick?: (e: React.MouseEvent) => void;
}
const DropdownItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, Props & Partial<Omit<NavLinkProps, 'children'>>>(
({ disabled, danger, className, onClick, children, icon: IconComponent, ...props }, ref) => {
const DropdownItem = forwardRef<HTMLAnchorElement, Props>(
({ disabled, danger, className, onClick, children, icon: IconComponent }, ref) => {
return (
<Menu.Item disabled={disabled}>
{({ disabled, active }) => (
<>
{'to' in props && props.to !== undefined ? (
<NavLink
{...props}
to={props.to}
ref={ref as unknown as Ref<HTMLAnchorElement>}
<a
ref={ref}
href={'#'}
className={classNames(
styles.menu_item,
{
@ -39,30 +33,11 @@ const DropdownItem = forwardRef<HTMLAnchorElement | HTMLButtonElement, Props & P
>
{IconComponent}
{typeof children === 'function' ? children({ disabled, active }) : children}
</NavLink>
) : (
<button
type="button"
ref={ref as unknown as Ref<HTMLButtonElement>}
className={classNames(
styles.menu_item,
{
[styles.danger]: danger,
[styles.disabled]: disabled,
},
className,
)}
onClick={onClick}
>
{IconComponent}
{typeof children === 'function' ? children({ disabled, active }) : children}
</button>
)}
</>
</a>
)}
</Menu.Item>
);
},
);
export { DropdownItem };
export default DropdownItem;

View file

@ -119,14 +119,14 @@ export default ({ database, className }: Props) => {
<Can action={'database.view_password'}>
<div css={tw`mt-6`}>
<Label>Password</Label>
<CopyOnClick text={database.password} showInNotification={false}>
<CopyOnClick text={database.password}>
<Input type={'text'} readOnly value={database.password} />
</CopyOnClick>
</div>
</Can>
<div css={tw`mt-6`}>
<Label>JDBC Connection String</Label>
<CopyOnClick text={jdbcConnectionString} showInNotification={false}>
<CopyOnClick text={jdbcConnectionString}>
<Input type={'text'} readOnly value={jdbcConnectionString} />
</CopyOnClick>
</div>

View file

@ -18,12 +18,11 @@ import { ServerContext } from '@/state/server';
import styles from './style.module.css';
function Clickable({ file, children }: { file: FileObject; children: ReactNode }) {
const [canRead] = usePermissions(['file.read']);
const [canReadContents] = usePermissions(['file.read-content']);
const id = ServerContext.useStoreState(state => state.server.data!.id);
const directory = ServerContext.useStoreState(state => state.files.directory);
return (file.isFile && (!file.isEditable() || !canReadContents)) || (!file.isFile && !canRead) ? (
return !canReadContents || (file.isFile && !file.isEditable()) ? (
<div className={styles.details}>{children}</div>
) : (
<NavLink

View file

@ -93,7 +93,7 @@ const MassActionsBar = () => {
/>
)}
<Portal>
<div className="pointer-events-none fixed bottom-0 z-50 mb-6 flex w-full justify-center">
<div className="fixed bottom-0 z-50 mb-6 flex w-full justify-center">
<FadeTransition duration="duration-75" show={selectedFiles.length > 0} appear unmount>
<div className="pointer-events-auto flex items-center space-x-4 rounded bg-black/50 p-4">
<Button onClick={() => setShowMove(true)}>Move</Button>

View file

@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { decodeBase64 } from '@/lib/base64';
describe('@/lib/base64.ts', function () {
describe('decodeBase64()', function () {
it.each([
['', ''],
['', ''],
])('should decode "%s" to "%s"', function (input, output) {
expect(decodeBase64(input)).toBe(output);
});
});
});

View file

@ -0,0 +1,16 @@
function decodeBase64(input: string): string {
input = input.replace(/-/g, '+').replace(/_/g, '/');
const pad = input.length % 4;
if (pad) {
if (pad === 1) {
throw new Error('InvalidLengthError: Input base64url string is the wrong length to determine padding');
}
input += new Array(5 - pad).join('=');
}
return input;
}
export { decodeBase64 };

View file

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { decodeBuffer, encodeBuffer } from '@/lib/buffer';
describe('@/lib/buffer.ts', function () {
describe('decodeBuffer()', function () {
it.each([
['', ''],
['', ''],
])('should decode "%s" to "%s"', function (input, output) {
expect(decodeBuffer(input)).toBe(output);
});
});
describe('encodeBuffer()', function () {
it.each([
[new Uint8Array(0), ''],
[new Uint8Array(0), ''],
])('should encode "%s" to "%s"', function (input, output) {
expect(encodeBuffer(input)).toBe(output);
});
});
});

View file

@ -0,0 +1,9 @@
function decodeBuffer(value: string): ArrayBuffer {
return Uint8Array.from(window.atob(value), c => c.charCodeAt(0));
}
function encodeBuffer(value: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(value)));
}
export { decodeBuffer, encodeBuffer };

View file

@ -1,6 +1,7 @@
import type { ComponentType } from 'react';
import { lazy } from 'react';
import ServerConsole from '@/components/server/console/ServerConsoleContainer';
import DatabasesContainer from '@/components/server/databases/DatabasesContainer';
import ScheduleContainer from '@/components/server/schedules/ScheduleContainer';
import UsersContainer from '@/components/server/users/UsersContainer';
@ -20,7 +21,6 @@ import ServerActivityLogContainer from '@/components/server/ServerActivityLogCon
//
// These specific lazy loaded routes are to avoid loading in heavy screens
// for the server dashboard when they're only needed for specific instances.
const ServerConsoleContainer = lazy(() => import('@/components/server/console/ServerConsoleContainer'));
const FileEditContainer = lazy(() => import('@/components/server/files/FileEditContainer'));
const ScheduleEditContainer = lazy(() => import('@/components/server/schedules/ScheduleEditContainer'));
@ -86,7 +86,7 @@ export default {
path: '',
permission: null,
name: 'Console',
component: ServerConsoleContainer,
component: ServerConsole,
end: true,
},
{

View file

@ -36,6 +36,11 @@ Route::prefix('/account')->middleware(AccountSubject::class)->group(function ()
Route::post('/api-keys', [Client\ApiKeyController::class, 'store']);
Route::delete('/api-keys/{identifier}', [Client\ApiKeyController::class, 'delete']);
Route::get('/security-keys', [Client\SecurityKeyController::class, 'index'])->withoutMiddleware(RequireTwoFactorAuthentication::class);
Route::get('/security-keys/register', [Client\SecurityKeyController::class, 'create'])->withoutMiddleware(RequireTwoFactorAuthentication::class);
Route::post('/security-keys/register', [Client\SecurityKeyController::class, 'store'])->withoutMiddleware(RequireTwoFactorAuthentication::class);
Route::delete('/security-keys/{securityKey}', [Client\SecurityKeyController::class, 'delete'])->withoutMiddleware(RequireTwoFactorAuthentication::class);
Route::prefix('/ssh-keys')->group(function () {
Route::get('/', [Client\SSHKeyController::class, 'index']);
Route::post('/', [Client\SSHKeyController::class, 'store']);

View file

@ -25,7 +25,8 @@ Route::get('/password/reset/{token}', [Auth\LoginController::class, 'index'])->n
Route::middleware(['throttle:authentication'])->group(function () {
// Login endpoints.
Route::post('/login', [Auth\LoginController::class, 'login'])->middleware('recaptcha');
Route::post('/login/checkpoint', Auth\LoginCheckpointController::class)->name('auth.login-checkpoint');
Route::post('/login/checkpoint', [Auth\LoginCheckpointController::class, 'token'])->name('auth.checkpoint');
Route::post('/login/checkpoint/key', [Auth\LoginCheckpointController::class, 'key'])->name('auth.checkpoint.key');
// Forgot password route. A post to this endpoint will trigger an
// email to be sent containing a reset token.
@ -46,5 +47,5 @@ Route::post('/logout', [Auth\LoginController::class, 'logout'])
->middleware('auth')
->name('auth.logout');
// Catch any other combinations of routes and pass them off to the React component.
// Catch any other combinations of routes and pass them off to the React frontend.
Route::fallback([Auth\LoginController::class, 'index']);

View file

@ -9,13 +9,7 @@ with pkgs;
alejandra
composer
nodejs-18_x
nodePackages.pnpm
nodePackages.yarn
php81WithExtensions
docker-compose
];
shellHook = ''
PATH="$PATH:${pkgs.docker-compose}/libexec/docker/cli-plugins"
'';
}

View file

@ -255,7 +255,7 @@ class UserControllerTest extends ApplicationApiIntegrationTestCase
* Endpoints that should return a 403 error when the key does not have write
* permissions for user management.
*/
public static function userWriteEndpointsDataProvider(): array
public function userWriteEndpointsDataProvider(): array
{
return [
['postJson', '/api/application/users'],

View file

@ -241,7 +241,7 @@ class ApiKeyControllerTest extends ClientApiIntegrationTestCase
* Provides some different IP address combinations that can be used when
* testing that we accept the expected IP values.
*/
public static function validIPAddressDataProvider(): array
public function validIPAddressDataProvider(): array
{
return [
[[]],

View file

@ -331,7 +331,7 @@ class ClientControllerTest extends ClientApiIntegrationTestCase
$response->assertJsonPath('data.0.attributes.relationships.allocations.data.0.attributes.notes', null);
}
public static function filterTypeDataProvider(): array
public function filterTypeDataProvider(): array
{
return [['admin'], ['admin-all']];
}

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