Merge branch 'develop' into develop

This commit is contained in:
Caleb 2020-10-13 15:35:38 -04:00 committed by GitHub
commit ea778e9345
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
159 changed files with 3400 additions and 3896 deletions

View file

@ -3,6 +3,49 @@ 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.0.0
Pterodactyl 1.0 represents the culmination of over two years of work, almost 2,000 commits, endless bug and feature requests, and a dream that
has been in the making since 2013. 🎉
Due to the sheer size and timeline of this release I've massively truncated the listing below. There are numerous smaller
bug fixes and changes that would simply be too difficult to keep track of here. Please feel free to browse through the releases
tab for this repository to see more specific changes that have been made.
### Added
* Adds a new client-facing API allowing a user to control all aspects of their individual servers, or servers
which they have been granted access to as a subuser.
* Adds the ability for backups to be created for a server both manually and via a scheduled task.
* Adds the ability for users to modify their server allocations on the fly and include notes for each allocation.
* Adds the ability for users to generate recovery tokens for 2FA protected logins which can be used in place of
a code should their device be inaccessible.
* Adds support for transfering servers between Nodes via the Panel.
* Adds the ability to assign specific CPU cores to a server (CPU Pinning) process.
* Server owners can now reinstall their assigned server egg automatically with a button on the frontend.
### Changed
* The entire user frontend has been replaced with a responsive, React backed design implemented using Tailwind CSS.
* Replaces a large amount of complex daemon authentication logic by funneling most API calls through the Panel, and using
JSON Web Tokens where necessary to handle one-time direct authentication with Wings.
* Frontend server listing now includes a toggle to show or hide servers which an administrator has access to, rather
than always showing all servers on the system when logged into an admin account.
* We've replaced Ace Editor on the frontend with a better solution to allow lighter builds and more end-user functionality.
* Server permissions have been overhauled to be both easier to understand in the codebase, and allows plugins to better
hook into the permission system.
### Removed
* Removes large swaths of code complexity and confusing interface designs that caused a lot of pain to new developers
trying to jump into the codebase. We've simplified this to stick to more established Laravel design standards to make
it easy to parse through the project and make contributions.
## v0.7.19 (Derelict Dermodactylus)
### Fixed
* **[Security]** Fixes XSS in the admin area's server owner selection.
## v0.7.18 (Derelict Dermodactylus)
### Fixed
* **[Security]** Re-addressed missed endpoint that would not properly limit a user account to 5 API keys.
* **[Security]** Addresses a Client API vulnerability that would allow a user to list all servers on the system ([`GHSA-6888-7f3w-92jx`](https://github.com/pterodactyl/panel/security/advisories/GHSA-6888-7f3w-92jx))
## v0.7.17 (Derelict Dermodactylus)
### Fixed
* Limited accounts to 5 API keys at a time.
@ -301,7 +344,7 @@ the response from the server `GET` endpoint.
* Nest and Egg listings now show the associated ID in order to make API requests easier.
* Added star indicators to user listing in Admin CP to indicate users who are set as a root admin.
* Creating a new node will now requires a SSL connection if the Panel is configured to use SSL as well.
* Connector error messages due to permissions are now rendered correctly in the UI rather than causing a silent failure.
* Socketio error messages due to permissions are now rendered correctly in the UI rather than causing a silent failure.
* File manager now supports mass deletion option for files and folders.
* Support for CS:GO as a default service option selection.
* Support for GMOD as a default service option selection.
@ -431,7 +474,7 @@ the response from the server `GET` endpoint.
* Changed 2FA login process to be more secure. Previously authentication checking happened on the 2FA post page, now it happens prior and is passed along to the 2FA page to avoid storing any credentials.
### Added
* Connector error messages due to permissions are now rendered correctly in the UI rather than causing a silent failure.
* Socketio error messages due to permissions are now rendered correctly in the UI rather than causing a silent failure.
## v0.7.0-beta.1 (Derelict Dermodactylus)
### Added

View file

@ -1,8 +1,10 @@
[![Logo Image](https://cdn.pterodactyl.io/logos/new/pterodactyl_logo.png)](https://pterodactyl.io)
[![Build status](https://img.shields.io/travis/pterodactyl/panel/develop.svg?style=flat-square)](https://travis-ci.org/pterodactyl/panel)
[![Codecov](https://img.shields.io/codecov/c/github/pterodactyl/panel/develop.svg?style=flat-square)](https://codecov.io/gh/Pterodactyl/Panel)
[![Discord](https://img.shields.io/discord/122900397965705216.svg?style=flat-square&label=Discord)](https://pterodactyl.io/discord)
![GitHub Workflow Status](https://img.shields.io/github/workflow/status/pterodactyl/panel/tests?label=Tests&style=for-the-badge)
![Discord](https://img.shields.io/discord/122900397965705216?label=Discord&logo=Discord&logoColor=white&style=for-the-badge)
![GitHub Releases](https://img.shields.io/github/downloads/pterodactyl/panel/latest/total?style=for-the-badge)
![GitHub Pre-Releases](https://img.shields.io/github/downloads-pre/pterodactyl/panel/v1.0.0-rc.7/total?style=for-the-badge)
![GitHub contributors](https://img.shields.io/github/contributors/pterodactyl/panel?style=for-the-badge)
# Pterodactyl Panel
Pterodactyl is an open-source game server management panel built with PHP 7, React, and Go. Designed with security

View file

@ -0,0 +1,26 @@
<?php
namespace Pterodactyl\Console\Commands\Overrides;
use Pterodactyl\Console\RequiresDatabaseMigrations;
use Illuminate\Database\Console\Seeds\SeedCommand as BaseSeedCommand;
class SeedCommand extends BaseSeedCommand
{
use RequiresDatabaseMigrations;
/**
* Block someone from running this seed command if they have not completed the migration
* process.
*
* @return int
*/
public function handle()
{
if (!$this->hasCompletedMigrations()) {
return $this->showMigrationWarning();
}
return parent::handle();
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace Pterodactyl\Console\Commands\Overrides;
use Pterodactyl\Console\RequiresDatabaseMigrations;
use Illuminate\Foundation\Console\UpCommand as BaseUpCommand;
class UpCommand extends BaseUpCommand
{
use RequiresDatabaseMigrations;
/**
* @return bool|int
*/
public function handle()
{
if (!$this->hasCompletedMigrations()) {
return $this->showMigrationWarning();
}
return parent::handle();
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace Pterodactyl\Console;
/**
* @mixin \Illuminate\Console\Command
*/
trait RequiresDatabaseMigrations
{
/**
* Checks if the migrations have finished running by comparing the last migration file.
*
* @return bool
*/
protected function hasCompletedMigrations(): bool
{
/** @var \Illuminate\Database\Migrations\Migrator $migrator */
$migrator = $this->getLaravel()->make('migrator');
$files = $migrator->getMigrationFiles(database_path('migrations'));
if (! $migrator->repositoryExists()) {
return false;
}
if (array_diff(array_keys($files), $migrator->getRepository()->getRan())) {
return false;
}
return true;
}
/**
* Throw a massive error into the console to hopefully catch the users attention and get
* them to properly run the migrations rather than ignoring all of the other previous
* errors...
*
* @return int
*/
protected function showMigrationWarning(): int
{
$this->getOutput()->writeln("<options=bold>
| @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |
| |
| Your database has not been properly migrated! |
| |
| @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ |</>
You must run the following command to finish migrating your database:
<fg=green;options=bold>php artisan migrate --step --force</>
You will not be able to use Pterodactyl Panel as expected without fixing your
database state by running the command above.
");
$this->getOutput()->error("You must correct the error above before continuing.");
return 1;
}
}

View file

@ -42,18 +42,6 @@ interface DatabaseRepositoryInterface extends RepositoryInterface
*/
public function getDatabasesForHost(int $host, int $count = 25): LengthAwarePaginator;
/**
* Create a new database if it does not already exist on the host with
* the provided details.
*
* @param array $data
* @return \Pterodactyl\Models\Database
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\DuplicateDatabaseNameException
*/
public function createIfNotExists(array $data): Database;
/**
* Create a new database on a given connection.
*

View file

@ -54,15 +54,4 @@ interface NodeRepositoryInterface extends RepositoryInterface
* @return \Illuminate\Support\Collection
*/
public function getNodesForServerCreation(): Collection;
/**
* Return the IDs of all nodes that exist in the provided locations and have the space
* available to support the additional disk and memory provided.
*
* @param array $locations
* @param int $disk
* @param int $memory
* @return \Illuminate\Support\LazyCollection
*/
public function getNodesWithResourceUse(array $locations, int $disk, int $memory): LazyCollection;
}

View file

@ -17,6 +17,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
class Handler extends ExceptionHandler
{
@ -217,7 +218,9 @@ class Handler extends ExceptionHandler
'status' => method_exists($exception, 'getStatusCode')
? strval($exception->getStatusCode())
: ($exception instanceof ValidationException ? '422' : '500'),
'detail' => 'An error was encountered while processing this request.',
'detail' => $exception instanceof HttpExceptionInterface
? $exception->getMessage()
: 'An unexpected error was encountered while processing this request, please try again.',
];
if ($exception instanceof ModelNotFoundException || $exception->getPrevious() instanceof ModelNotFoundException) {

View file

@ -2,10 +2,12 @@
namespace Pterodactyl\Http\Controllers\Admin;
use Ramsey\Uuid\Uuid;
use Illuminate\Http\Request;
use Pterodactyl\Models\Nest;
use Pterodactyl\Models\Mount;
use Pterodactyl\Models\Location;
use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Mounts\MountUpdateService;
use Pterodactyl\Http\Requests\Admin\MountFormRequest;
@ -37,21 +39,6 @@ class MountController extends Controller
*/
protected $repository;
/**
* @var \Pterodactyl\Services\Mounts\MountCreationService
*/
protected $creationService;
/**
* @var \Pterodactyl\Services\Mounts\MountDeletionService
*/
protected $deletionService;
/**
* @var \Pterodactyl\Services\Mounts\MountUpdateService
*/
protected $updateService;
/**
* MountController constructor.
*
@ -59,26 +46,17 @@ class MountController extends Controller
* @param \Pterodactyl\Contracts\Repository\NestRepositoryInterface $nestRepository
* @param \Pterodactyl\Contracts\Repository\LocationRepositoryInterface $locationRepository
* @param \Pterodactyl\Repositories\Eloquent\MountRepository $repository
* @param \Pterodactyl\Services\Mounts\MountCreationService $creationService
* @param \Pterodactyl\Services\Mounts\MountDeletionService $deletionService
* @param \Pterodactyl\Services\Mounts\MountUpdateService $updateService
*/
public function __construct(
AlertsMessageBag $alert,
NestRepositoryInterface $nestRepository,
LocationRepositoryInterface $locationRepository,
MountRepository $repository,
MountCreationService $creationService,
MountDeletionService $deletionService,
MountUpdateService $updateService
MountRepository $repository
) {
$this->alert = $alert;
$this->nestRepository = $nestRepository;
$this->locationRepository = $locationRepository;
$this->repository = $repository;
$this->creationService = $creationService;
$this->deletionService = $deletionService;
$this->updateService = $updateService;
}
/**
@ -103,11 +81,8 @@ class MountController extends Controller
*/
public function view($id)
{
$nests = $this->nestRepository->all();
$nests->load('eggs');
$locations = $this->locationRepository->all();
$locations->load('nodes');
$nests = Nest::query()->with('eggs')->get();
$locations = Location::query()->with('nodes')->get();
return view('admin.mounts.view', [
'mount' => $this->repository->getWithRelations($id),
@ -126,7 +101,13 @@ class MountController extends Controller
*/
public function create(MountFormRequest $request)
{
$mount = $this->creationService->handle($request->normalize());
/** @var \Pterodactyl\Models\Mount $mount */
$model = (new Mount())->fill($request->validated());
$model->forceFill(['uuid' => Uuid::uuid4()->toString()]);
$model->saveOrFail();
$mount = $model->fresh();
$this->alert->success('Mount was created successfully.')->flash();
return redirect()->route('admin.mounts.view', $mount->id);
@ -147,7 +128,8 @@ class MountController extends Controller
return $this->delete($mount);
}
$this->updateService->handle($mount->id, $request->normalize());
$mount->forceFill($request->validated())->save();
$this->alert->success('Mount was updated successfully.')->flash();
return redirect()->route('admin.mounts.view', $mount->id);
@ -163,15 +145,9 @@ class MountController extends Controller
*/
public function delete(Mount $mount)
{
try {
$this->deletionService->handle($mount->id);
$mount->delete();
return redirect()->route('admin.mounts');
} catch (DisplayException $ex) {
$this->alert->danger($ex->getMessage())->flash();
}
return redirect()->route('admin.mounts.view', $mount->id);
return redirect()->route('admin.mounts');
}
/**
@ -188,11 +164,12 @@ class MountController extends Controller
]);
$eggs = $validatedData['eggs'] ?? [];
if (sizeof($eggs) > 0) {
$mount->eggs()->attach(array_map('intval', $eggs));
$this->alert->success('Mount was updated successfully.')->flash();
if (count($eggs) > 0) {
$mount->eggs()->attach($eggs);
}
$this->alert->success('Mount was updated successfully.')->flash();
return redirect()->route('admin.mounts.view', $mount->id);
}
@ -205,16 +182,15 @@ class MountController extends Controller
*/
public function addNodes(Request $request, Mount $mount)
{
$validatedData = $request->validate([
'nodes' => 'required|exists:nodes,id',
]);
$data = $request->validate(['nodes' => 'required|exists:nodes,id']);
$nodes = $validatedData['nodes'] ?? [];
if (sizeof($nodes) > 0) {
$mount->nodes()->attach(array_map('intval', $nodes));
$this->alert->success('Mount was updated successfully.')->flash();
$nodes = $data['nodes'] ?? [];
if (count($nodes) > 0) {
$mount->nodes()->attach($nodes);
}
$this->alert->success('Mount was updated successfully.')->flash();
return redirect()->route('admin.mounts.view', $mount->id);
}

View file

@ -1,15 +1,9 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Http\Controllers\Admin\Nests;
use Illuminate\View\View;
use Pterodactyl\Models\Egg;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Http\Controllers\Controller;
@ -81,14 +75,14 @@ class EggScriptController extends Controller
* Handle a request to update the installation script for an Egg.
*
* @param \Pterodactyl\Http\Requests\Admin\Egg\EggScriptFormRequest $request
* @param int $egg
* @param \Pterodactyl\Models\Egg $egg
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Pterodactyl\Exceptions\Service\Egg\InvalidCopyFromException
*/
public function update(EggScriptFormRequest $request, int $egg): RedirectResponse
public function update(EggScriptFormRequest $request, Egg $egg): RedirectResponse
{
$this->installScriptService->handle($egg, $request->normalize());
$this->alert->success(trans('admin/nests.eggs.notices.script_updated'))->flash();

View file

@ -102,7 +102,7 @@ class EggShareController extends Controller
* Update an existing Egg using a new imported file.
*
* @param \Pterodactyl\Http\Requests\Admin\Egg\EggImportFormRequest $request
* @param int $egg
* @param \Pterodactyl\Models\Egg $egg
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
@ -110,7 +110,7 @@ class EggShareController extends Controller
* @throws \Pterodactyl\Exceptions\Service\Egg\BadJsonFormatException
* @throws \Pterodactyl\Exceptions\Service\InvalidFileUploadException
*/
public function update(EggImportFormRequest $request, int $egg): RedirectResponse
public function update(EggImportFormRequest $request, Egg $egg): RedirectResponse
{
$this->updateImporterService->handle($egg, $request->file('import_file'));
$this->alert->success(trans('admin/nests.eggs.notices.updated_via_import'))->flash();

View file

@ -12,7 +12,9 @@ namespace Pterodactyl\Http\Controllers\Admin;
use Illuminate\Support\Arr;
use Illuminate\Http\Request;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Mount;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\MountServer;
use Prologue\Alerts\AlertsMessageBag;
use GuzzleHttp\Exception\RequestException;
use Pterodactyl\Exceptions\DisplayException;
@ -251,7 +253,7 @@ class ServersController extends Controller
*/
public function reinstallServer(Server $server)
{
$this->reinstallService->reinstall($server);
$this->reinstallService->handle($server);
$this->alert->success(trans('admin/server.alerts.server_reinstalled'))->flash();
return redirect()->route('admin.servers.view.manage', $server->id);
@ -332,13 +334,18 @@ class ServersController extends Controller
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function saveStartup(Request $request, Server $server)
{
$this->startupModificationService->setUserLevel(User::USER_LEVEL_ADMIN);
$this->startupModificationService->handle($server, $request->except('_token'));
try {
$this->startupModificationService
->setUserLevel(User::USER_LEVEL_ADMIN)
->handle($server, $request->except('_token'));
} catch (DataValidationException $exception) {
throw new ValidationException($exception->validator);
}
$this->alert->success(trans('admin/server.alerts.startup_changed'))->flash();
return redirect()->route('admin.servers.view.startup', $server->id);
@ -356,7 +363,7 @@ class ServersController extends Controller
public function newDatabase(StoreServerDatabaseRequest $request, Server $server)
{
$this->databaseManagementService->create($server, [
'database' => $request->input('database'),
'database' => DatabaseManagementService::generateUniqueDatabaseName($request->input('database'), $server->id),
'remote' => $request->input('remote'),
'database_host_id' => $request->input('database_host_id'),
'max_connections' => $request->input('max_connections'),
@ -403,7 +410,7 @@ class ServersController extends Controller
['id', '=', $database],
]);
$this->databaseManagementService->delete($database->id);
$this->databaseManagementService->delete($database);
return response('', 204);
}
@ -412,12 +419,17 @@ class ServersController extends Controller
* Add a mount to a server.
*
* @param Server $server
* @param int $mount_id
* @param \Pterodactyl\Models\Mount $mount
*
* @return \Illuminate\Http\RedirectResponse
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException|\Throwable
*/
public function addMount(Server $server, int $mount_id)
public function addMount(Server $server, Mount $mount)
{
$server->mounts()->attach($mount_id);
$mountServer = new MountServer;
$mountServer->mount_id = $mount->id;
$mountServer->server_id = $server->id;
$mountServer->saveOrFail();
$data = $this->serverConfigurationStructureService->handle($server);
@ -438,15 +450,15 @@ class ServersController extends Controller
* Remove a mount from a server.
*
* @param Server $server
* @param int $mount_id
* @param \Pterodactyl\Models\Mount $mount
* @return \Illuminate\Http\RedirectResponse
*
* @throws DaemonConnectionException
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function deleteMount(Server $server, int $mount_id)
public function deleteMount(Server $server, Mount $mount)
{
$server->mounts()->detach($mount_id);
MountServer::where('mount_id', $mount->id)->where('server_id', $server->id)->delete();
$data = $this->serverConfigurationStructureService->handle($server);

View file

@ -86,8 +86,8 @@ class UserController extends Controller
{
$users = QueryBuilder::for(
User::query()->select('users.*')
->selectRaw('COUNT(subusers.id) as subuser_of_count')
->selectRaw('COUNT(servers.id) as servers_count')
->selectRaw('COUNT(DISTINCT(subusers.id)) as subuser_of_count')
->selectRaw('COUNT(DISTINCT(servers.id)) as servers_count')
->leftJoin('subusers', 'subusers.user_id', '=', 'users.id')
->leftJoin('servers', 'servers.owner_id', '=', 'users.id')
->groupBy('users.id')

View file

@ -110,7 +110,9 @@ class DatabaseController extends ApplicationApiController
*/
public function store(StoreServerDatabaseRequest $request, Server $server): JsonResponse
{
$database = $this->databaseManagementService->create($server, $request->validated());
$database = $this->databaseManagementService->create($server, array_merge($request->validated(), [
'database' => $request->databaseName(),
]));
return $this->fractal->item($database)
->transformWith($this->getTransformer(ServerDatabaseTransformer::class))
@ -133,7 +135,7 @@ class DatabaseController extends ApplicationApiController
*/
public function delete(ServerDatabaseWriteRequest $request): Response
{
$this->databaseManagementService->delete($request->getModel(Database::class)->id);
$this->databaseManagementService->delete($request->getModel(Database::class));
return response('', 204);
}

View file

@ -82,7 +82,7 @@ class ServerManagementController extends ApplicationApiController
*/
public function reinstall(ServerWriteRequest $request, Server $server): Response
{
$this->reinstallServerService->reinstall($server);
$this->reinstallServerService->handle($server);
return $this->returnNoContent();
}

View file

@ -129,7 +129,7 @@ class DatabaseController extends ClientApiController
*/
public function delete(DeleteDatabaseRequest $request, Server $server, Database $database): Response
{
$this->managementService->delete($database->id);
$this->managementService->delete($database);
return Response::create('', Response::HTTP_NO_CONTENT);
}

View file

@ -70,7 +70,7 @@ class FileController extends ClientApiController
{
$contents = $this->fileRepository
->setServer($server)
->getDirectory(urlencode($request->get('directory') ?? '/'));
->getDirectory(urlencode(urldecode($request->get('directory') ?? '/')));
return $this->fractal->collection($contents)
->transformWith($this->getTransformer(FileObjectTransformer::class))
@ -91,7 +91,7 @@ class FileController extends ClientApiController
{
return new Response(
$this->fileRepository->setServer($server)->getContent(
urlencode($request->get('file')), config('pterodactyl.files.max_edit_size')
urlencode(urldecode($request->get('file'))), config('pterodactyl.files.max_edit_size')
),
Response::HTTP_OK,
['Content-Type' => 'text/plain']

View file

@ -120,15 +120,27 @@ class ScheduleController extends ClientApiController
*/
public function update(UpdateScheduleRequest $request, Server $server, Schedule $schedule)
{
$this->repository->update($schedule->id, [
$active = (bool) $request->input('is_active');
$data = [
'name' => $request->input('name'),
'cron_day_of_week' => $request->input('day_of_week'),
'cron_day_of_month' => $request->input('day_of_month'),
'cron_hour' => $request->input('hour'),
'cron_minute' => $request->input('minute'),
'is_active' => (bool) $request->input('is_active'),
'is_active' => $active,
'next_run_at' => $this->getNextRunAt($request),
]);
];
// Toggle the processing state of the scheduled task when it is enabled or disabled so that an
// invalid state can be reset without manual database intervention.
//
// @see https://github.com/pterodactyl/panel/issues/2425
if ($schedule->is_active !== $active) {
$data['is_processing'] = false;
}
$this->repository->update($schedule->id, $data);
return $this->fractal->item($schedule->refresh())
->transformWith($this->getTransformer(ScheduleTransformer::class))

View file

@ -69,7 +69,7 @@ class SettingsController extends ClientApiController
*/
public function reinstall(ReinstallServerRequest $request, Server $server)
{
$this->reinstallServerService->reinstall($server);
$this->reinstallServerService->handle($server);
return new JsonResponse([], Response::HTTP_ACCEPTED);
}

View file

@ -100,7 +100,7 @@ class StartupController extends ClientApiController
'server_id' => $server->id,
'variable_id' => $variable->id,
], [
'variable_value' => $request->input('value'),
'variable_value' => $request->input('value') ?? '',
]);
$variable = $variable->refresh();

View file

@ -49,11 +49,11 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings
return Database::query()->where('id', $id)->firstOrFail();
});
$this->router->model('backup', Backup::class, function ($value) {
$this->router->bind('backup', function ($value) {
return Backup::query()->where('uuid', $value)->firstOrFail();
});
$this->router->model('user', User::class, function ($value) {
$this->router->bind('user', function ($value) {
return User::query()->where('uuid', $value)->firstOrFail();
});

View file

@ -19,12 +19,12 @@ class EggFormRequest extends AdminFormRequest
public function rules()
{
$rules = [
'name' => 'required|string|max:255',
'name' => 'required|string|max:191',
'description' => 'nullable|string',
'docker_image' => 'required|string|max:255',
'docker_image' => 'required|string|max:191',
'startup' => 'required|string',
'config_from' => 'sometimes|bail|nullable|numeric',
'config_stop' => 'required_without:config_from|nullable|string|max:255',
'config_stop' => 'required_without:config_from|nullable|string|max:191',
'config_startup' => 'required_without:config_from|nullable|json',
'config_logs' => 'required_without:config_from|nullable|json',
'config_files' => 'required_without:config_from|nullable|json',

View file

@ -15,9 +15,9 @@ class EggVariableFormRequest extends AdminFormRequest
public function rules()
{
return [
'name' => 'required|string|min:1|max:255',
'name' => 'required|string|min:1|max:191',
'description' => 'sometimes|nullable|string',
'env_variable' => 'required|regex:/^[\w]{1,255}$/|notIn:' . EggVariable::RESERVED_ENV_NAMES,
'env_variable' => 'required|regex:/^[\w]{1,191}$/|notIn:' . EggVariable::RESERVED_ENV_NAMES,
'options' => 'sometimes|required|array',
'rules' => 'bail|required|string',
'default_value' => 'present',

View file

@ -1,11 +1,4 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Http\Requests\Admin;

View file

@ -19,7 +19,7 @@ class StoreNestFormRequest extends AdminFormRequest
public function rules()
{
return [
'name' => 'required|string|min:1|max:255',
'name' => 'required|string|min:1|max:191',
'description' => 'string|nullable',
];
}

View file

@ -20,7 +20,7 @@ class AllocationFormRequest extends AdminFormRequest
{
return [
'allocation_ip' => 'required|string',
'allocation_alias' => 'sometimes|nullable|string|max:255',
'allocation_alias' => 'sometimes|nullable|string|max:191',
'allocation_ports' => 'required|array',
];
}

View file

@ -15,8 +15,8 @@ class AdvancedSettingsFormRequest extends AdminFormRequest
{
return [
'recaptcha:enabled' => 'required|in:true,false',
'recaptcha:secret_key' => 'required|string|max:255',
'recaptcha:website_key' => 'required|string|max:255',
'recaptcha:secret_key' => 'required|string|max:191',
'recaptcha:website_key' => 'required|string|max:191',
'pterodactyl:guzzle:timeout' => 'required|integer|between:1,60',
'pterodactyl:guzzle:connect_timeout' => 'required|integer|between:1,60',
'pterodactyl:console:count' => 'required|integer|min:1',

View file

@ -16,7 +16,7 @@ class BaseSettingsFormRequest extends AdminFormRequest
public function rules()
{
return [
'app:name' => 'required|string|max:255',
'app:name' => 'required|string|max:191',
'pterodactyl:auth:2fa_required' => 'required|integer|in:0,1,2',
'app:locale' => ['required', 'string', Rule::in(array_keys($this->getAvailableLanguages()))],
'app:analytics' => 'nullable|string',

View file

@ -18,10 +18,10 @@ class MailSettingsFormRequest extends AdminFormRequest
'mail:host' => 'required|string',
'mail:port' => 'required|integer|between:1,65535',
'mail:encryption' => ['present', Rule::in([null, 'tls', 'ssl'])],
'mail:username' => 'nullable|string|max:255',
'mail:password' => 'nullable|string|max:255',
'mail:username' => 'nullable|string|max:191',
'mail:password' => 'nullable|string|max:191',
'mail:from:address' => 'required|string|email',
'mail:from:name' => 'nullable|string|max:255',
'mail:from:name' => 'nullable|string|max:191',
];
}

View file

@ -24,7 +24,7 @@ class StoreAllocationRequest extends ApplicationApiRequest
{
return [
'ip' => 'required|string',
'alias' => 'sometimes|nullable|string|max:255',
'alias' => 'sometimes|nullable|string|max:191',
'ports' => 'required|array',
'ports.*' => 'string',
];

View file

@ -2,9 +2,12 @@
namespace Pterodactyl\Http\Requests\Api\Application\Servers\Databases;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Illuminate\Validation\Rule;
use Illuminate\Database\Query\Builder;
use Pterodactyl\Services\Acl\Api\AdminAcl;
use Pterodactyl\Services\Databases\DatabaseManagementService;
use Pterodactyl\Http\Requests\Api\Application\ApplicationApiRequest;
class StoreServerDatabaseRequest extends ApplicationApiRequest
@ -26,14 +29,16 @@ class StoreServerDatabaseRequest extends ApplicationApiRequest
*/
public function rules(): array
{
$server = $this->route()->parameter('server');
return [
'database' => [
'required',
'string',
'alpha_dash',
'min:1',
'max:24',
Rule::unique('databases')->where(function (Builder $query) {
$query->where('database_host_id', $this->input('host') ?? 0);
'max:48',
Rule::unique('databases')->where(function (Builder $query) use ($server) {
$query->where('server_id', $server->id)->where('database', $this->databaseName());
}),
],
'remote' => 'required|string|regex:/^[0-9%.]{1,15}$/',
@ -68,4 +73,18 @@ class StoreServerDatabaseRequest extends ApplicationApiRequest
'database' => 'Database Name',
];
}
/**
* Returns the database name in the expected format.
*
* @return string
*/
public function databaseName(): string
{
$server = $this->route()->parameter('server');
Assert::isInstanceOf($server, Server::class);
return DatabaseManagementService::generateUniqueDatabaseName($this->input('database'), $server->id);
}
}

View file

@ -2,6 +2,7 @@
namespace Pterodactyl\Http\Requests\Api\Client\Account;
use Pterodactyl\Models\ApiKey;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class StoreApiKeyRequest extends ClientApiRequest
@ -11,9 +12,11 @@ class StoreApiKeyRequest extends ClientApiRequest
*/
public function rules(): array
{
$rules = ApiKey::getRules();
return [
'description' => 'required|string|min:4',
'allowed_ips' => 'array',
'description' => $rules['memo'],
'allowed_ips' => $rules['allowed_ips'],
'allowed_ips.*' => 'ip',
];
}

View file

@ -21,7 +21,7 @@ class StoreBackupRequest extends ClientApiRequest
public function rules(): array
{
return [
'name' => 'nullable|string|max:255',
'name' => 'nullable|string|max:191',
'ignored' => 'nullable|string',
];
}

View file

@ -2,9 +2,14 @@
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Databases;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Illuminate\Validation\Rule;
use Pterodactyl\Models\Permission;
use Illuminate\Database\Query\Builder;
use Pterodactyl\Contracts\Http\ClientPermissionsRequest;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Services\Databases\DatabaseManagementService;
class StoreDatabaseRequest extends ClientApiRequest implements ClientPermissionsRequest
{
@ -21,9 +26,35 @@ class StoreDatabaseRequest extends ClientApiRequest implements ClientPermissions
*/
public function rules(): array
{
$server = $this->route()->parameter('server');
Assert::isInstanceOf($server, Server::class);
return [
'database' => 'required|alpha_dash|min:3|max:48',
'database' => [
'required',
'alpha_dash',
'min:1',
'max:48',
// Yes, I am aware that you could have the same database name across two unique hosts. However,
// I don't really care about that for this validation. We just want to make sure it is unique to
// the server itself. No need for complexity.
Rule::unique('databases')->where(function (Builder $query) use ($server) {
$query->where('server_id', $server->id)
->where('database', DatabaseManagementService::generateUniqueDatabaseName($this->input('database'), $server->id));
}),
],
'remote' => 'required|string|regex:/^[0-9%.]{1,15}$/',
];
}
/**
* @return array
*/
public function messages()
{
return [
'database.unique' => 'The database name you have selected is already in use by this server.',
];
}
}

View file

@ -24,7 +24,7 @@ class UpdateStartupVariableRequest extends ClientApiRequest
{
return [
'key' => 'required|string',
'value' => 'present|string',
'value' => 'present',
];
}
}

View file

@ -20,7 +20,7 @@ class StoreSubuserRequest extends SubuserRequest
public function rules(): array
{
return [
'email' => 'required|email',
'email' => 'required|email|between:1,191',
'permissions' => 'required|array',
'permissions.*' => 'string',
];

View file

@ -3,10 +3,10 @@
namespace Pterodactyl\Jobs\Schedule;
use Exception;
use Carbon\Carbon;
use Pterodactyl\Jobs\Job;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Task;
use InvalidArgumentException;
use Illuminate\Container\Container;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
@ -15,39 +15,25 @@ use Pterodactyl\Repositories\Eloquent\TaskRepository;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Repositories\Wings\DaemonPowerRepository;
use Pterodactyl\Repositories\Wings\DaemonCommandRepository;
use Pterodactyl\Contracts\Repository\TaskRepositoryInterface;
use Pterodactyl\Contracts\Repository\ScheduleRepositoryInterface;
class RunTaskJob extends Job implements ShouldQueue
{
use DispatchesJobs, InteractsWithQueue, SerializesModels;
/**
* @var int
*/
public $schedule;
/**
* @var int
* @var \Pterodactyl\Models\Task
*/
public $task;
/**
* @var \Pterodactyl\Repositories\Eloquent\TaskRepository
*/
protected $taskRepository;
/**
* RunTaskJob constructor.
*
* @param int $task
* @param int $schedule
* @param \Pterodactyl\Models\Task $task
*/
public function __construct(int $task, int $schedule)
public function __construct(Task $task)
{
$this->queue = config('pterodactyl.queues.standard');
$this->task = $task;
$this->schedule = $schedule;
}
/**
@ -58,7 +44,6 @@ class RunTaskJob extends Job implements ShouldQueue
* @param \Pterodactyl\Repositories\Wings\DaemonPowerRepository $powerRepository
* @param \Pterodactyl\Repositories\Eloquent\TaskRepository $taskRepository
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Throwable
*/
public function handle(
@ -67,36 +52,32 @@ class RunTaskJob extends Job implements ShouldQueue
DaemonPowerRepository $powerRepository,
TaskRepository $taskRepository
) {
$this->taskRepository = $taskRepository;
$task = $this->taskRepository->getTaskForJobProcess($this->task);
$server = $task->getRelation('server');
// Do not process a task that is not set to active.
if (! $task->getRelation('schedule')->is_active) {
if (! $this->task->schedule->is_active) {
$this->markTaskNotQueued();
$this->markScheduleComplete();
return;
}
$server = $this->task->server;
// Perform the provided task against the daemon.
switch ($task->action) {
switch ($this->task->action) {
case 'power':
$powerRepository->setServer($server)->send($task->payload);
$powerRepository->setServer($server)->send($this->task->payload);
break;
case 'command':
$commandRepository->setServer($server)->send($task->payload);
$commandRepository->setServer($server)->send($this->task->payload);
break;
case 'backup':
$backupService->setIgnoredFiles(explode(PHP_EOL, $task->payload))->handle($server, null);
$backupService->setIgnoredFiles(explode(PHP_EOL, $this->task->payload))->handle($server, null);
break;
default:
throw new InvalidArgumentException('Cannot run a task that points to a non-existent action.');
}
$this->markTaskNotQueued();
$this->queueNextTask($task->sequence_id);
$this->queueNextTask();
}
/**
@ -112,23 +93,23 @@ class RunTaskJob extends Job implements ShouldQueue
/**
* Get the next task in the schedule and queue it for running after the defined period of wait time.
*
* @param int $sequence
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
private function queueNextTask($sequence)
private function queueNextTask()
{
$nextTask = $this->taskRepository->getNextTask($this->schedule, $sequence);
/** @var \Pterodactyl\Models\Task|null $nextTask */
$nextTask = Task::query()->where('schedule_id', $this->task->schedule_id)
->where('sequence_id', $this->task->sequence_id + 1)
->first();
if (is_null($nextTask)) {
$this->markScheduleComplete();
return;
}
$this->taskRepository->update($nextTask->id, ['is_queued' => true]);
$this->dispatch((new self($nextTask->id, $this->schedule))->delay($nextTask->time_offset));
$nextTask->update(['is_queued' => true]);
$this->dispatch((new self($nextTask))->delay($nextTask->time_offset));
}
/**
@ -136,13 +117,10 @@ class RunTaskJob extends Job implements ShouldQueue
*/
private function markScheduleComplete()
{
Container::getInstance()
->make(ScheduleRepositoryInterface::class)
->withoutFreshModel()
->update($this->schedule, [
'is_processing' => false,
'last_run_at' => Carbon::now()->toDateTimeString(),
]);
$this->task->schedule()->update([
'is_processing' => false,
'last_run_at' => CarbonImmutable::now()->toDateTimeString(),
]);
}
/**
@ -150,8 +128,6 @@ class RunTaskJob extends Job implements ShouldQueue
*/
private function markTaskNotQueued()
{
Container::getInstance()
->make(TaskRepositoryInterface::class)
->update($this->task, ['is_queued' => false]);
$this->task->update(['is_queued' => false]);
}
}

View file

@ -50,7 +50,7 @@ class DatabaseHost extends Model
* @var array
*/
public static $validationRules = [
'name' => 'required|string|max:255',
'name' => 'required|string|max:191',
'host' => 'required|string',
'port' => 'required|numeric|between:1,65535',
'username' => 'required|string|max:32',

View file

@ -93,13 +93,13 @@ class Egg extends Model
public static $validationRules = [
'nest_id' => 'required|bail|numeric|exists:nests,id',
'uuid' => 'required|string|size:36',
'name' => 'required|string|max:255',
'name' => 'required|string|max:191',
'description' => 'string|nullable',
'author' => 'required|string|email',
'docker_image' => 'required|string|max:255',
'docker_image' => 'required|string|max:191',
'startup' => 'required|nullable|string',
'config_from' => 'sometimes|bail|nullable|numeric|exists:eggs,id',
'config_stop' => 'required_without:config_from|nullable|string|max:255',
'config_stop' => 'required_without:config_from|nullable|string|max:191',
'config_startup' => 'required_without:config_from|nullable|json',
'config_logs' => 'required_without:config_from|nullable|json',
'config_files' => 'required_without:config_from|nullable|json',

21
app/Models/EggMount.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace Pterodactyl\Models;
class EggMount extends Model
{
/**
* @var string
*/
protected $table = 'egg_mount';
/**
* @var null
*/
protected $primaryKey = null;
/**
* @var bool
*/
public $incrementing = false;
}

View file

@ -73,9 +73,9 @@ class EggVariable extends Model
*/
public static $validationRules = [
'egg_id' => 'exists:eggs,id',
'name' => 'required|string|between:1,255',
'name' => 'required|string|between:1,191',
'description' => 'string',
'env_variable' => 'required|regex:/^[\w]{1,255}$/|notIn:' . self::RESERVED_ENV_NAMES,
'env_variable' => 'required|regex:/^[\w]{1,191}$/|notIn:' . self::RESERVED_ENV_NAMES,
'default_value' => 'string',
'user_viewable' => 'boolean',
'user_editable' => 'boolean',

View file

@ -41,7 +41,7 @@ class Location extends Model
*/
public static $validationRules = [
'short' => 'required|string|between:1,60|unique:locations,short',
'long' => 'string|nullable|between:1,255',
'long' => 'string|nullable|between:1,191',
];
/**

View file

@ -7,6 +7,7 @@ use Illuminate\Validation\Rule;
use Illuminate\Container\Container;
use Illuminate\Contracts\Validation\Factory;
use Illuminate\Database\Eloquent\Model as IlluminateModel;
use Pterodactyl\Exceptions\Model\DataValidationException;
abstract class Model extends IlluminateModel
{
@ -55,7 +56,11 @@ abstract class Model extends IlluminateModel
static::$validatorFactory = Container::getInstance()->make(Factory::class);
static::saving(function (Model $model) {
return $model->validate();
if (! $model->validate()) {
throw new DataValidationException($model->getValidator());
}
return true;
});
}
@ -147,9 +152,9 @@ abstract class Model extends IlluminateModel
}
return $this->getValidator()->setData(
// Trying to do self::toArray() here will leave out keys based on the whitelist/blacklist
// for that model. Doing this will return all of the attributes in a format that can
// properly be validated.
// Trying to do self::toArray() here will leave out keys based on the whitelist/blacklist
// for that model. Doing this will return all of the attributes in a format that can
// properly be validated.
$this->addCastAttributesToArray(
$this->getAttributes(), $this->getMutatedAttributes()
)

View file

@ -56,7 +56,7 @@ class Mount extends Model
*/
public static $validationRules = [
'name' => 'required|string|min:2|max:64|unique:mounts,name',
'description' => 'nullable|string|max:255',
'description' => 'nullable|string|max:191',
'source' => 'required|string',
'target' => 'required|string',
'read_only' => 'sometimes|boolean',

View file

@ -11,6 +11,11 @@ class MountServer extends Model
*/
protected $table = 'mount_server';
/**
* @var bool
*/
public $timestamps = false;
/**
* @var null
*/

View file

@ -44,7 +44,7 @@ class Nest extends Model
*/
public static $validationRules = [
'author' => 'required|string|email',
'name' => 'required|string|max:255',
'name' => 'required|string|max:191',
'description' => 'nullable|string',
];

View file

@ -219,80 +219,4 @@ class Permission extends Model
{
return Collection::make(self::$permissions);
}
/**
* A list of all permissions available for a user.
*
* @var array
* @deprecated
*/
protected static $deprecatedPermissions = [
'power' => [
'power-start' => 's:power:start',
'power-stop' => 's:power:stop',
'power-restart' => 's:power:restart',
'power-kill' => 's:power:kill',
'send-command' => 's:command',
],
'subuser' => [
'list-subusers' => null,
'view-subuser' => null,
'edit-subuser' => null,
'create-subuser' => null,
'delete-subuser' => null,
],
'server' => [
'view-allocations' => null,
'edit-allocation' => null,
'view-startup' => null,
'edit-startup' => null,
],
'database' => [
'view-databases' => null,
'reset-db-password' => null,
'delete-database' => null,
'create-database' => null,
],
'file' => [
'access-sftp' => null,
'list-files' => 's:files:get',
'edit-files' => 's:files:read',
'save-files' => 's:files:post',
'move-files' => 's:files:move',
'copy-files' => 's:files:copy',
'compress-files' => 's:files:compress',
'decompress-files' => 's:files:decompress',
'create-files' => 's:files:create',
'upload-files' => 's:files:upload',
'delete-files' => 's:files:delete',
'download-files' => 's:files:download',
],
'task' => [
'list-schedules' => null,
'view-schedule' => null,
'toggle-schedule' => null,
'queue-schedule' => null,
'edit-schedule' => null,
'create-schedule' => null,
'delete-schedule' => null,
],
];
/**
* Return a collection of permissions available.
*
* @param bool $array
* @return array|\Illuminate\Database\Eloquent\Collection
* @deprecated
*/
public static function getPermissions($array = false)
{
if ($array) {
return collect(self::$deprecatedPermissions)->mapWithKeys(function ($item) {
return $item;
})->all();
}
return collect(self::$deprecatedPermissions);
}
}

View file

@ -103,7 +103,7 @@ class Schedule extends Model
*/
public static $validationRules = [
'server_id' => 'required|exists:servers,id',
'name' => 'required|string|max:255',
'name' => 'required|string|max:191',
'cron_day_of_week' => 'required|string',
'cron_day_of_month' => 'required|string',
'cron_hour' => 'required|string',

View file

@ -15,7 +15,7 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property string $name
* @property string $description
* @property bool $skip_scripts
* @property int $suspended
* @property bool $suspended
* @property int $owner_id
* @property int $memory
* @property int $swap
@ -103,7 +103,7 @@ class Server extends Model
public static $validationRules = [
'external_id' => 'sometimes|nullable|string|between:1,191|unique:servers',
'owner_id' => 'required|integer|exists:users,id',
'name' => 'required|string|min:1|max:255',
'name' => 'required|string|min:1|max:191',
'node_id' => 'required|exists:nodes,id',
'description' => 'string',
'memory' => 'required|numeric|min:0',
@ -118,7 +118,7 @@ class Server extends Model
'egg_id' => 'required|exists:eggs,id',
'startup' => 'required|string',
'skip_scripts' => 'sometimes|boolean',
'image' => 'required|string|max:255',
'image' => 'required|string|max:191',
'installed' => 'in:0,1,2',
'database_limit' => 'present|nullable|integer|min:0',
'allocation_limit' => 'sometimes|nullable|integer|min:0',
@ -133,7 +133,7 @@ class Server extends Model
protected $casts = [
'node_id' => 'integer',
'skip_scripts' => 'boolean',
'suspended' => 'integer',
'suspended' => 'boolean',
'owner_id' => 'integer',
'memory' => 'integer',
'swap' => 'integer',

View file

@ -25,7 +25,7 @@ class Setting extends Model
* @var array
*/
public static $validationRules = [
'key' => 'required|string|between:1,255',
'key' => 'required|string|between:1,191',
'value' => 'string',
];
}

View file

@ -137,11 +137,11 @@ class User extends Model implements
*/
public static $validationRules = [
'uuid' => 'required|string|size:36|unique:users,uuid',
'email' => 'required|email|unique:users,email',
'external_id' => 'sometimes|nullable|string|max:255|unique:users,external_id',
'username' => 'required|between:1,255|unique:users,username',
'name_first' => 'required|string|between:1,255',
'name_last' => 'required|string|between:1,255',
'email' => 'required|email|between:1,191|unique:users,email',
'external_id' => 'sometimes|nullable|string|max:191|unique:users,external_id',
'username' => 'required|between:1,191|unique:users,username',
'name_first' => 'required|string|between:1,191',
'name_last' => 'required|string|between:1,191',
'password' => 'sometimes|nullable|string',
'root_admin' => 'boolean',
'language' => 'string',

View file

@ -93,31 +93,6 @@ class DatabaseRepository extends EloquentRepository implements DatabaseRepositor
->paginate($count, $this->getColumns());
}
/**
* Create a new database if it does not already exist on the host with
* the provided details.
*
* @param array $data
* @return \Pterodactyl\Models\Database
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\DuplicateDatabaseNameException
*/
public function createIfNotExists(array $data): Database
{
$count = $this->getBuilder()->where([
['server_id', '=', array_get($data, 'server_id')],
['database_host_id', '=', array_get($data, 'database_host_id')],
['database', '=', array_get($data, 'database')],
])->count();
if ($count > 0) {
throw new DuplicateDatabaseNameException('A database with those details already exists for the specified server.');
}
return $this->create($data);
}
/**
* Create a new database on a given connection.
*

View file

@ -171,28 +171,4 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa
return $instance->first();
}
/**
* Return the IDs of all nodes that exist in the provided locations and have the space
* available to support the additional disk and memory provided.
*
* @param array $locations
* @param int $disk
* @param int $memory
* @return \Illuminate\Support\LazyCollection
*/
public function getNodesWithResourceUse(array $locations, int $disk, int $memory): LazyCollection
{
$instance = $this->getBuilder()
->select(['nodes.id', 'nodes.memory', 'nodes.disk', 'nodes.memory_overallocate', 'nodes.disk_overallocate'])
->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory, IFNULL(SUM(servers.disk), 0) as sum_disk')
->leftJoin('servers', 'servers.node_id', '=', 'nodes.id')
->where('nodes.public', 1);
if (! empty($locations)) {
$instance->whereIn('nodes.location_id', $locations);
}
return $instance->groupBy('nodes.id')->cursor();
}
}

View file

@ -3,18 +3,29 @@
namespace Pterodactyl\Services\Databases;
use Exception;
use InvalidArgumentException;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Database;
use Pterodactyl\Helpers\Utilities;
use Illuminate\Database\ConnectionInterface;
use Symfony\Component\VarDumper\Cloner\Data;
use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Extensions\DynamicDatabaseConnection;
use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface;
use Pterodactyl\Repositories\Eloquent\DatabaseRepository;
use Pterodactyl\Exceptions\Repository\DuplicateDatabaseNameException;
use Pterodactyl\Exceptions\Service\Database\TooManyDatabasesException;
use Pterodactyl\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException;
class DatabaseManagementService
{
/**
* The regex used to validate that the database name passed through to the function is
* in the expected format.
*
* @see \Pterodactyl\Services\Databases\DatabaseManagementService::generateUniqueDatabaseName()
*/
private const MATCH_NAME_REGEX = '/^(s[\d]+_)(.*)$/';
/**
* @var \Illuminate\Database\ConnectionInterface
*/
@ -31,7 +42,7 @@ class DatabaseManagementService
private $encrypter;
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface
* @var \Pterodactyl\Repositories\Eloquent\DatabaseRepository
*/
private $repository;
@ -50,13 +61,13 @@ class DatabaseManagementService
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Extensions\DynamicDatabaseConnection $dynamic
* @param \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface $repository
* @param \Pterodactyl\Repositories\Eloquent\DatabaseRepository $repository
* @param \Illuminate\Contracts\Encryption\Encrypter $encrypter
*/
public function __construct(
ConnectionInterface $connection,
DynamicDatabaseConnection $dynamic,
DatabaseRepositoryInterface $repository,
DatabaseRepository $repository,
Encrypter $encrypter
) {
$this->connection = $connection;
@ -65,6 +76,21 @@ class DatabaseManagementService
$this->repository = $repository;
}
/**
* Generates a unique database name for the given server. This name should be passed through when
* calling this handle function for this service, otherwise the database will be created with
* whatever name is provided.
*
* @param string $name
* @param int $serverId
* @return string
*/
public static function generateUniqueDatabaseName(string $name, int $serverId): string
{
// Max of 48 characters, including the s123_ that we append to the front.
return sprintf('s%d_%s', $serverId, substr($name, 0, 48 - strlen("s{$serverId}_")));
}
/**
* Set wether or not this class should validate that the server has enough slots
* left before creating the new database.
@ -104,12 +130,15 @@ class DatabaseManagementService
}
}
// Max of 48 characters, including the s123_ that we append to the front.
$truncatedName = substr($data['database'], 0, 48 - strlen("s{$server->id}_"));
// Protect against developer mistakes...
if (empty($data['database']) || ! preg_match(self::MATCH_NAME_REGEX, $data['database'])) {
throw new InvalidArgumentException(
'The database name passed to DatabaseManagementService::handle MUST be prefixed with "s{server_id}_".'
);
}
$data = array_merge($data, [
'server_id' => $server->id,
'database' => $truncatedName,
'username' => sprintf('u%d_%s', $server->id, str_random(10)),
'password' => $this->encrypter->encrypt(
Utilities::randomStringWithSpecialCharacters(24)
@ -120,7 +149,8 @@ class DatabaseManagementService
try {
return $this->connection->transaction(function () use ($data, &$database) {
$database = $this->repository->createIfNotExists($data);
$database = $this->createModel($data);
$this->dynamic->set('dynamic', $data['database_host_id']);
$this->repository->createDatabase($database->database);
@ -139,7 +169,7 @@ class DatabaseManagementService
$this->repository->dropUser($database->username, $database->remote);
$this->repository->flush();
}
} catch (Exception $exception) {
} catch (Exception $deletionException) {
// Do nothing here. We've already encountered an issue before this point so no
// reason to prioritize this error over the initial one.
}
@ -151,20 +181,48 @@ class DatabaseManagementService
/**
* Delete a database from the given host server.
*
* @param int $id
* @param \Pterodactyl\Models\Database $database
* @return bool|null
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Exception
*/
public function delete($id)
public function delete(Database $database)
{
$database = $this->repository->find($id);
$this->dynamic->set('dynamic', $database->database_host_id);
$this->repository->dropDatabase($database->database);
$this->repository->dropUser($database->username, $database->remote);
$this->repository->flush();
return $this->repository->delete($id);
return $database->delete();
}
/**
* Create the database if there is not an identical match in the DB. While you can technically
* have the same name across multiple hosts, for the sake of keeping this logic easy to understand
* and avoiding user confusion we will ignore the specific host and just look across all hosts.
*
* @param array $data
* @return \Pterodactyl\Models\Database
*
* @throws \Pterodactyl\Exceptions\Repository\DuplicateDatabaseNameException
* @throws \Throwable
*/
protected function createModel(array $data): Database
{
$exists = Database::query()->where('server_id', $data['server_id'])
->where('database', $data['database'])
->exists();
if ($exists) {
throw new DuplicateDatabaseNameException(
'A database with that name already exists for this server.'
);
}
$database = (new Database)->forceFill($data);
$database->saveOrFail();
return $database;
}
}

View file

@ -2,44 +2,27 @@
namespace Pterodactyl\Services\Databases;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Database;
use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface;
use Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface;
use Pterodactyl\Models\DatabaseHost;
use Pterodactyl\Exceptions\Service\Database\NoSuitableDatabaseHostException;
class DeployServerDatabaseService
{
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface
*/
private $databaseHostRepository;
/**
* @var \Pterodactyl\Services\Databases\DatabaseManagementService
*/
private $managementService;
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface
*/
private $repository;
/**
* ServerDatabaseCreationService constructor.
*
* @param \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface $repository
* @param \Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface $databaseHostRepository
* @param \Pterodactyl\Services\Databases\DatabaseManagementService $managementService
*/
public function __construct(
DatabaseRepositoryInterface $repository,
DatabaseHostRepositoryInterface $databaseHostRepository,
DatabaseManagementService $managementService
) {
$this->databaseHostRepository = $databaseHostRepository;
public function __construct(DatabaseManagementService $managementService)
{
$this->managementService = $managementService;
$this->repository = $repository;
}
/**
@ -53,28 +36,26 @@ class DeployServerDatabaseService
*/
public function handle(Server $server, array $data): Database
{
$allowRandom = config('pterodactyl.client_features.databases.allow_random');
$hosts = $this->databaseHostRepository->setColumns(['id'])->findWhere([
['node_id', '=', $server->node_id],
]);
if ($hosts->isEmpty() && ! $allowRandom) {
throw new NoSuitableDatabaseHostException;
}
Assert::notEmpty($data['database'] ?? null);
Assert::notEmpty($data['remote'] ?? null);
$hosts = DatabaseHost::query()->get()->toBase();
if ($hosts->isEmpty()) {
$hosts = $this->databaseHostRepository->setColumns(['id'])->all();
if ($hosts->isEmpty()) {
throw new NoSuitableDatabaseHostException;
} else {
$nodeHosts = $hosts->where('node_id', $server->node_id)->toBase();
if ($nodeHosts->isEmpty() && ! config('pterodactyl.client_features.databases.allow_random')) {
throw new NoSuitableDatabaseHostException;
}
}
$host = $hosts->random();
return $this->managementService->create($server, [
'database_host_id' => $host->id,
'database' => array_get($data, 'database'),
'remote' => array_get($data, 'remote'),
'database_host_id' => $nodeHosts->isEmpty()
? $hosts->random()->id
: $nodeHosts->random()->id,
'database' => DatabaseManagementService::generateUniqueDatabaseName($data['database'], $server->id),
'remote' => $data['remote'],
]);
}
}

View file

@ -3,16 +3,12 @@
namespace Pterodactyl\Services\Deployment;
use Webmozart\Assert\Assert;
use Pterodactyl\Contracts\Repository\NodeRepositoryInterface;
use Pterodactyl\Models\Node;
use Illuminate\Support\LazyCollection;
use Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException;
class FindViableNodesService
{
/**
* @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface
*/
private $repository;
/**
* @var array
*/
@ -28,16 +24,6 @@ class FindViableNodesService
*/
protected $memory;
/**
* FindViableNodesService constructor.
*
* @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository
*/
public function __construct(NodeRepositoryInterface $repository)
{
$this->repository = $repository;
}
/**
* Set the locations that should be searched through to locate available nodes.
*
@ -46,6 +32,8 @@ class FindViableNodesService
*/
public function setLocations(array $locations): self
{
Assert::allInteger($locations, 'An array of location IDs should be provided when calling setLocations.');
$this->locations = $locations;
return $this;
@ -90,32 +78,34 @@ class FindViableNodesService
* are tossed out, as are any nodes marked as non-public, meaning automatic
* deployments should not be done against them.
*
* @return int[]
* @return \Pterodactyl\Models\Node[]|\Illuminate\Support\Collection
* @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException
*/
public function handle(): array
public function handle()
{
Assert::integer($this->disk, 'Calls to ' . __METHOD__ . ' must have the disk space set as an integer, received %s');
Assert::integer($this->memory, 'Calls to ' . __METHOD__ . ' must have the memory usage set as an integer, received %s');
Assert::integer($this->disk, 'Disk space must be an int, got %s');
Assert::integer($this->memory, 'Memory usage must be an int, got %s');
$nodes = $this->repository->getNodesWithResourceUse($this->locations, $this->disk, $this->memory);
$viable = [];
$query = Node::query()->select('nodes.*')
->selectRaw('IFNULL(SUM(servers.memory), 0) as sum_memory')
->selectRaw('IFNULL(SUM(servers.disk), 0) as sum_disk')
->leftJoin('servers', 'servers.node_id', '=', 'nodes.id')
->where('nodes.public', 1);
foreach ($nodes as $node) {
$memoryLimit = $node->memory * (1 + ($node->memory_overallocate / 100));
$diskLimit = $node->disk * (1 + ($node->disk_overallocate / 100));
if (($node->sum_memory + $this->memory) > $memoryLimit || ($node->sum_disk + $this->disk) > $diskLimit) {
continue;
}
$viable[] = $node->id;
if (! empty($this->locations)) {
$query = $query->whereIn('nodes.location_id', $this->locations);
}
if (empty($viable)) {
$results = $query->groupBy('nodes.id')
->havingRaw('(IFNULL(SUM(servers.memory), 0) + ?) <= (nodes.memory * (1 + (nodes.memory_overallocate / 100)))', [ $this->memory ])
->havingRaw('(IFNULL(SUM(servers.disk), 0) + ?) <= (nodes.disk * (1 + (nodes.disk_overallocate / 100)))', [ $this->disk ])
->get()
->toBase();
if ($results->isEmpty()) {
throw new NoViableNodeException(trans('exceptions.deployment.no_viable_nodes'));
}
return $viable;
return $results;
}
}

View file

@ -1,11 +1,4 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Services\Eggs\Scripts;
@ -40,12 +33,8 @@ class InstallScriptService
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Pterodactyl\Exceptions\Service\Egg\InvalidCopyFromException
*/
public function handle($egg, array $data)
public function handle(Egg $egg, array $data)
{
if (! $egg instanceof Egg) {
$egg = $this->repository->find($egg);
}
if (! is_null(array_get($data, 'copy_script_from'))) {
if (! $this->repository->isCopyableScript(array_get($data, 'copy_script_from'), $egg->nest_id)) {
throw new InvalidCopyFromException(trans('exceptions.nest.egg.invalid_copy_id'));

View file

@ -1,11 +1,4 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Services\Eggs\Sharing;

View file

@ -1,11 +1,4 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Services\Eggs\Sharing;

View file

@ -2,6 +2,7 @@
namespace Pterodactyl\Services\Eggs\Sharing;
use Pterodactyl\Models\Egg;
use Illuminate\Http\UploadedFile;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Contracts\Repository\EggRepositoryInterface;
@ -46,7 +47,7 @@ class EggUpdateImporterService
/**
* Update an existing Egg using an uploaded JSON file.
*
* @param int $egg
* @param \Pterodactyl\Models\Egg $egg
* @param \Illuminate\Http\UploadedFile $file
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
@ -54,7 +55,7 @@ class EggUpdateImporterService
* @throws \Pterodactyl\Exceptions\Service\Egg\BadJsonFormatException
* @throws \Pterodactyl\Exceptions\Service\InvalidFileUploadException
*/
public function handle(int $egg, UploadedFile $file)
public function handle(Egg $egg, UploadedFile $file)
{
if ($file->getError() !== UPLOAD_ERR_OK || ! $file->isFile()) {
throw new InvalidFileUploadException(
@ -81,7 +82,7 @@ class EggUpdateImporterService
}
$this->connection->beginTransaction();
$this->repository->update($egg, [
$this->repository->update($egg->id, [
'author' => object_get($parsed, 'author'),
'name' => object_get($parsed, 'name'),
'description' => object_get($parsed, 'description'),
@ -99,19 +100,19 @@ class EggUpdateImporterService
// Update Existing Variables
collect($parsed->variables)->each(function ($variable) use ($egg) {
$this->variableRepository->withoutFreshModel()->updateOrCreate([
'egg_id' => $egg,
'egg_id' => $egg->id,
'env_variable' => $variable->env_variable,
], collect($variable)->except(['egg_id', 'env_variable'])->toArray());
});
$imported = collect($parsed->variables)->pluck('env_variable')->toArray();
$existing = $this->variableRepository->setColumns(['id', 'env_variable'])->findWhere([['egg_id', '=', $egg]]);
$existing = $this->variableRepository->setColumns(['id', 'env_variable'])->findWhere([['egg_id', '=', $egg->id]]);
// Delete variables not present in the import.
collect($existing)->each(function ($variable) use ($egg, $imported) {
if (! in_array($variable->env_variable, $imported)) {
$this->variableRepository->deleteWhere([
['egg_id', '=', $egg],
['egg_id', '=', $egg->id],
['env_variable', '=', $variable->env_variable],
]);
}

View file

@ -1,40 +0,0 @@
<?php
namespace Pterodactyl\Services\Mounts;
use Ramsey\Uuid\Uuid;
use Pterodactyl\Repositories\Eloquent\MountRepository;
class MountCreationService
{
/**
* @var \Pterodactyl\Repositories\Eloquent\MountRepository
*/
protected $repository;
/**
* MountCreationService constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\MountRepository $repository
*/
public function __construct(MountRepository $repository)
{
$this->repository = $repository;
}
/**
* Create a new mount.
*
* @param array $data
* @return \Pterodactyl\Models\Mount
*
* @throws \Exception
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
*/
public function handle(array $data)
{
return $this->repository->create(array_merge($data, [
'uuid' => Uuid::uuid4()->toString(),
]), true, true);
}
}

View file

@ -1,40 +0,0 @@
<?php
namespace Pterodactyl\Services\Mounts;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Mount;
use Pterodactyl\Repositories\Eloquent\MountRepository;
class MountDeletionService
{
/**
* @var \Pterodactyl\Repositories\Eloquent\MountRepository
*/
protected $repository;
/**
* MountDeletionService constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\MountRepository $repository
*/
public function __construct(MountRepository $repository)
{
$this->repository = $repository;
}
/**
* Delete an existing location.
*
* @param int|\Pterodactyl\Models\Mount $mount
* @return int|null
*/
public function handle($mount)
{
$mount = ($mount instanceof Mount) ? $mount->id : $mount;
Assert::integerish($mount, 'First argument passed to handle must be numeric or an instance of ' . Mount::class . ', received %s.');
return $this->repository->delete($mount);
}
}

View file

@ -1,41 +0,0 @@
<?php
namespace Pterodactyl\Services\Mounts;
use Pterodactyl\Models\Mount;
use Pterodactyl\Repositories\Eloquent\MountRepository;
class MountUpdateService
{
/**
* @var \Pterodactyl\Repositories\Eloquent\MountRepository
*/
protected $repository;
/**
* MountUpdateService constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\MountRepository $repository
*/
public function __construct(MountRepository $repository)
{
$this->repository = $repository;
}
/**
* Update an existing location.
*
* @param int|\Pterodactyl\Models\Mount $mount
* @param array $data
* @return \Pterodactyl\Models\Mount
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function handle($mount, array $data)
{
$mount = ($mount instanceof Mount) ? $mount->id : $mount;
return $this->repository->update($mount, $data);
}
}

View file

@ -73,7 +73,7 @@ class ProcessScheduleService
$this->taskRepository->update($task->id, ['is_queued' => true]);
$this->dispatcher->dispatch(
(new RunTaskJob($task->id, $schedule->id))->delay($task->time_offset)
(new RunTaskJob($task))->delay($task->time_offset)
);
}
}

View file

@ -4,8 +4,6 @@ namespace Pterodactyl\Services\Servers;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\EggVariable;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
class EnvironmentService
{
@ -14,28 +12,6 @@ class EnvironmentService
*/
private $additional = [];
/**
* @var \Illuminate\Contracts\Config\Repository
*/
private $config;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* EnvironmentService constructor.
*
* @param \Illuminate\Contracts\Config\Repository $config
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
*/
public function __construct(ConfigRepository $config, ServerRepositoryInterface $repository)
{
$this->config = $config;
$this->repository = $repository;
}
/**
* Dynamically configure additional environment variables to be assigned
* with a specific server.
@ -79,7 +55,7 @@ class EnvironmentService
}
// Process variables set in the configuration file.
foreach ($this->config->get('pterodactyl.environment_variables', []) as $key => $object) {
foreach (config('pterodactyl.environment_variables', []) as $key => $object) {
$variables->put(
$key, is_callable($object) ? call_user_func($object, $server) : object_get($server, $object)
);

View file

@ -19,26 +19,18 @@ class ReinstallServerService
*/
private $connection;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $repository;
/**
* ReinstallService constructor.
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Repositories\Wings\DaemonServerRepository $daemonServerRepository
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
*/
public function __construct(
ConnectionInterface $connection,
DaemonServerRepository $daemonServerRepository,
ServerRepository $repository
DaemonServerRepository $daemonServerRepository
) {
$this->daemonServerRepository = $daemonServerRepository;
$this->connection = $connection;
$this->repository = $repository;
}
/**
@ -49,16 +41,14 @@ class ReinstallServerService
*
* @throws \Throwable
*/
public function reinstall(Server $server)
public function handle(Server $server)
{
return $this->connection->transaction(function () use ($server) {
$updated = $this->repository->update($server->id, [
'installed' => Server::STATUS_INSTALLING,
], true, true);
$server->forceFill(['installed' => Server::STATUS_INSTALLING])->save();
$this->daemonServerRepository->setServer($server)->reinstall();
return $updated;
return $server->refresh();
});
}
}

View file

@ -1,17 +1,9 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Services\Servers;
use Pterodactyl\Models\Mount;
use Pterodactyl\Models\Server;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
class ServerConfigurationStructureService
{
@ -22,22 +14,13 @@ class ServerConfigurationStructureService
*/
private $environment;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* ServerConfigurationStructureService constructor.
*
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
* @param \Pterodactyl\Services\Servers\EnvironmentService $environment
*/
public function __construct(
ServerRepositoryInterface $repository,
EnvironmentService $environment
) {
$this->repository = $repository;
public function __construct(EnvironmentService $environment)
{
$this->environment = $environment;
}
@ -50,8 +33,6 @@ class ServerConfigurationStructureService
* @param \Pterodactyl\Models\Server $server
* @param bool $legacy
* @return array
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function handle(Server $server, bool $legacy = false): array
{
@ -72,7 +53,7 @@ class ServerConfigurationStructureService
{
return [
'uuid' => $server->uuid,
'suspended' => (bool) $server->suspended,
'suspended' => $server->suspended,
'environment' => $this->environment->handle($server),
'invocation' => $server->startup,
'skip_egg_scripts' => $server->skip_scripts,
@ -112,8 +93,6 @@ class ServerConfigurationStructureService
*
* @param \Pterodactyl\Models\Server $server
* @return array
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
protected function returnLegacyFormat(Server $server)
{

View file

@ -4,7 +4,9 @@ namespace Pterodactyl\Services\Servers;
use Ramsey\Uuid\Uuid;
use Illuminate\Support\Arr;
use Pterodactyl\Models\Egg;
use Pterodactyl\Models\User;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Illuminate\Support\Collection;
use Pterodactyl\Models\Allocation;
@ -13,7 +15,6 @@ use Pterodactyl\Models\Objects\DeploymentObject;
use Pterodactyl\Repositories\Eloquent\EggRepository;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Repositories\Eloquent\AllocationRepository;
use Pterodactyl\Services\Deployment\FindViableNodesService;
use Pterodactyl\Repositories\Eloquent\ServerVariableRepository;
use Pterodactyl\Services\Deployment\AllocationSelectionService;
@ -21,11 +22,6 @@ use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class ServerCreationService
{
/**
* @var \Pterodactyl\Repositories\Eloquent\AllocationRepository
*/
private $allocationRepository;
/**
* @var \Pterodactyl\Services\Deployment\AllocationSelectionService
*/
@ -79,7 +75,6 @@ class ServerCreationService
/**
* CreationService constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\AllocationRepository $allocationRepository
* @param \Pterodactyl\Services\Deployment\AllocationSelectionService $allocationSelectionService
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Repositories\Wings\DaemonServerRepository $daemonServerRepository
@ -92,7 +87,6 @@ class ServerCreationService
* @param \Pterodactyl\Services\Servers\VariableValidatorService $validatorService
*/
public function __construct(
AllocationRepository $allocationRepository,
AllocationSelectionService $allocationSelectionService,
ConnectionInterface $connection,
DaemonServerRepository $daemonServerRepository,
@ -105,7 +99,6 @@ class ServerCreationService
VariableValidatorService $validatorService
) {
$this->allocationSelectionService = $allocationSelectionService;
$this->allocationRepository = $allocationRepository;
$this->configurationStructureService = $configurationStructureService;
$this->connection = $connection;
$this->findViableNodesService = $findViableNodesService;
@ -130,15 +123,12 @@ class ServerCreationService
* @throws \Throwable
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableNodeException
* @throws \Pterodactyl\Exceptions\Service\Deployment\NoViableAllocationException
*/
public function handle(array $data, DeploymentObject $deployment = null): Server
{
$this->connection->beginTransaction();
// If a deployment object has been passed we need to get the allocation
// that the server should use, and assign the node from that allocation.
if ($deployment instanceof DeploymentObject) {
@ -149,37 +139,42 @@ class ServerCreationService
// Auto-configure the node based on the selected allocation
// if no node was defined.
if (is_null(Arr::get($data, 'node_id'))) {
$data['node_id'] = $this->getNodeFromAllocation($data['allocation_id']);
if (empty($data['node_id'])) {
Assert::false(empty($data['allocation_id']), 'Expected a non-empty allocation_id in server creation data.');
$data['node_id'] = Allocation::query()->findOrFail($data['allocation_id'])->node_id;
}
if (is_null(Arr::get($data, 'nest_id'))) {
/** @var \Pterodactyl\Models\Egg $egg */
$egg = $this->eggRepository->setColumns(['id', 'nest_id'])->find(Arr::get($data, 'egg_id'));
$data['nest_id'] = $egg->nest_id;
if (empty($data['nest_id'])) {
Assert::false(empty($data['egg_id']), 'Expected a non-empty egg_id in server creation data.');
$data['nest_id'] = Egg::query()->findOrFail($data['egg_id'])->nest_id;
}
$eggVariableData = $this->validatorService
->setUserLevel(User::USER_LEVEL_ADMIN)
->handle(Arr::get($data, 'egg_id'), Arr::get($data, 'environment', []));
// Create the server and assign any additional allocations to it.
$server = $this->createModel($data);
$this->storeAssignedAllocations($server, $data);
$this->storeEggVariables($server, $eggVariableData);
// Due to the design of the Daemon, we need to persist this server to the disk
// before we can actually create it on the Daemon.
//
// If that connection fails out we will attempt to perform a cleanup by just
// deleting the server itself from the system.
$this->connection->commit();
/** @var \Pterodactyl\Models\Server $server */
$server = $this->connection->transaction(function () use ($data, $eggVariableData) {
// Create the server and assign any additional allocations to it.
$server = $this->createModel($data);
$structure = $this->configurationStructureService->handle($server);
$this->storeAssignedAllocations($server, $data);
$this->storeEggVariables($server, $eggVariableData);
return $server;
});
try {
$this->daemonServerRepository->setServer($server)->create($structure);
$this->daemonServerRepository->setServer($server)->create(
$this->configurationStructureService->handle($server)
);
} catch (DaemonConnectionException $exception) {
$this->serverDeletionService->withForce(true)->handle($server);
@ -208,7 +203,7 @@ class ServerCreationService
->handle();
return $this->allocationSelectionService->setDedicated($deployment->isDedicated())
->setNodes($nodes)
->setNodes($nodes->pluck('id')->toArray())
->setPorts($deployment->getPorts())
->handle();
}
@ -269,7 +264,7 @@ class ServerCreationService
$records = array_merge($records, $data['allocation_additional']);
}
$this->allocationRepository->updateWhereIn('id', $records, [
Allocation::query()->whereIn('id', $records)->update([
'server_id' => $server->id,
]);
}
@ -295,22 +290,6 @@ class ServerCreationService
}
}
/**
* Get the node that an allocation belongs to.
*
* @param int $id
* @return int
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
private function getNodeFromAllocation(int $id): int
{
/** @var \Pterodactyl\Models\Allocation $allocation */
$allocation = $this->allocationRepository->setColumns(['id', 'node_id'])->find($id);
return $allocation->node_id;
}
/**
* Create a unique UUID and UUID-Short combo for a server.
*

View file

@ -3,11 +3,10 @@
namespace Pterodactyl\Services\Servers;
use Exception;
use Psr\Log\LoggerInterface;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Illuminate\Support\Facades\Log;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Repositories\Eloquent\DatabaseRepository;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Services\Databases\DatabaseManagementService;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
@ -29,50 +28,26 @@ class ServerDeletionService
*/
private $daemonServerRepository;
/**
* @var \Pterodactyl\Repositories\Eloquent\DatabaseRepository
*/
private $databaseRepository;
/**
* @var \Pterodactyl\Services\Databases\DatabaseManagementService
*/
private $databaseManagementService;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $repository;
/**
* @var \Psr\Log\LoggerInterface
*/
private $writer;
/**
* DeletionService constructor.
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Repositories\Wings\DaemonServerRepository $daemonServerRepository
* @param \Pterodactyl\Repositories\Eloquent\DatabaseRepository $databaseRepository
* @param \Pterodactyl\Services\Databases\DatabaseManagementService $databaseManagementService
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
* @param \Psr\Log\LoggerInterface $writer
*/
public function __construct(
ConnectionInterface $connection,
DaemonServerRepository $daemonServerRepository,
DatabaseRepository $databaseRepository,
DatabaseManagementService $databaseManagementService,
ServerRepository $repository,
LoggerInterface $writer
DatabaseManagementService $databaseManagementService
) {
$this->connection = $connection;
$this->daemonServerRepository = $daemonServerRepository;
$this->databaseRepository = $databaseRepository;
$this->databaseManagementService = $databaseManagementService;
$this->repository = $repository;
$this->writer = $writer;
}
/**
@ -101,27 +76,39 @@ class ServerDeletionService
try {
$this->daemonServerRepository->setServer($server)->delete();
} catch (DaemonConnectionException $exception) {
if ($this->force) {
$this->writer->warning($exception);
} else {
// If there is an error not caused a 404 error and this isn't a forced delete,
// go ahead and bail out. We specifically ignore a 404 since that can be assumed
// to be a safe error, meaning the server doesn't exist at all on Wings so there
// is no reason we need to bail out from that.
if (! $this->force && $exception->getStatusCode() !== Response::HTTP_NOT_FOUND) {
throw $exception;
}
Log::warning($exception);
}
$this->connection->transaction(function () use ($server) {
$this->databaseRepository->setColumns('id')->findWhere([['server_id', '=', $server->id]])->each(function ($item) {
foreach ($server->databases as $database) {
try {
$this->databaseManagementService->delete($item->id);
$this->databaseManagementService->delete($database);
} catch (Exception $exception) {
if ($this->force) {
$this->writer->warning($exception);
} else {
if (!$this->force) {
throw $exception;
}
}
});
$this->repository->delete($server->id);
// Oh well, just try to delete the database entry we have from the database
// so that the server itself can be deleted. This will leave it dangling on
// the host instance, but we couldn't delete it anyways so not sure how we would
// handle this better anyways.
//
// @see https://github.com/pterodactyl/panel/issues/2085
$database->delete();
Log::warning($exception);
}
}
$server->delete();
});
}
}

View file

@ -2,13 +2,13 @@
namespace Pterodactyl\Services\Servers;
use Illuminate\Support\Arr;
use Pterodactyl\Models\Egg;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\ServerVariable;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Traits\Services\HasUserLevels;
use Pterodactyl\Contracts\Repository\EggRepositoryInterface;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Pterodactyl\Contracts\Repository\ServerVariableRepositoryInterface;
class StartupModificationService
{
@ -19,63 +19,21 @@ class StartupModificationService
*/
private $connection;
/**
* @var \Pterodactyl\Contracts\Repository\EggRepositoryInterface
*/
private $eggRepository;
/**
* @var \Pterodactyl\Services\Servers\EnvironmentService
*/
private $environmentService;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* @var \Pterodactyl\Contracts\Repository\ServerVariableRepositoryInterface
*/
private $serverVariableRepository;
/**
* @var \Pterodactyl\Services\Servers\VariableValidatorService
*/
private $validatorService;
/**
* @var \Pterodactyl\Services\Servers\ServerConfigurationStructureService
*/
private $structureService;
/**
* StartupModificationService constructor.
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Contracts\Repository\EggRepositoryInterface $eggRepository
* @param \Pterodactyl\Services\Servers\EnvironmentService $environmentService
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
* @param \Pterodactyl\Services\Servers\ServerConfigurationStructureService $structureService
* @param \Pterodactyl\Contracts\Repository\ServerVariableRepositoryInterface $serverVariableRepository
* @param \Pterodactyl\Services\Servers\VariableValidatorService $validatorService
*/
public function __construct(
ConnectionInterface $connection,
EggRepositoryInterface $eggRepository,
EnvironmentService $environmentService,
ServerRepositoryInterface $repository,
ServerConfigurationStructureService $structureService,
ServerVariableRepositoryInterface $serverVariableRepository,
VariableValidatorService $validatorService
) {
public function __construct(ConnectionInterface $connection, VariableValidatorService $validatorService)
{
$this->connection = $connection;
$this->eggRepository = $eggRepository;
$this->environmentService = $environmentService;
$this->repository = $repository;
$this->serverVariableRepository = $serverVariableRepository;
$this->validatorService = $validatorService;
$this->structureService = $structureService;
}
/**
@ -85,34 +43,42 @@ class StartupModificationService
* @param array $data
* @return \Pterodactyl\Models\Server
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Throwable
*/
public function handle(Server $server, array $data): Server
{
$this->connection->beginTransaction();
if (! is_null(array_get($data, 'environment'))) {
$this->validatorService->setUserLevel($this->getUserLevel());
$results = $this->validatorService->handle(array_get($data, 'egg_id', $server->egg_id), array_get($data, 'environment', []));
return $this->connection->transaction(function () use ($server, $data) {
if (! empty($data['environment'])) {
$egg = $this->isUserLevel(User::USER_LEVEL_ADMIN) ? ($data['egg_id'] ?? $server->egg_id) : $server->egg_id;
$results->each(function ($result) use ($server) {
$this->serverVariableRepository->withoutFreshModel()->updateOrCreate([
'server_id' => $server->id,
'variable_id' => $result->id,
], [
'variable_value' => $result->value ?? '',
]);
});
}
$results = $this->validatorService
->setUserLevel($this->getUserLevel())
->handle($egg, $data['environment']);
if ($this->isUserLevel(User::USER_LEVEL_ADMIN)) {
$this->updateAdministrativeSettings($data, $server);
}
foreach ($results as $result) {
ServerVariable::query()->updateOrCreate(
[
'server_id' => $server->id,
'variable_id' => $result->id,
],
['variable_value' => $result->value ?? '']
);
}
}
$this->connection->commit();
if ($this->isUserLevel(User::USER_LEVEL_ADMIN)) {
$this->updateAdministrativeSettings($data, $server);
}
return $server;
// Calling ->refresh() rather than ->fresh() here causes it to return the
// variables as triplicates for some reason? Not entirely sure, should dig
// in more to figure it out, but luckily we have a test case covering this
// specific call so we can be assured we're not breaking it _here_ at least.
//
// TODO(dane): this seems like a red-flag for the code powering the relationship
// that should be looked into more.
return $server->fresh();
});
}
/**
@ -120,28 +86,25 @@ class StartupModificationService
*
* @param array $data
* @param \Pterodactyl\Models\Server $server
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
private function updateAdministrativeSettings(array $data, Server &$server)
protected function updateAdministrativeSettings(array $data, Server &$server)
{
if (
is_digit(array_get($data, 'egg_id'))
&& $data['egg_id'] != $server->egg_id
&& is_null(array_get($data, 'nest_id'))
) {
$egg = $this->eggRepository->setColumns(['id', 'nest_id'])->find($data['egg_id']);
$data['nest_id'] = $egg->nest_id;
$eggId = Arr::get($data, 'egg_id');
if (is_digit($eggId) && $server->egg_id !== (int)$eggId) {
/** @var \Pterodactyl\Models\Egg $egg */
$egg = Egg::query()->findOrFail($data['egg_id']);
$server = $server->forceFill([
'egg_id' => $egg->id,
'nest_id' => $egg->nest_id,
]);
}
$server = $this->repository->update($server->id, [
'installed' => 0,
'startup' => array_get($data, 'startup', $server->startup),
'nest_id' => array_get($data, 'nest_id', $server->nest_id),
'egg_id' => array_get($data, 'egg_id', $server->egg_id),
'skip_scripts' => array_get($data, 'skip_scripts') ?? isset($data['skip_scripts']),
'image' => array_get($data, 'docker_image', $server->image),
]);
$server->fill([
'startup' => $data['startup'] ?? $server->startup,
'skip_scripts' => $data['skip_scripts'] ?? isset($data['skip_scripts']),
'image' => $data['docker_image'] ?? $server->image,
])->save();
}
}

View file

@ -2,12 +2,10 @@
namespace Pterodactyl\Services\Servers;
use Psr\Log\LoggerInterface;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
class SuspensionService
{
@ -19,16 +17,6 @@ class SuspensionService
*/
private $connection;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* @var \Psr\Log\LoggerInterface
*/
private $writer;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonServerRepository
*/
@ -39,25 +27,19 @@ class SuspensionService
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Repositories\Wings\DaemonServerRepository $daemonServerRepository
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
* @param \Psr\Log\LoggerInterface $writer
*/
public function __construct(
ConnectionInterface $connection,
DaemonServerRepository $daemonServerRepository,
ServerRepositoryInterface $repository,
LoggerInterface $writer
DaemonServerRepository $daemonServerRepository
) {
$this->connection = $connection;
$this->repository = $repository;
$this->writer = $writer;
$this->daemonServerRepository = $daemonServerRepository;
}
/**
* Suspends a server on the system.
*
* @param int|\Pterodactyl\Models\Server $server
* @param \Pterodactyl\Models\Server $server
* @param string $action
*
* @throws \Throwable
@ -66,15 +48,16 @@ class SuspensionService
{
Assert::oneOf($action, [self::ACTION_SUSPEND, self::ACTION_UNSUSPEND]);
if (
$action === self::ACTION_SUSPEND && $server->suspended ||
$action === self::ACTION_UNSUSPEND && ! $server->suspended
) {
$isSuspending = $action === self::ACTION_SUSPEND;
// Nothing needs to happen if we're suspending the server and it is already
// suspended in the database. Additionally, nothing needs to happen if the server
// is not suspended and we try to un-suspend the instance.
if ($isSuspending === $server->suspended) {
return;
}
$this->connection->transaction(function () use ($action, $server) {
$this->repository->withoutFreshModel()->update($server->id, [
$server->update([
'suspended' => $action === self::ACTION_SUSPEND,
]);

View file

@ -11,32 +11,15 @@ namespace Pterodactyl\Services\Servers;
use Pterodactyl\Models\User;
use Illuminate\Support\Collection;
use Pterodactyl\Models\EggVariable;
use Illuminate\Validation\ValidationException;
use Pterodactyl\Traits\Services\HasUserLevels;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Illuminate\Contracts\Validation\Factory as ValidationFactory;
use Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface;
use Pterodactyl\Contracts\Repository\ServerVariableRepositoryInterface;
class VariableValidatorService
{
use HasUserLevels;
/**
* @var \Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface
*/
private $optionVariableRepository;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $serverRepository;
/**
* @var \Pterodactyl\Contracts\Repository\ServerVariableRepositoryInterface
*/
private $serverVariableRepository;
/**
* @var \Illuminate\Contracts\Validation\Factory
*/
@ -45,20 +28,10 @@ class VariableValidatorService
/**
* VariableValidatorService constructor.
*
* @param \Pterodactyl\Contracts\Repository\EggVariableRepositoryInterface $optionVariableRepository
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $serverRepository
* @param \Pterodactyl\Contracts\Repository\ServerVariableRepositoryInterface $serverVariableRepository
* @param \Illuminate\Contracts\Validation\Factory $validator
*/
public function __construct(
EggVariableRepositoryInterface $optionVariableRepository,
ServerRepositoryInterface $serverRepository,
ServerVariableRepositoryInterface $serverVariableRepository,
ValidationFactory $validator
) {
$this->optionVariableRepository = $optionVariableRepository;
$this->serverRepository = $serverRepository;
$this->serverVariableRepository = $serverVariableRepository;
public function __construct(ValidationFactory $validator)
{
$this->validator = $validator;
}
@ -72,16 +45,18 @@ class VariableValidatorService
*/
public function handle(int $egg, array $fields = []): Collection
{
$variables = $this->optionVariableRepository->findWhere([['egg_id', '=', $egg]]);
$query = EggVariable::query()->where('egg_id', $egg);
if (! $this->isUserLevel(User::USER_LEVEL_ADMIN)) {
// Don't attempt to validate variables if they aren't user editable
// and we're not running this at an admin level.
$query = $query->where('user_editable', true)->where('user_viewable', true);
}
/** @var \Pterodactyl\Models\EggVariable[] $variables */
$variables = $query->get();
$data = $rules = $customAttributes = [];
foreach ($variables as $variable) {
// Don't attempt to validate variables if they aren't user editable
// and we're not running this at an admin level.
if (! $variable->user_editable && ! $this->isUserLevel(User::USER_LEVEL_ADMIN)) {
continue;
}
$data['environment'][$variable->env_variable] = array_get($fields, $variable->env_variable);
$rules['environment.' . $variable->env_variable] = $variable->rules;
$customAttributes['environment.' . $variable->env_variable] = trans('validation.internal.variable_value', ['env' => $variable->name]);
@ -92,23 +67,12 @@ class VariableValidatorService
throw new ValidationException($validator);
}
$response = $variables->filter(function ($item) {
// Skip doing anything if user is not an admin and variable is not user viewable or editable.
if (! $this->isUserLevel(User::USER_LEVEL_ADMIN) && (! $item->user_editable || ! $item->user_viewable)) {
return false;
}
return true;
})->map(function ($item) use ($fields) {
return (object) [
return Collection::make($variables)->map(function ($item) use ($fields) {
return (object)[
'id' => $item->id,
'key' => $item->env_variable,
'value' => array_get($fields, $item->env_variable),
'value' => $fields[$item->env_variable] ?? null,
];
})->filter(function ($item) {
return is_object($item);
});
return $response;
}
}

View file

@ -67,7 +67,7 @@ class ServerTransformer extends BaseClientTransformer
'allocations' => $server->allocation_limit,
'backups' => $server->backup_limit,
],
'is_suspended' => $server->suspended !== 0,
'is_suspended' => $server->suspended,
'is_installing' => $server->installed !== 1,
];
}

View file

@ -9,7 +9,7 @@ return [
| change this value if you are not maintaining your own internal versions.
*/
'version' => 'canary',
'version' => '1.0.1',
/*
|--------------------------------------------------------------------------

View file

@ -13,7 +13,7 @@ return [
*/
'rate_limit' => [
'client_period' => 1,
'client' => env('APP_API_CLIENT_RATELIMIT', 240),
'client' => env('APP_API_CLIENT_RATELIMIT', 720),
'application_period' => 1,
'application' => env('APP_API_APPLICATION_RATELIMIT', 240),

View file

@ -1,5 +1,6 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
return [
@ -75,5 +76,10 @@ return [
'driver' => 'errorlog',
'level' => 'debug',
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
],
];

View file

@ -1,5 +1,6 @@
<?php
use Carbon\Carbon;
use Ramsey\Uuid\Uuid;
use Cake\Chronos\Chronos;
use Illuminate\Support\Str;
@ -7,6 +8,7 @@ use Pterodactyl\Models\Node;
use Faker\Generator as Faker;
use Pterodactyl\Models\ApiKey;
/** @var \Illuminate\Database\Eloquent\Factory $factory */
/*
|--------------------------------------------------------------------------
| Model Factories
@ -35,8 +37,8 @@ $factory->define(Pterodactyl\Models\Server::class, function (Faker $faker) {
'installed' => 1,
'database_limit' => null,
'allocation_limit' => null,
'created_at' => \Carbon\Carbon::now(),
'updated_at' => \Carbon\Carbon::now(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
];
});
@ -160,8 +162,8 @@ $factory->define(Pterodactyl\Models\Database::class, function (Faker $faker) {
'username' => str_random(10),
'remote' => '%',
'password' => $password ?: bcrypt('test123'),
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
];
});
@ -190,7 +192,7 @@ $factory->define(Pterodactyl\Models\ApiKey::class, function (Faker $faker) {
'token' => $token ?: $token = encrypt(str_random(Pterodactyl\Models\ApiKey::KEY_LENGTH)),
'allowed_ips' => null,
'memo' => 'Test Function Key',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
];
});

View file

@ -1,12 +1,64 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Collection;
use Pterodactyl\Models\Permission;
use Illuminate\Support\Facades\Schema;
use Pterodactyl\Models\Permission as P;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class MergePermissionsTableIntoSubusers extends Migration
{
/**
* A list of all pre-1.0 permissions available to a user and their associated
* casting for the new permissions system.
*
* @var array
*/
protected static $permissionsMap = [
'power-start' => P::ACTION_CONTROL_START,
'power-stop' => P::ACTION_CONTROL_STOP,
'power-restart' => P::ACTION_CONTROL_RESTART,
'power-kill' => P::ACTION_CONTROL_STOP,
'send-command' => P::ACTION_CONTROL_CONSOLE,
'list-subusers' => P::ACTION_USER_READ,
'view-subuser' => P::ACTION_USER_READ,
'edit-subuser' => P::ACTION_USER_UPDATE,
'create-subuser' => P::ACTION_USER_CREATE,
'delete-subuser' => P::ACTION_USER_DELETE,
'view-allocations' => P::ACTION_ALLOCATION_READ,
'edit-allocation' => P::ACTION_ALLOCATION_UPDATE,
'view-startup' => P::ACTION_STARTUP_READ,
'edit-startup' => P::ACTION_STARTUP_UPDATE,
'view-databases' => P::ACTION_DATABASE_READ,
// Better to just break this flow a bit than accidentally grant a dangerous permission.
'reset-db-password' => P::ACTION_DATABASE_UPDATE,
'delete-database' => P::ACTION_DATABASE_DELETE,
'create-database' => P::ACTION_DATABASE_CREATE,
'access-sftp' => P::ACTION_FILE_SFTP,
'list-files' => P::ACTION_FILE_READ,
'edit-files' => P::ACTION_FILE_READ_CONTENT,
'save-files' => P::ACTION_FILE_UPDATE,
'create-files' => P::ACTION_FILE_CREATE,
'delete-files' => P::ACTION_FILE_DELETE,
'compress-files' => P::ACTION_FILE_ARCHIVE,
'list-schedules' => P::ACTION_SCHEDULE_READ,
'view-schedule' => P::ACTION_SCHEDULE_READ,
'edit-schedule' => P::ACTION_SCHEDULE_UPDATE,
'create-schedule' => P::ACTION_SCHEDULE_CREATE,
'delete-schedule' => P::ACTION_SCHEDULE_DELETE,
// Skipping these permissions as they are granted if you have more specific read/write permissions.
'move-files' => null,
'copy-files' => null,
'decompress-files' => null,
'upload-files' => null,
'download-files' => null,
// These permissions do not exist in 1.0
'toggle-schedule' => null,
'queue-schedule' => null,
];
/**
* Run the migrations.
*
@ -27,10 +79,19 @@ class MergePermissionsTableIntoSubusers extends Migration
DB::transaction(function () use (&$cursor) {
$cursor->each(function ($datum) {
DB::update('UPDATE subusers SET permissions = ? WHERE id = ?', [
json_encode(explode(',', $datum->permissions)),
$datum->subuser_id,
]);
$updated = Collection::make(explode(',', $datum->permissions))
->map(function ($value) {
return self::$permissionsMap[$value] ?? null;
})->filter(function ($value) {
return !is_null($value) && $value !== Permission::ACTION_WEBSOCKET_CONNECT;
})
// All subusers get this permission, so make sure it gets pushed into the array.
->merge([ Permission::ACTION_WEBSOCKET_CONNECT ])
->unique()
->values()
->toJson();
DB::update('UPDATE subusers SET permissions = ? WHERE id = ?', [$updated, $datum->subuser_id]);
});
});
}
@ -42,11 +103,15 @@ class MergePermissionsTableIntoSubusers extends Migration
*/
public function down()
{
$flipped = array_flip(self::$permissionsMap);
foreach (DB::select('SELECT id, permissions FROM subusers') as $datum) {
$values = [];
foreach (json_decode($datum->permissions, true) as $permission) {
$values[] = $datum->id;
$values[] = $permission;
if (!empty($v = $flipped[$permission])) {
$values[] = $datum->id;
$values[] = $v;
}
}
if (! empty($values)) {

View file

@ -13,6 +13,10 @@ class AddTableServerTransfers extends Migration
*/
public function up()
{
// Nuclear approach to whatever plugins are out there and not properly namespacing their own tables
// leading to constant support requests from people...
Schema::dropIfExists('server_transfers');
Schema::create('server_transfers', function (Blueprint $table) {
$table->increments('id');
$table->integer('server_id')->unsigned();

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ChangeUniqueDatabaseNameToAccountForServer extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('databases', function (Blueprint $table) {
$table->dropUnique(['database_host_id', 'database']);
});
Schema::table('databases', function (Blueprint $table) {
$table->unique(['database_host_id', 'server_id', 'database']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('databases', function (Blueprint $table) {
$table->dropUnique(['database_host_id', 'server_id', 'database']);
});
Schema::table('databases', function (Blueprint $table) {
$table->unique(['database_host_id', 'database']);
});
}
}

View file

@ -130,7 +130,7 @@ class EggSeeder extends Seeder
['nest_id', '=', $nest->id],
]);
$this->updateImporterService->handle($egg->id, $file);
$this->updateImporterService->handle($egg, $file);
$this->command->info('Updated ' . $decoded->name);
} catch (RecordNotFoundException $exception) {

View file

@ -153,6 +153,12 @@ function updateAdditionalAllocations() {
}
function initUserIdSelect(data) {
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
$('#pUserId').select2({
ajax: {
url: '/admin/users/accounts.json',
@ -176,28 +182,27 @@ function initUserIdSelect(data) {
data: data,
escapeMarkup: function (markup) { return markup; },
minimumInputLength: 2,
templateResult: function (data) {
if (data.loading) return data.text;
if (data.loading) return escapeHtml(data.text);
return '<div class="user-block"> \
<img class="img-circle img-bordered-xs" src="https://www.gravatar.com/avatar/' + data.md5 + '?s=120" alt="User Image"> \
<span class="username"> \
<a href="#">' + data.name_first + ' ' + data.name_last +'</a> \
</span> \
<span class="description"><strong>' + data.email + '</strong> - ' + data.username + '</span> \
</div>';
<img class="img-circle img-bordered-xs" src="https://www.gravatar.com/avatar/' + escapeHtml(data.md5) + '?s=120" alt="User Image"> \
<span class="username"> \
<a href="#">' + escapeHtml(data.name_first) + ' ' + escapeHtml(data.name_last) +'</a> \
</span> \
<span class="description"><strong>' + escapeHtml(data.email) + '</strong> - ' + escapeHtml(data.username) + '</span> \
</div>';
},
templateSelection: function (data) {
return '<div> \
<span> \
<img class="img-rounded img-bordered-xs" src="https://www.gravatar.com/avatar/' + data.md5 + '?s=120" style="height:28px;margin-top:-4px;" alt="User Image"> \
</span> \
<span style="padding-left:5px;"> \
' + data.name_first + ' ' + data.name_last + ' (<strong>' + data.email + '</strong>) \
</span> \
</div>';
<span> \
<img class="img-rounded img-bordered-xs" src="https://www.gravatar.com/avatar/' + escapeHtml(data.md5) + '?s=120" style="height:28px;margin-top:-4px;" alt="User Image"> \
</span> \
<span style="padding-left:5px;"> \
' + escapeHtml(data.name_first) + ' ' + escapeHtml(data.name_last) + ' (<strong>' + escapeHtml(data.email) + '</strong>) \
</span> \
</div>';
}
});
}

View file

@ -9,7 +9,6 @@ interface Response {
}
export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise<Response> => {
console.log('firing getServerStartup');
const { data } = await http.get(`/api/client/servers/${uuid}/startup`);
const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable);

View file

@ -94,7 +94,6 @@ export default () => {
}
<div css={tw`mt-6 text-center`}>
<Link
type={'button'}
to={'/auth/login'}
css={tw`text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700`}
>

View file

@ -14,26 +14,8 @@ import { httpErrorToHuman } from '@/api/http';
import { format } from 'date-fns';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import { breakpoint } from '@/theme';
import styled from 'styled-components/macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
const Container = styled.div`
${tw`flex flex-wrap my-10`};
& > div {
${tw`w-full`};
${breakpoint('md')`
width: calc(50% - 1rem);
`}
${breakpoint('xl')`
${tw`w-auto flex-1`};
`}
}
`;
export default () => {
const [ deleteIdentifier, setDeleteIdentifier ] = useState('');
const [ keys, setKeys ] = useState<ApiKey[]>([]);
@ -67,12 +49,12 @@ export default () => {
return (
<PageContentBlock title={'Account API'}>
<FlashMessageRender byKey={'account'} css={tw`mb-4`}/>
<Container>
<ContentBox title={'Create API Key'}>
<FlashMessageRender byKey={'account'}/>
<div css={tw`md:flex flex-no-wrap my-10`}>
<ContentBox title={'Create API Key'} css={tw`flex-none w-full md:w-1/2`}>
<CreateApiKeyForm onKeyCreated={key => setKeys(s => ([ ...s!, key ]))}/>
</ContentBox>
<ContentBox title={'API Keys'} css={tw`mt-8 md:mt-0 md:ml-8`}>
<ContentBox title={'API Keys'} css={tw`flex-1 overflow-hidden mt-8 md:mt-0 md:ml-8`}>
<SpinnerOverlay visible={loading}/>
<ConfirmationModal
visible={!!deleteIdentifier}
@ -99,14 +81,14 @@ export default () => {
css={[ tw`bg-neutral-600 flex items-center`, index > 0 && tw`mt-2` ]}
>
<FontAwesomeIcon icon={faKey} css={tw`text-neutral-300`}/>
<div css={tw`ml-4 flex-1`}>
<p css={tw`text-sm`}>{key.description}</p>
<div css={tw`ml-4 flex-1 overflow-hidden`}>
<p css={tw`text-sm break-words`}>{key.description}</p>
<p css={tw`text-2xs text-neutral-300 uppercase`}>
Last used:&nbsp;
{key.lastUsedAt ? format(key.lastUsedAt, 'MMM do, yyyy HH:mm') : 'Never'}
</p>
</div>
<p css={tw`text-sm ml-4`}>
<p css={tw`text-sm ml-4 hidden md:block`}>
<code css={tw`font-mono py-1 px-2 bg-neutral-900 rounded`}>
{key.identifier}
</code>
@ -124,7 +106,7 @@ export default () => {
))
}
</ContentBox>
</Container>
</div>
</PageContentBlock>
);
};

View file

@ -1,21 +1,43 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { memo, useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { Link } from 'react-router-dom';
import { Server } from '@/api/server/getServer';
import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage';
import getServerResourceUsage, { ServerPowerState, ServerStats } from '@/api/server/getServerResourceUsage';
import { bytesToHuman, megabytesToHuman } from '@/helpers';
import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox';
import Spinner from '@/components/elements/Spinner';
import styled from 'styled-components/macro';
import isEqual from 'react-fast-compare';
// Determines if the current value is in an alarm threshold so we can show it in red rather
// than the more faded default style.
const isAlarmState = (current: number, limit: number): boolean => {
const limitInBytes = limit * 1024 * 1024;
const isAlarmState = (current: number, limit: number): boolean => limit > 0 && (current / (limit * 1024 * 1024) >= 0.90);
return current / limitInBytes >= 0.90;
};
const Icon = memo(styled(FontAwesomeIcon)<{ $alarm: boolean }>`
${props => props.$alarm ? tw`text-red-400` : tw`text-neutral-500`};
`, isEqual);
const IconDescription = styled.p<{ $alarm: boolean }>`
${tw`text-sm ml-2`};
${props => props.$alarm ? tw`text-white` : tw`text-neutral-400`};
`;
const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | undefined }>`
${tw`grid grid-cols-12 gap-4 relative`};
& .status-bar {
${tw`w-2 bg-red-500 absolute right-0 z-20 rounded-full m-1 opacity-50 transition-all duration-150`};
height: calc(100% - 0.5rem);
${({ $status }) => (!$status || $status === 'offline') ? tw`bg-red-500` : ($status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`)};
}
&:hover .status-bar {
${tw`opacity-75`};
}
`;
export default ({ server, className }: { server: Server; className?: string }) => {
const interval = useRef<number>(null);
@ -54,29 +76,31 @@ export default ({ server, className }: { server: Server; className?: string }) =
const memorylimit = server.limits.memory !== 0 ? megabytesToHuman(server.limits.memory) : 'Unlimited';
return (
<GreyRowBox as={Link} to={`/server/${server.id}`} className={className}>
<div className={'icon'} css={tw`hidden md:block`}>
<FontAwesomeIcon icon={faServer}/>
</div>
<div css={tw`flex-1 md:ml-4`}>
<p css={tw`text-lg break-all`}>{server.name}</p>
{!!server.description &&
<p css={tw`text-sm text-neutral-300 break-all`}>{server.description}</p>
}
</div>
<div css={tw`w-48 overflow-hidden self-start hidden lg:block`}>
<div css={tw`flex ml-4 justify-end`}>
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
<p css={tw`text-sm text-neutral-400 ml-2`}>
{
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
<StatusIndicatorBox as={Link} to={`/server/${server.id}`} className={className} $status={stats?.status}>
<div css={tw`flex items-center col-span-12 sm:col-span-5 lg:col-span-6`}>
<div className={'icon'} css={tw`mr-4`}>
<FontAwesomeIcon icon={faServer}/>
</div>
<div>
<p css={tw`text-lg break-words`}>{server.name}</p>
{!!server.description &&
<p css={tw`text-sm text-neutral-300 break-words`}>{server.description}</p>
}
</div>
</div>
<div css={tw`w-1/3 sm:w-1/2 lg:w-1/3 flex items-baseline justify-center relative`}>
<div css={tw`hidden lg:col-span-2 lg:flex ml-4 justify-end h-full`}>
<FontAwesomeIcon icon={faEthernet} css={tw`text-neutral-500`}/>
<p css={tw`text-sm text-neutral-400 ml-2`}>
{
server.allocations.filter(alloc => alloc.isDefault).map(allocation => (
<React.Fragment key={allocation.ip + allocation.port.toString()}>
{allocation.alias || allocation.ip}:{allocation.port}
</React.Fragment>
))
}
</p>
</div>
<div css={tw`hidden col-span-7 lg:col-span-4 sm:flex items-baseline justify-center`}>
{!stats ?
!statsError ?
<Spinner size={'small'}/>
@ -96,80 +120,33 @@ export default ({ server, className }: { server: Server; className?: string }) =
:
<React.Fragment>
<div css={tw`flex-1 flex md:ml-4 sm:flex hidden justify-center`}>
<FontAwesomeIcon
icon={faMicrochip}
css={[
!alarms.cpu && tw`text-neutral-500`,
alarms.cpu && tw`text-red-400`,
]}
/>
<p
css={[
tw`text-sm ml-2`,
!alarms.cpu && tw`text-neutral-400`,
alarms.cpu && tw`text-white`,
]}
>
<Icon icon={faMicrochip} $alarm={alarms.cpu}/>
<IconDescription $alarm={alarms.cpu}>
{stats.cpuUsagePercent} %
</p>
</IconDescription>
</div>
<div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}>
<FontAwesomeIcon
icon={faMemory}
css={[
!alarms.memory && tw`text-neutral-500`,
alarms.memory && tw`text-red-400`,
]}
/>
<p
css={[
tw`text-sm ml-2`,
!alarms.memory && tw`text-neutral-400`,
alarms.memory && tw`text-white`,
]}
>
<Icon icon={faMemory} $alarm={alarms.memory}/>
<IconDescription $alarm={alarms.memory}>
{bytesToHuman(stats.memoryUsageInBytes)}
</p>
</IconDescription>
</div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {memorylimit}</p>
</div>
<div css={tw`flex-1 ml-4 sm:block hidden`}>
<div css={tw`flex justify-center`}>
<FontAwesomeIcon
icon={faHdd}
css={[
!alarms.disk && tw`text-neutral-500`,
alarms.disk && tw`text-red-400`,
]}
/>
<p
css={[
tw`text-sm ml-2`,
!alarms.disk && tw`text-neutral-400`,
alarms.disk && tw`text-white`,
]}
>
<Icon icon={faHdd} $alarm={alarms.disk}/>
<IconDescription $alarm={alarms.disk}>
{bytesToHuman(stats.diskUsageInBytes)}
</p>
</IconDescription>
</div>
<p css={tw`text-xs text-neutral-600 text-center mt-1`}>of {disklimit}</p>
</div>
<div css={tw`flex-1 flex justify-end sm:hidden`}>
<div css={tw`flex items-end text-right`}>
<div
css={[
tw`w-3 h-3 rounded-full`,
(!stats?.status || stats?.status === 'offline')
? tw`bg-red-500`
: (stats?.status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`),
]}
/>
</div>
</div>
</React.Fragment>
}
</div>
</GreyRowBox>
<div className={'status-bar'}/>
</StatusIndicatorBox>
);
};

View file

@ -69,6 +69,7 @@ export default ({ onDismissed, ...props }: RequiredModalProps) => {
{({ isSubmitting }) => (
<Modal
{...props}
top={false}
onDismissed={dismiss}
dismissable={!isSubmitting}
showSpinnerOverlay={loading || isSubmitting}

View file

@ -2,11 +2,11 @@ import styled from 'styled-components/macro';
import tw from 'twin.macro';
export default styled.div<{ $hoverable?: boolean }>`
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150`};
${tw`flex rounded no-underline text-neutral-200 items-center bg-neutral-700 p-4 border border-transparent transition-colors duration-150 overflow-hidden`};
${props => props.$hoverable !== false && tw`hover:border-neutral-500`};
& > div.icon {
& .icon {
${tw`rounded-full bg-neutral-500 p-3`};
}
`;

View file

@ -35,6 +35,8 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
margin-top: 20%;
${breakpoint('md')`margin-top: 10%`};
`};
margin-bottom: auto;
& > .close-icon {
${tw`absolute right-0 p-2 text-white cursor-pointer opacity-50 transition-all duration-150 ease-linear hover:opacity-100`};

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
import { ITerminalOptions, Terminal } from 'xterm';
import * as TerminalFit from 'xterm/lib/addons/fit/fit';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
@ -7,6 +7,8 @@ import styled from 'styled-components/macro';
import { usePermissions } from '@/plugins/usePermissions';
import tw from 'twin.macro';
import 'xterm/dist/xterm.css';
import useEventListener from '@/plugins/useEventListener';
import { debounce } from 'debounce';
const theme = {
background: 'transparent',
@ -51,8 +53,7 @@ const TerminalDiv = styled.div`
export default () => {
const TERMINAL_PRELUDE = '\u001b[1m\u001b[33mcontainer@pterodactyl~ \u001b[0m';
const [ terminalElement, setTerminalElement ] = useState<HTMLDivElement | null>(null);
const useRef = useCallback(node => setTerminalElement(node), []);
const ref = useRef<HTMLDivElement>(null);
const terminal = useMemo(() => new Terminal({ ...terminalProps }), []);
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ canSendCommands ] = usePermissions([ 'control.console' ]);
@ -79,8 +80,8 @@ export default () => {
};
useEffect(() => {
if (connected && terminalElement && !terminal.element) {
terminal.open(terminalElement);
if (connected && ref.current && !terminal.element) {
terminal.open(ref.current);
// @see https://github.com/xtermjs/xterm.js/issues/2265
// @see https://github.com/xtermjs/xterm.js/issues/2230
@ -97,7 +98,13 @@ export default () => {
return true;
});
}
}, [ terminal, connected, terminalElement ]);
}, [ terminal, connected ]);
const fit = debounce(() => {
TerminalFit.fit(terminal);
}, 100);
useEventListener('resize', () => fit());
useEffect(() => {
if (connected && instance) {
@ -134,7 +141,7 @@ export default () => {
maxHeight: '32rem',
}}
>
<TerminalDiv id={'terminal'} ref={useRef}/>
<TerminalDiv id={'terminal'} ref={ref}/>
</div>
{canSendCommands &&
<div css={tw`rounded-b bg-neutral-900 text-neutral-100 flex`}>

View file

@ -0,0 +1,55 @@
import React from 'react';
import tw from 'twin.macro';
import Can from '@/components/elements/Can';
import Button from '@/components/elements/Button';
import StopOrKillButton from '@/components/server/StopOrKillButton';
import { PowerAction } from '@/components/server/ServerConsole';
import { ServerContext } from '@/state/server';
const PowerControls = () => {
const status = ServerContext.useStoreState(state => state.status.value);
const instance = ServerContext.useStoreState(state => state.socket.instance);
const sendPowerCommand = (command: PowerAction) => {
instance && instance.send('set state', command);
};
return (
<div css={tw`shadow-md bg-neutral-700 rounded p-3 flex text-xs mt-4 justify-center`}>
<Can action={'control.start'}>
<Button
size={'xsmall'}
color={'green'}
isSecondary
css={tw`mr-2`}
disabled={status !== 'offline'}
onClick={e => {
e.preventDefault();
sendPowerCommand('start');
}}
>
Start
</Button>
</Can>
<Can action={'control.restart'}>
<Button
size={'xsmall'}
isSecondary
css={tw`mr-2`}
disabled={!status}
onClick={e => {
e.preventDefault();
sendPowerCommand('restart');
}}
>
Restart
</Button>
</Can>
<Can action={'control.stop'}>
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
</Can>
</div>
);
};
export default PowerControls;

View file

@ -1,130 +1,29 @@
import React, { lazy, useEffect, useState } from 'react';
import React, { lazy, memo } from 'react';
import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, megabytesToHuman } from '@/helpers';
import SuspenseSpinner from '@/components/elements/SuspenseSpinner';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import Can from '@/components/elements/Can';
import ContentContainer from '@/components/elements/ContentContainer';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import StopOrKillButton from '@/components/server/StopOrKillButton';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import ServerDetailsBlock from '@/components/server/ServerDetailsBlock';
import isEqual from 'react-fast-compare';
import PowerControls from '@/components/server/PowerControls';
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
const ChunkedConsole = lazy(() => import(/* webpackChunkName: "console" */'@/components/server/Console'));
const ChunkedStatGraphs = lazy(() => import(/* webpackChunkName: "graphs" */'@/components/server/StatGraphs'));
export default () => {
const [ memory, setMemory ] = useState(0);
const [ cpu, setCpu ] = useState(0);
const [ disk, setDisk ] = useState(0);
const name = ServerContext.useStoreState(state => state.server.data!.name);
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const ServerConsole = () => {
const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling);
const status = ServerContext.useStoreState(state => state.status.value);
const connected = ServerContext.useStoreState(state => state.socket.connected);
const instance = ServerContext.useStoreState(state => state.socket.instance);
const statsListener = (data: string) => {
let stats: any = {};
try {
stats = JSON.parse(data);
} catch (e) {
return;
}
setMemory(stats.memory_bytes);
setCpu(stats.cpu_absolute);
setDisk(stats.disk_bytes);
};
const sendPowerCommand = (command: PowerAction) => {
instance && instance.send('set state', command);
};
useEffect(() => {
if (!connected || !instance) {
return;
}
instance.addListener('stats', statsListener);
return () => {
instance.removeListener('stats', statsListener);
};
}, [ instance, connected ]);
const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited';
const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited';
return (
<ServerContentBlock title={'Console'} css={tw`flex flex-wrap`}>
<div css={tw`w-full md:w-1/4`}>
<TitledGreyBox css={tw`break-all`} title={name} icon={faServer}>
<p css={tw`text-xs uppercase`}>
<FontAwesomeIcon
icon={faCircle}
fixedWidth
css={[
tw`mr-1`,
status === 'offline' ? tw`text-red-500` : (status === 'running' ? tw`text-green-500` : tw`text-yellow-500`),
]}
/>
&nbsp;{!status ? 'Connecting...' : status}
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMicrochip} fixedWidth css={tw`mr-1`}/> {cpu.toFixed(2)}%
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMemory} fixedWidth css={tw`mr-1`}/> {bytesToHuman(memory)}
<span css={tw`text-neutral-500`}> / {memorylimit}</span>
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/>&nbsp;{bytesToHuman(disk)}
<span css={tw`text-neutral-500`}> / {disklimit}</span>
</p>
</TitledGreyBox>
<div css={tw`w-full lg:w-1/4`}>
<ServerDetailsBlock/>
{!isInstalling ?
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
<div css={tw`shadow-md bg-neutral-700 rounded p-3 flex text-xs mt-4 justify-center`}>
<Can action={'control.start'}>
<Button
size={'xsmall'}
color={'green'}
isSecondary
css={tw`mr-2`}
disabled={status !== 'offline'}
onClick={e => {
e.preventDefault();
sendPowerCommand('start');
}}
>
Start
</Button>
</Can>
<Can action={'control.restart'}>
<Button
size={'xsmall'}
isSecondary
css={tw`mr-2`}
disabled={!status}
onClick={e => {
e.preventDefault();
sendPowerCommand('restart');
}}
>
Restart
</Button>
</Can>
<Can action={'control.stop'}>
<StopOrKillButton onPress={action => sendPowerCommand(action)}/>
</Can>
</div>
<PowerControls/>
</Can>
:
<div css={tw`mt-4 rounded bg-yellow-500 p-3`}>
@ -137,7 +36,7 @@ export default () => {
</div>
}
</div>
<div css={tw`w-full md:flex-1 md:ml-4 mt-4 md:mt-0`}>
<div css={tw`w-full lg:w-3/4 mt-4 lg:mt-0 lg:pl-4`}>
<SuspenseSpinner>
<ChunkedConsole/>
<ChunkedStatGraphs/>
@ -146,3 +45,5 @@ export default () => {
</ServerContentBlock>
);
};
export default memo(ServerConsole, isEqual);

View file

@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import tw from 'twin.macro';
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { bytesToHuman, megabytesToHuman } from '@/helpers';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { ServerContext } from '@/state/server';
interface Stats {
memory: number;
cpu: number;
disk: number;
}
const ServerDetailsBlock = () => {
const [ stats, setStats ] = useState<Stats>({ memory: 0, cpu: 0, disk: 0 });
const status = ServerContext.useStoreState(state => state.status.value);
const connected = ServerContext.useStoreState(state => state.socket.connected);
const instance = ServerContext.useStoreState(state => state.socket.instance);
const statsListener = (data: string) => {
let stats: any = {};
try {
stats = JSON.parse(data);
} catch (e) {
return;
}
setStats({
memory: stats.memory_bytes,
cpu: stats.cpu_absolute,
disk: stats.disk_bytes,
});
};
useEffect(() => {
if (!connected || !instance) {
return;
}
instance.addListener('stats', statsListener);
instance.send('send stats');
return () => {
instance.removeListener('stats', statsListener);
};
}, [ instance, connected ]);
const name = ServerContext.useStoreState(state => state.server.data!.name);
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited';
const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited';
return (
<TitledGreyBox css={tw`break-words`} title={name} icon={faServer}>
<p css={tw`text-xs uppercase`}>
<FontAwesomeIcon
icon={faCircle}
fixedWidth
css={[
tw`mr-1`,
status === 'offline' ? tw`text-red-500` : (status === 'running' ? tw`text-green-500` : tw`text-yellow-500`),
]}
/>
&nbsp;{!status ? 'Connecting...' : status}
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMicrochip} fixedWidth css={tw`mr-1`}/> {stats.cpu.toFixed(2)}%
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faMemory} fixedWidth css={tw`mr-1`}/> {bytesToHuman(stats.memory)}
<span css={tw`text-neutral-500`}> / {memorylimit}</span>
</p>
<p css={tw`text-xs mt-2`}>
<FontAwesomeIcon icon={faHdd} fixedWidth css={tw`mr-1`}/>&nbsp;{bytesToHuman(stats.disk)}
<span css={tw`text-neutral-500`}> / {disklimit}</span>
</p>
</TitledGreyBox>
);
};
export default ServerDetailsBlock;

View file

@ -142,24 +142,28 @@ export default () => {
return (
<div css={tw`flex flex-wrap mt-4`}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`md:flex-1 w-full md:w-1/2 md:mr-2`}>
{status !== 'offline' ?
<canvas id={'memory_chart'} ref={memoryRef} aria-label={'Server Memory Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip} css={tw`md:flex-1 w-full md:w-1/2 md:ml-2 mt-4 md:mt-0`}>
{status !== 'offline' ?
<canvas id={'cpu_chart'} ref={cpuRef} aria-label={'Server CPU Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
<div css={tw`w-full sm:w-1/2`}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`mr-0 sm:mr-4`}>
{status !== 'offline' ?
<canvas id={'memory_chart'} ref={memoryRef} aria-label={'Server Memory Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
</div>
<div css={tw`w-full sm:w-1/2 mt-4 sm:mt-0`}>
<TitledGreyBox title={'CPU usage'} icon={faMicrochip} css={tw`ml-0 sm:ml-4`}>
{status !== 'offline' ?
<canvas id={'cpu_chart'} ref={cpuRef} aria-label={'Server CPU Usage Graph'} role={'img'}/>
:
<p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline.
</p>
}
</TitledGreyBox>
</div>
</div>
);
};

View file

@ -8,20 +8,27 @@ import Spinner from '@/components/elements/Spinner';
import tw from 'twin.macro';
export default () => {
const server = ServerContext.useStoreState(state => state.server.data);
const [ error, setError ] = useState(false);
let updatingToken = false;
const [ error, setError ] = useState<'connecting' | string>('');
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
const setServerStatus = ServerContext.useStoreActions(actions => actions.status.setServerStatus);
const { setInstance, setConnectionState } = ServerContext.useStoreActions(actions => actions.socket);
const updateToken = (uuid: string, socket: Websocket) => {
if (updatingToken) return;
updatingToken = true;
getWebsocketToken(uuid)
.then(data => socket.setToken(data.token, true))
.catch(error => console.error(error));
.catch(error => console.error(error))
.then(() => {
updatingToken = false;
});
};
useEffect(() => {
connected && setError(false);
connected && setError('');
}, [ connected ]);
useEffect(() => {
@ -33,7 +40,7 @@ export default () => {
useEffect(() => {
// If there is already an instance or there is no server, just exit out of this process
// since we don't need to make a new connection.
if (instance || !server) {
if (instance || !uuid) {
return;
}
@ -42,7 +49,7 @@ export default () => {
socket.on('auth success', () => setConnectionState(true));
socket.on('SOCKET_CLOSE', () => setConnectionState(false));
socket.on('SOCKET_ERROR', () => {
setError(true);
setError('connecting');
setConnectionState(false);
});
socket.on('status', (status) => setServerStatus(status));
@ -51,10 +58,20 @@ export default () => {
console.warn('Got error message from daemon socket:', message);
});
socket.on('token expiring', () => updateToken(server.uuid, socket));
socket.on('token expired', () => updateToken(server.uuid, socket));
socket.on('token expiring', () => updateToken(uuid, socket));
socket.on('token expired', () => updateToken(uuid, socket));
socket.on('jwt error', (error: string) => {
setConnectionState(false);
console.warn('JWT validation error from wings:', error);
getWebsocketToken(server.uuid)
if (error === 'jwt: exp claim is invalid') {
updateToken(uuid, socket);
} else {
setError('There was an error validating the credentials provided for the websocket. Please refresh the page.');
}
});
getWebsocketToken(uuid)
.then(data => {
// Connect and then set the authentication token.
socket.setToken(data.token).connect(data.socket);
@ -63,17 +80,25 @@ export default () => {
setInstance(socket);
})
.catch(error => console.error(error));
}, [ server ]);
}, [ uuid ]);
return (
error ?
<CSSTransition timeout={150} in appear classNames={'fade'}>
<div css={tw`bg-red-500 py-2`}>
<ContentContainer css={tw`flex items-center justify-center`}>
<Spinner size={'small'}/>
<p css={tw`ml-2 text-sm text-red-100`}>
We&apos;re having some trouble connecting to your server, please wait...
</p>
{error === 'connecting' ?
<>
<Spinner size={'small'}/>
<p css={tw`ml-2 text-sm text-red-100`}>
We&apos;re having some trouble connecting to your server, please wait...
</p>
</>
:
<p css={tw`ml-2 text-sm text-red-100`}>
{error}
</p>
}
</ContentContainer>
</div>
</CSSTransition>

View file

@ -40,31 +40,35 @@ export default ({ backup, className }: Props) => {
});
return (
<GreyRowBox css={tw`flex items-center`} className={className}>
<div css={tw`mr-4`}>
{backup.completedAt ?
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300 hidden md:block`}/>
:
<Spinner size={'small'}/>
}
</div>
<div css={tw`flex-1`}>
<p css={tw`text-sm mb-1`}>
{!backup.isSuccessful &&
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
Failed
</span>
<GreyRowBox css={tw`flex-wrap md:flex-no-wrap items-center`} className={className}>
<div css={tw`flex items-center truncate w-full md:flex-1`}>
<div css={tw`mr-4`}>
{backup.completedAt ?
<FontAwesomeIcon icon={faArchive} css={tw`text-neutral-300`}/>
:
<Spinner size={'small'}/>
}
{backup.name}
{(backup.completedAt && backup.isSuccessful) &&
<span css={tw`ml-3 text-neutral-300 text-xs font-thin hidden sm:inline`}>{bytesToHuman(backup.bytes)}</span>
}
</p>
<p css={tw`text-xs text-neutral-400 font-mono hidden md:block`}>
{backup.uuid}
</p>
</div>
<div css={tw`flex flex-col truncate`}>
<div css={tw`flex items-center text-sm mb-1`}>
{!backup.isSuccessful &&
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
Failed
</span>
}
<p css={tw`break-words truncate`}>
{backup.name}
</p>
{(backup.completedAt && backup.isSuccessful) &&
<span css={tw`ml-3 text-neutral-300 text-xs font-thin hidden sm:inline`}>{bytesToHuman(backup.bytes)}</span>
}
</div>
<p css={tw`mt-1 md:mt-0 text-xs text-neutral-400 font-mono truncate`}>
{backup.uuid}
</p>
</div>
</div>
<div css={tw`ml-8 text-center`}>
<div css={tw`flex-1 md:flex-none md:w-48 mt-4 md:mt-0 md:ml-8 md:text-center`}>
<p
title={format(backup.createdAt, 'ddd, MMMM do, yyyy HH:mm:ss')}
css={tw`text-sm`}
@ -74,7 +78,7 @@ export default ({ backup, className }: Props) => {
<p css={tw`text-2xs text-neutral-500 uppercase mt-1`}>Created</p>
</div>
<Can action={'backup.download'}>
<div css={tw`ml-6`} style={{ marginRight: '-0.5rem' }}>
<div css={tw`mt-4 md:mt-0 ml-6`} style={{ marginRight: '-0.5rem' }}>
{!backup.completedAt ?
<div css={tw`p-2 invisible`}>
<FontAwesomeIcon icon={faEllipsisH}/>

View file

@ -87,14 +87,14 @@ export default () => {
onSubmit={submit}
initialValues={{ name: '', ignored: '' }}
validationSchema={object().shape({
name: string().max(255),
name: string().max(191),
ignored: string(),
})}
>
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
</Formik>
}
<Button onClick={() => setVisible(true)}>
<Button css={tw`w-full sm:w-auto`} onClick={() => setVisible(true)}>
Create backup
</Button>
</>

View file

@ -32,6 +32,7 @@ export default () => {
const id = ServerContext.useStoreState(state => state.server.data!.id);
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const { addError, clearFlashes } = useFlash();
let fetchFileContent: null | (() => Promise<string>) = null;
@ -39,8 +40,9 @@ export default () => {
useEffect(() => {
if (action === 'new') return;
setLoading(true);
setError('');
setLoading(true);
setDirectory(hash.replace(/^#/, '').split('/').filter(v => !!v).slice(0, -1).join('/'));
getFileContents(uuid, hash.replace(/^#/, ''))
.then(setContent)
.catch(error => {
@ -116,7 +118,13 @@ export default () => {
fetchContent={value => {
fetchFileContent = value;
}}
onContentSaved={save}
onContentSaved={() => {
if (action !== 'edit') {
setModalVisible(true);
} else {
save();
}
}}
/>
</div>
<div css={tw`flex justify-end mt-4`}>

View file

@ -45,13 +45,15 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
return (
<div css={tw`flex items-center text-sm mb-4 text-neutral-500`}>
{(files && files.length > 0 && !params?.action) &&
<FileActionCheckbox
type={'checkbox'}
css={tw`mx-4`}
checked={selectedFilesLength === (files ? files.length : -1)}
onChange={onSelectAllClick}
/>
{(files && files.length > 0 && !params?.action) ?
<FileActionCheckbox
type={'checkbox'}
css={tw`mx-4`}
checked={selectedFilesLength === (files ? files.length : -1)}
onChange={onSelectAllClick}
/>
:
<div css={tw`w-12`}/>
}
/<span css={tw`px-1 text-neutral-300`}>home</span>/
<NavLink

View file

@ -38,13 +38,13 @@ const Clickable: React.FC<{ file: FileObject }> = memo(({ file, children }) => {
return (
(!canReadContents || (file.isFile && !file.isEditable())) ?
<div css={tw`flex flex-1 text-neutral-300 no-underline p-3 cursor-default`}>
<div css={tw`flex flex-1 text-neutral-300 no-underline p-3 cursor-default overflow-hidden truncate`}>
{children}
</div>
:
<NavLink
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`}
css={tw`flex flex-1 text-neutral-300 no-underline p-3`}
css={tw`flex flex-1 text-neutral-300 no-underline p-3 overflow-hidden truncate`}
onClick={onRowClick}
>
{children}
@ -69,7 +69,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => (
<FontAwesomeIcon icon={faFolder}/>
}
</div>
<div css={tw`flex-1`}>
<div css={tw`flex-1 truncate`}>
{file.name}
</div>
{file.isFile &&
@ -92,4 +92,11 @@ const FileObjectRow = ({ file }: { file: FileObject }) => (
</Row>
);
export default memo(FileObjectRow, isEqual);
export default memo(FileObjectRow, (prevProps, nextProps) => {
/* eslint-disable @typescript-eslint/no-unused-vars */
const { isArchiveType, isEditable, ...prevFile } = prevProps.file;
const { isArchiveType: nextIsArchiveType, isEditable: nextIsEditable, ...nextFile } = nextProps.file;
/* eslint-enable @typescript-eslint/no-unused-vars */
return isEqual(prevFile, nextFile);
});

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