Merge branch 'develop' into feature/cross-env

This commit is contained in:
Dane Everitt 2020-04-05 11:14:22 -07:00 committed by GitHub
commit 0a53e75fd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
149 changed files with 5712 additions and 2350 deletions

View file

@ -33,6 +33,7 @@ return PhpCsFixer\Config::create()
'new_with_braces' => false, 'new_with_braces' => false,
'no_alias_functions' => true, 'no_alias_functions' => true,
'no_multiline_whitespace_before_semicolons' => true, 'no_multiline_whitespace_before_semicolons' => true,
'no_superfluous_phpdoc_tags' => false,
'no_unreachable_default_argument_value' => true, 'no_unreachable_default_argument_value' => true,
'no_useless_return' => true, 'no_useless_return' => true,
'not_operator_with_successor_space' => true, 'not_operator_with_successor_space' => true,

View file

@ -0,0 +1,177 @@
<?php
namespace Pterodactyl\Http\Controllers\Admin\Servers;
use Illuminate\Http\Request;
use Pterodactyl\Models\Server;
use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Models\ServerTransfer;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Servers\TransferService;
use Pterodactyl\Services\Servers\SuspensionService;
use Pterodactyl\Repositories\Eloquent\NodeRepository;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Repositories\Eloquent\LocationRepository;
use Pterodactyl\Repositories\Wings\DaemonConfigurationRepository;
use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface;
class ServerTransferController extends Controller
{
/**
* @var \Prologue\Alerts\AlertsMessageBag
*/
private $alert;
/**
* @var \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface
*/
private $allocationRepository;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $repository;
/**
* @var \Pterodactyl\Repositories\Eloquent\LocationRepository
*/
private $locationRepository;
/**
* @var \Pterodactyl\Repositories\Eloquent\NodeRepository
*/
private $nodeRepository;
/**
* @var \Pterodactyl\Services\Servers\SuspensionService
*/
private $suspensionService;
/**
* @var \Pterodactyl\Services\Servers\TransferService
*/
private $transferService;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository
*/
private $daemonConfigurationRepository;
/**
* ServerTransferController constructor.
*
* @param \Prologue\Alerts\AlertsMessageBag $alert
* @param \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface $allocationRepository
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
* @param \Pterodactyl\Repositories\Eloquent\LocationRepository $locationRepository
* @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository
* @param \Pterodactyl\Services\Servers\SuspensionService $suspensionService
* @param \Pterodactyl\Services\Servers\TransferService $transferService
* @param \Pterodactyl\Repositories\Wings\DaemonConfigurationRepository $daemonConfigurationRepository
*/
public function __construct(
AlertsMessageBag $alert,
AllocationRepositoryInterface $allocationRepository,
ServerRepository $repository,
LocationRepository $locationRepository,
NodeRepository $nodeRepository,
SuspensionService $suspensionService,
TransferService $transferService,
DaemonConfigurationRepository $daemonConfigurationRepository
) {
$this->alert = $alert;
$this->allocationRepository = $allocationRepository;
$this->repository = $repository;
$this->locationRepository = $locationRepository;
$this->nodeRepository = $nodeRepository;
$this->suspensionService = $suspensionService;
$this->transferService = $transferService;
$this->daemonConfigurationRepository = $daemonConfigurationRepository;
}
/**
* Starts a transfer of a server to a new node.
*
* @param \Illuminate\Http\Request $request
* @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Throwable
*/
public function transfer(Request $request, Server $server)
{
$validatedData = $request->validate([
'node_id' => 'required|exists:nodes,id',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
'allocation_additional' => 'nullable',
]);
$node_id = $validatedData['node_id'];
$allocation_id = intval($validatedData['allocation_id']);
$additional_allocations = array_map('intval', $validatedData['allocation_additional'] ?? []);
// Check if the node is viable for the transfer.
$node = $this->nodeRepository->getNodeWithResourceUsage($node_id);
if ($node->isViable($server->memory, $server->disk)) {
// Check if the selected daemon is online.
$this->daemonConfigurationRepository->setNode($node)->getSystemInformation();
// Suspend the server and request an archive to be created.
$this->suspensionService->toggle($server, 'suspend');
// Create a new ServerTransfer entry.
$transfer = new ServerTransfer;
$transfer->server_id = $server->id;
$transfer->old_node = $server->node_id;
$transfer->new_node = $node_id;
$transfer->old_allocation = $server->allocation_id;
$transfer->new_allocation = $allocation_id;
$transfer->old_additional_allocations = json_encode($server->allocations->where('id', '!=', $server->allocation_id)->pluck('id'));
$transfer->new_additional_allocations = json_encode($additional_allocations);
$transfer->save();
// Add the allocations to the server so they cannot be automatically assigned while the transfer is in progress.
$this->assignAllocationsToServer($server, $node_id, $allocation_id, $additional_allocations);
// Request an archive from the server's current daemon. (this also checks if the daemon is online)
$this->transferService->requestArchive($server);
$this->alert->success(trans('admin/server.alerts.transfer_started'))->flash();
} else {
$this->alert->danger(trans('admin/server.alerts.transfer_not_viable'))->flash();
}
return redirect()->route('admin.servers.view.manage', $server->id);
}
/**
* Assigns the specified allocations to the specified server.
*
* @param Server $server
* @param int $node_id
* @param int $allocation_id
* @param array $additional_allocations
*/
private function assignAllocationsToServer(Server $server, int $node_id, int $allocation_id, array $additional_allocations)
{
$allocations = $additional_allocations;
array_push($allocations, $allocation_id);
$unassigned = $this->allocationRepository->getUnassignedAllocationIds($node_id);
$updateIds = [];
foreach ($allocations as $allocation) {
if (! in_array($allocation, $unassigned)) {
continue;
}
$updateIds[] = $allocation;
}
if (! empty($updateIds)) {
$this->allocationRepository->updateWhereIn('id', $updateIds, ['server_id' => $server->id]);
}
}
}

View file

@ -2,6 +2,7 @@
namespace Pterodactyl\Http\Controllers\Admin\Servers; namespace Pterodactyl\Http\Controllers\Admin\Servers;
use JavaScript;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Pterodactyl\Models\Nest; use Pterodactyl\Models\Nest;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
@ -9,8 +10,10 @@ use Illuminate\Contracts\View\Factory;
use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Repositories\Eloquent\NestRepository; use Pterodactyl\Repositories\Eloquent\NestRepository;
use Pterodactyl\Repositories\Eloquent\NodeRepository;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Traits\Controllers\JavascriptInjection; use Pterodactyl\Traits\Controllers\JavascriptInjection;
use Pterodactyl\Repositories\Eloquent\LocationRepository;
use Pterodactyl\Repositories\Eloquent\DatabaseHostRepository; use Pterodactyl\Repositories\Eloquent\DatabaseHostRepository;
class ServerViewController extends Controller class ServerViewController extends Controller
@ -37,17 +40,31 @@ class ServerViewController extends Controller
*/ */
private $nestRepository; private $nestRepository;
/**
* @var \Pterodactyl\Repositories\Eloquent\LocationRepository
*/
private $locationRepository;
/**
* @var \Pterodactyl\Repositories\Eloquent\NodeRepository
*/
private $nodeRepository;
/** /**
* ServerViewController constructor. * ServerViewController constructor.
* *
* @param \Pterodactyl\Repositories\Eloquent\DatabaseHostRepository $databaseHostRepository * @param \Pterodactyl\Repositories\Eloquent\DatabaseHostRepository $databaseHostRepository
* @param \Pterodactyl\Repositories\Eloquent\NestRepository $nestRepository * @param \Pterodactyl\Repositories\Eloquent\NestRepository $nestRepository
* @param \Pterodactyl\Repositories\Eloquent\LocationRepository $locationRepository
* @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository * @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
* @param \Illuminate\Contracts\View\Factory $view * @param \Illuminate\Contracts\View\Factory $view
*/ */
public function __construct( public function __construct(
DatabaseHostRepository $databaseHostRepository, DatabaseHostRepository $databaseHostRepository,
NestRepository $nestRepository, NestRepository $nestRepository,
LocationRepository $locationRepository,
NodeRepository $nodeRepository,
ServerRepository $repository, ServerRepository $repository,
Factory $view Factory $view
) { ) {
@ -55,6 +72,8 @@ class ServerViewController extends Controller
$this->databaseHostRepository = $databaseHostRepository; $this->databaseHostRepository = $databaseHostRepository;
$this->repository = $repository; $this->repository = $repository;
$this->nestRepository = $nestRepository; $this->nestRepository = $nestRepository;
$this->nodeRepository = $nodeRepository;
$this->locationRepository = $locationRepository;
} }
/** /**
@ -150,6 +169,7 @@ class ServerViewController extends Controller
* @return \Illuminate\Contracts\View\View * @return \Illuminate\Contracts\View\View
* *
* @throws \Pterodactyl\Exceptions\DisplayException * @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function manage(Request $request, Server $server) public function manage(Request $request, Server $server)
{ {
@ -159,7 +179,22 @@ class ServerViewController extends Controller
); );
} }
return $this->view->make('admin.servers.view.manage', compact('server')); // Check if the panel doesn't have at least 2 nodes configured.
$nodes = $this->nodeRepository->all();
$canTransfer = false;
if (count($nodes) >= 2) {
$canTransfer = true;
}
Javascript::put([
'nodeData' => $this->nodeRepository->getNodesForServerCreation(),
]);
return $this->view->make('admin.servers.view.manage', [
'server' => $server,
'locations' => $this->locationRepository->all(),
'canTransfer' => $canTransfer,
]);
} }
/** /**

View file

@ -262,7 +262,7 @@ class ServersController extends Controller
{ {
$this->buildModificationService->handle($server, $request->only([ $this->buildModificationService->handle($server, $request->only([
'allocation_id', 'add_allocations', 'remove_allocations', 'allocation_id', 'add_allocations', 'remove_allocations',
'memory', 'swap', 'io', 'cpu', 'disk', 'memory', 'swap', 'io', 'cpu', 'threads', 'disk',
'database_limit', 'allocation_limit', 'oom_disabled', 'database_limit', 'allocation_limit', 'oom_disabled',
])); ]));
$this->alert->success(trans('admin/server.alerts.build_updated'))->flash(); $this->alert->success(trans('admin/server.alerts.build_updated'))->flash();

View file

@ -3,7 +3,6 @@
namespace Pterodactyl\Http\Controllers\Api\Client; namespace Pterodactyl\Http\Controllers\Api\Client;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
use Illuminate\Support\Collection;
use Pterodactyl\Models\Permission; use Pterodactyl\Models\Permission;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Transformers\Api\Client\ServerTransformer; use Pterodactyl\Transformers\Api\Client\ServerTransformer;
@ -72,16 +71,10 @@ class ClientController extends ClientApiController
*/ */
public function permissions() public function permissions()
{ {
$permissions = Permission::permissions()->map(function ($values, $key) {
return Collection::make($values)->map(function ($permission) use ($key) {
return $key . '.' . $permission;
})->values()->toArray();
})->flatten();
return [ return [
'object' => 'system_permissions', 'object' => 'system_permissions',
'attributes' => [ 'attributes' => [
'permissions' => $permissions, 'permissions' => Permission::permissions(),
], ],
]; ];
} }

View file

@ -0,0 +1,77 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Pterodactyl\Models\Server;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\GetBackupsRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
class BackupController extends ClientApiController
{
/**
* @var \Pterodactyl\Services\Backups\InitiateBackupService
*/
private $initiateBackupService;
/**
* BackupController constructor.
*
* @param \Pterodactyl\Services\Backups\InitiateBackupService $initiateBackupService
*/
public function __construct(InitiateBackupService $initiateBackupService)
{
parent::__construct();
$this->initiateBackupService = $initiateBackupService;
}
/**
* Returns all of the backups for a given server instance in a paginated
* result set.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\GetBackupsRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*/
public function index(GetBackupsRequest $request, Server $server)
{
return $this->fractal->collection($server->backups()->paginate(20))
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
/**
* Starts the backup process for a server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*
* @throws \Exception
*/
public function store(StoreBackupRequest $request, Server $server)
{
$backup = $this->initiateBackupService
->setIgnoredFiles($request->input('ignored'))
->handle($server, $request->input('name'));
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
}
public function view()
{
}
public function update()
{
}
public function delete()
{
}
}

View file

@ -0,0 +1,80 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Lcobucci\JWT\Builder;
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
use Lcobucci\JWT\Signer\Key;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Illuminate\Http\RedirectResponse;
use Illuminate\Contracts\Routing\ResponseFactory;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest;
class DownloadBackupController extends ClientApiController
{
/**
* @var \Pterodactyl\Repositories\Wings\DaemonBackupRepository
*/
private $daemonBackupRepository;
/**
* @var \Illuminate\Contracts\Routing\ResponseFactory
*/
private $responseFactory;
/**
* DownloadBackupController constructor.
*
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
* @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory
*/
public function __construct(
DaemonBackupRepository $daemonBackupRepository,
ResponseFactory $responseFactory
) {
parent::__construct();
$this->daemonBackupRepository = $daemonBackupRepository;
$this->responseFactory = $responseFactory;
}
/**
* Download the backup for a given server instance. For daemon local files, the file
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
* which the user is redirected to.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest $request
* @param \Pterodactyl\Models\Server $server
* @param \Pterodactyl\Models\Backup $backup
* @return \Illuminate\Http\RedirectResponse
*/
public function __invoke(DownloadBackupRequest $request, Server $server, Backup $backup)
{
$signer = new Sha256;
$now = CarbonImmutable::now();
$token = (new Builder)->issuedBy(config('app.url'))
->permittedFor($server->node->getConnectionAddress())
->identifiedBy(hash('sha256', $request->user()->id . $server->uuid), true)
->issuedAt($now->getTimestamp())
->canOnlyBeUsedAfter($now->subMinutes(5)->getTimestamp())
->expiresAt($now->addMinutes(15)->getTimestamp())
->withClaim('unique_id', Str::random(16))
->withClaim('backup_uuid', $backup->uuid)
->withClaim('server_uuid', $server->uuid)
->getToken($signer, new Key($server->node->daemonSecret));
$location = sprintf(
'%s/download/backup?token=%s',
$server->node->getConnectionAddress(),
$token->__toString()
);
return RedirectResponse::create($location);
}
}

View file

@ -4,9 +4,12 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Services\Servers\ReinstallServerService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest;
class SettingsController extends ClientApiController class SettingsController extends ClientApiController
{ {
@ -15,16 +18,25 @@ class SettingsController extends ClientApiController
*/ */
private $repository; private $repository;
/**
* @var \Pterodactyl\Services\Servers\ReinstallServerService
*/
private $reinstallServerService;
/** /**
* SettingsController constructor. * SettingsController constructor.
* *
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository * @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
* @param \Pterodactyl\Services\Servers\ReinstallServerService $reinstallServerService
*/ */
public function __construct(ServerRepository $repository) public function __construct(
{ ServerRepository $repository,
ReinstallServerService $reinstallServerService
) {
parent::__construct(); parent::__construct();
$this->repository = $repository; $this->repository = $repository;
$this->reinstallServerService = $reinstallServerService;
} }
/** /**
@ -32,7 +44,7 @@ class SettingsController extends ClientApiController
* *
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Settings\RenameServerRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\Response * @return \Illuminate\Http\JsonResponse
* *
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
@ -43,6 +55,22 @@ class SettingsController extends ClientApiController
'name' => $request->input('name'), 'name' => $request->input('name'),
]); ]);
return Response::create('', Response::HTTP_NO_CONTENT); return JsonResponse::create([], Response::HTTP_NO_CONTENT);
}
/**
* Reinstalls the server on the daemon.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Settings\ReinstallServerRequest $request
* @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse
*
* @throws \Throwable
*/
public function reinstall(ReinstallServerRequest $request, Server $server)
{
$this->reinstallServerService->reinstall($server);
return JsonResponse::create([], Response::HTTP_ACCEPTED);
} }
} }

View file

@ -2,11 +2,17 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers; namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Request;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Repositories\Eloquent\SubuserRepository; use Pterodactyl\Repositories\Eloquent\SubuserRepository;
use Pterodactyl\Services\Subusers\SubuserCreationService;
use Pterodactyl\Transformers\Api\Client\SubuserTransformer; use Pterodactyl\Transformers\Api\Client\SubuserTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\StoreSubuserRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest;
class SubuserController extends ClientApiController class SubuserController extends ClientApiController
{ {
@ -15,16 +21,25 @@ class SubuserController extends ClientApiController
*/ */
private $repository; private $repository;
/**
* @var \Pterodactyl\Services\Subusers\SubuserCreationService
*/
private $creationService;
/** /**
* SubuserController constructor. * SubuserController constructor.
* *
* @param \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository * @param \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository
* @param \Pterodactyl\Services\Subusers\SubuserCreationService $creationService
*/ */
public function __construct(SubuserRepository $repository) public function __construct(
{ SubuserRepository $repository,
SubuserCreationService $creationService
) {
parent::__construct(); parent::__construct();
$this->repository = $repository; $this->repository = $repository;
$this->creationService = $creationService;
} }
/** /**
@ -36,10 +51,78 @@ class SubuserController extends ClientApiController
*/ */
public function index(GetSubuserRequest $request, Server $server) public function index(GetSubuserRequest $request, Server $server)
{ {
$server->subusers->load('user');
return $this->fractal->collection($server->subusers) return $this->fractal->collection($server->subusers)
->transformWith($this->getTransformer(SubuserTransformer::class)) ->transformWith($this->getTransformer(SubuserTransformer::class))
->toArray(); ->toArray();
} }
/**
* Create a new subuser for the given server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\StoreSubuserRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Service\Subuser\ServerSubuserExistsException
* @throws \Pterodactyl\Exceptions\Service\Subuser\UserIsServerOwnerException
* @throws \Throwable
*/
public function store(StoreSubuserRequest $request, Server $server)
{
$response = $this->creationService->handle(
$server, $request->input('email'), $this->getDefaultPermissions($request)
);
return $this->fractal->item($response)
->transformWith($this->getTransformer(SubuserTransformer::class))
->toArray();
}
/**
* Update a given subuser in the system for the server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function update(UpdateSubuserRequest $request, Server $server): array
{
$subuser = $request->endpointSubuser();
$this->repository->update($subuser->id, [
'permissions' => $this->getDefaultPermissions($request),
]);
return $this->fractal->item($subuser->refresh())
->transformWith($this->getTransformer(SubuserTransformer::class))
->toArray();
}
/**
* Removes a subusers from a server's assignment.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request
* @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse
*/
public function delete(DeleteSubuserRequest $request, Server $server)
{
$this->repository->delete($request->endpointSubuser()->id);
return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Returns the default permissions for all subusers to ensure none are ever removed wrongly.
*
* @param \Illuminate\Http\Request $request
* @return array
*/
protected function getDefaultPermissions(Request $request): array
{
return array_unique(array_merge($request->input('permissions') ?? [], ['websocket.*']));
}
} }

View file

@ -0,0 +1,70 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Remote\Servers;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
class ServerBackupController extends Controller
{
/**
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
*/
private $repository;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $serverRepository;
/**
* ServerBackupController constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $serverRepository
*/
public function __construct(BackupRepository $repository, ServerRepository $serverRepository)
{
$this->repository = $repository;
$this->serverRepository = $serverRepository;
}
/**
* Updates a server backup's state in the database depending on wether or not
* it was successful.
*
* @param \Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest $request
* @param string $uuid
* @param string $backup
* @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function __invoke(ReportBackupCompleteRequest $request, string $uuid, string $backup)
{
$server = $this->serverRepository->getByUuid($uuid);
$where = [
['uuid', '=', $backup],
['server_id', '=', $server->id],
];
if ($request->input('successful')) {
$this->repository->updateWhere($where, [
'sha256_hash' => $request->input('sha256_hash'),
'bytes' => $request->input('file_size'),
'completed_at' => Carbon::now(),
]);
} else {
$this->repository->deleteWhere($where);
}
return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT);
}
}

View file

@ -0,0 +1,238 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Remote\Servers;
use Cake\Chronos\Chronos;
use Lcobucci\JWT\Builder;
use Illuminate\Http\Request;
use Lcobucci\JWT\Signer\Key;
use Psr\Log\LoggerInterface;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Servers\SuspensionService;
use Pterodactyl\Repositories\Eloquent\NodeRepository;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Repositories\Wings\DaemonTransferRepository;
use Pterodactyl\Contracts\Repository\AllocationRepositoryInterface;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
use Pterodactyl\Services\Servers\ServerConfigurationStructureService;
class ServerTransferController extends Controller
{
/**
* @var \Illuminate\Database\ConnectionInterface
*/
private $connection;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerRepository
*/
private $repository;
/**
* @var \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface
*/
private $allocationRepository;
/**
* @var \Pterodactyl\Repositories\Eloquent\NodeRepository
*/
private $nodeRepository;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonServerRepository
*/
private $daemonServerRepository;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonTransferRepository
*/
private $daemonTransferRepository;
/**
* @var \Pterodactyl\Services\Servers\ServerConfigurationStructureService
*/
private $configurationStructureService;
/**
* @var \Pterodactyl\Services\Servers\SuspensionService
*/
private $suspensionService;
/**
* @var \Psr\Log\LoggerInterface
*/
private $writer;
/**
* ServerTransferController constructor.
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
* @param \Pterodactyl\Contracts\Repository\AllocationRepositoryInterface $allocationRepository
* @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository
* @param \Pterodactyl\Repositories\Wings\DaemonServerRepository $daemonServerRepository
* @param \Pterodactyl\Repositories\Wings\DaemonTransferRepository $daemonTransferRepository
* @param \Pterodactyl\Services\Servers\ServerConfigurationStructureService $configurationStructureService
* @param \Pterodactyl\Services\Servers\SuspensionService $suspensionService
* @param \Psr\Log\LoggerInterface $writer
*/
public function __construct(
ConnectionInterface $connection,
ServerRepository $repository,
AllocationRepositoryInterface $allocationRepository,
NodeRepository $nodeRepository,
DaemonServerRepository $daemonServerRepository,
DaemonTransferRepository $daemonTransferRepository,
ServerConfigurationStructureService $configurationStructureService,
SuspensionService $suspensionService,
LoggerInterface $writer
) {
$this->connection = $connection;
$this->repository = $repository;
$this->allocationRepository = $allocationRepository;
$this->nodeRepository = $nodeRepository;
$this->daemonServerRepository = $daemonServerRepository;
$this->daemonTransferRepository = $daemonTransferRepository;
$this->configurationStructureService = $configurationStructureService;
$this->suspensionService = $suspensionService;
$this->writer = $writer;
}
/**
* The daemon notifies us about the archive status.
*
* @param \Illuminate\Http\Request $request
* @param string $uuid
* @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Throwable
*/
public function archive(Request $request, string $uuid)
{
$server = $this->repository->getByUuid($uuid);
// Unsuspend the server and don't continue the transfer.
if (! $request->input('successful')) {
$this->suspensionService->toggle($server, 'unsuspend');
return JsonResponse::create([], Response::HTTP_NO_CONTENT);
}
$server->node_id = $server->transfer->new_node;
$data = $this->configurationStructureService->handle($server);
$data['suspended'] = false;
$data['service']['skip_scripts'] = true;
$allocations = $server->getAllocationMappings();
$data['allocations']['default']['ip'] = array_key_first($allocations);
$data['allocations']['default']['port'] = $allocations[$data['allocations']['default']['ip']][0];
$now = Chronos::now();
$signer = new Sha256;
$token = (new Builder)->issuedBy(config('app.url'))
->permittedFor($server->node->getConnectionAddress())
->identifiedBy(hash('sha256', $server->uuid), true)
->issuedAt($now->getTimestamp())
->canOnlyBeUsedAfter($now->getTimestamp())
->expiresAt($now->addMinutes(15)->getTimestamp())
->relatedTo($server->uuid, true)
->getToken($signer, new Key($server->node->daemonSecret));
// On the daemon transfer repository, make sure to set the node after the server
// because setServer() tells the repository to use the server's node and not the one
// we want to specify.
try {
$this->daemonTransferRepository
->setServer($server)
->setNode($this->nodeRepository->find($server->transfer->new_node))
->notify($server, $data, $server->node, $token->__toString());
} catch (DaemonConnectionException $exception) {
throw $exception;
}
return JsonResponse::create([], Response::HTTP_NO_CONTENT);
}
/**
* The daemon notifies us about a transfer failure.
*
* @param \Illuminate\Http\Request $request
* @param string $uuid
* @return \Illuminate\Http\JsonResponse
*
* @throws \Throwable
*/
public function failure(string $uuid)
{
$server = $this->repository->getByUuid($uuid);
$transfer = $server->transfer;
$allocationIds = json_decode($transfer->new_additional_allocations);
array_push($allocationIds, $transfer->new_allocation);
// Remove the new allocations.
$this->allocationRepository->updateWhereIn('id', $allocationIds, ['server_id' => null]);
// Unsuspend the server.
$this->suspensionService->toggle($server, 'unsuspend');
return JsonResponse::create([], Response::HTTP_NO_CONTENT);
}
/**
* The daemon notifies us about a transfer success.
*
* @param string $uuid
* @return \Illuminate\Http\JsonResponse
*
* @throws \Throwable
*/
public function success(string $uuid)
{
$server = $this->repository->getByUuid($uuid);
$transfer = $server->transfer;
$allocationIds = json_decode($transfer->old_additional_allocations);
array_push($allocationIds, $transfer->old_allocation);
// Begin a transaction.
$this->connection->beginTransaction();
// Remove the old allocations.
$this->allocationRepository->updateWhereIn('id', $allocationIds, ['server_id' => null]);
// Update the server's allocation_id and node_id.
$server->allocation_id = $transfer->new_allocation;
$server->node_id = $transfer->new_node;
$server->save();
// Mark the transfer as successful.
$transfer->successful = true;
$transfer->save();
// Commit the transaction.
$this->connection->commit();
// Delete the server from the old node
try {
$this->daemonServerRepository->setServer($server)->delete();
} catch (DaemonConnectionException $exception) {
$this->writer->warning($exception);
}
// Unsuspend the server
$server->load('node');
$this->suspensionService->toggle($server, $this->suspensionService::ACTION_UNSUSPEND);
return JsonResponse::create([], Response::HTTP_NO_CONTENT);
}
}

View file

@ -42,6 +42,16 @@ class AuthenticateServerAccess
throw new NotFoundHttpException(trans('exceptions.api.resource_not_found')); throw new NotFoundHttpException(trans('exceptions.api.resource_not_found'));
} }
// At the very least, ensure that the user trying to make this request is the
// server owner, a subuser, or a root admin. We'll leave it up to the controllers
// to authenticate more detailed permissions if needed.
if ($request->user()->id !== $server->owner_id && ! $request->user()->root_admin) {
// Check for subuser status.
if (! $server->subusers->contains('user_id', $request->user()->id)) {
throw new NotFoundHttpException(trans('exceptions.api.resource_not_found'));
}
}
if ($server->suspended) { if ($server->suspended) {
throw new AccessDeniedHttpException('Cannot access a server that is marked as being suspended.'); throw new AccessDeniedHttpException('Cannot access a server that is marked as being suspended.');
} }

View file

@ -3,6 +3,7 @@
namespace Pterodactyl\Http\Middleware\Api\Client; namespace Pterodactyl\Http\Middleware\Api\Client;
use Closure; use Closure;
use Pterodactyl\Models\Backup;
use Illuminate\Container\Container; use Illuminate\Container\Container;
use Pterodactyl\Contracts\Extensions\HashidsInterface; use Pterodactyl\Contracts\Extensions\HashidsInterface;
use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings; use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings;
@ -55,6 +56,10 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings
} }
}); });
$this->router->model('backup', Backup::class, function ($value) {
return Backup::query()->where('uuid', $value)->firstOrFail();
});
return parent::handle($request, $next); return parent::handle($request, $next);
} }
} }

View file

@ -49,6 +49,7 @@ class StoreServerRequest extends ApplicationApiRequest
'limits.swap' => $rules['swap'], 'limits.swap' => $rules['swap'],
'limits.disk' => $rules['disk'], 'limits.disk' => $rules['disk'],
'limits.io' => $rules['io'], 'limits.io' => $rules['io'],
'limits.threads' => $rules['threads'],
'limits.cpu' => $rules['cpu'], 'limits.cpu' => $rules['cpu'],
// Application Resource Limits // Application Resource Limits
@ -96,6 +97,7 @@ class StoreServerRequest extends ApplicationApiRequest
'disk' => array_get($data, 'limits.disk'), 'disk' => array_get($data, 'limits.disk'),
'io' => array_get($data, 'limits.io'), 'io' => array_get($data, 'limits.io'),
'cpu' => array_get($data, 'limits.cpu'), 'cpu' => array_get($data, 'limits.cpu'),
'threads' => array_get($data, 'limits.threads'),
'skip_scripts' => array_get($data, 'skip_scripts', false), 'skip_scripts' => array_get($data, 'skip_scripts', false),
'allocation_id' => array_get($data, 'allocation.default'), 'allocation_id' => array_get($data, 'allocation.default'),
'allocation_additional' => array_get($data, 'allocation.additional'), 'allocation_additional' => array_get($data, 'allocation.additional'),

View file

@ -25,6 +25,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
'limits.swap' => $this->requiredToOptional('swap', $rules['swap'], true), 'limits.swap' => $this->requiredToOptional('swap', $rules['swap'], true),
'limits.io' => $this->requiredToOptional('io', $rules['io'], true), 'limits.io' => $this->requiredToOptional('io', $rules['io'], true),
'limits.cpu' => $this->requiredToOptional('cpu', $rules['cpu'], true), 'limits.cpu' => $this->requiredToOptional('cpu', $rules['cpu'], true),
'limits.threads' => $this->requiredToOptional('threads', $rules['threads'], true),
'limits.disk' => $this->requiredToOptional('disk', $rules['disk'], true), 'limits.disk' => $this->requiredToOptional('disk', $rules['disk'], true),
// Legacy rules to maintain backwards compatable API support without requiring // Legacy rules to maintain backwards compatable API support without requiring
@ -35,6 +36,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
'swap' => $this->requiredToOptional('swap', $rules['swap']), 'swap' => $this->requiredToOptional('swap', $rules['swap']),
'io' => $this->requiredToOptional('io', $rules['io']), 'io' => $this->requiredToOptional('io', $rules['io']),
'cpu' => $this->requiredToOptional('cpu', $rules['cpu']), 'cpu' => $this->requiredToOptional('cpu', $rules['cpu']),
'threads' => $this->requiredToOptional('threads', $rules['threads']),
'disk' => $this->requiredToOptional('disk', $rules['disk']), 'disk' => $this->requiredToOptional('disk', $rules['disk']),
'add_allocations' => 'bail|array', 'add_allocations' => 'bail|array',

View file

@ -0,0 +1,41 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Backups;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class DownloadBackupRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_BACKUP_DOWNLOAD;
}
/**
* Ensure that this backup belongs to the server that is also present in the
* request.
*
* @return bool
*/
public function resourceExists(): bool
{
/** @var \Pterodactyl\Models\Server|mixed $server */
$server = $this->route()->parameter('server');
/** @var \Pterodactyl\Models\Backup|mixed $backup */
$backup = $this->route()->parameter('backup');
if ($server instanceof Server && $backup instanceof Backup) {
if ($server->exists && $backup->exists && $server->id === $backup->server_id) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Backups;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class GetBackupsRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_BACKUP_READ;
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Backups;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class StoreBackupRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_BACKUP_CREATE;
}
/**
* @return array
*/
public function rules(): array
{
return [
'name' => 'nullable|string|max:255',
'ignore' => 'nullable|string',
];
}
}

View file

@ -18,11 +18,10 @@ class SendPowerRequest extends ClientApiRequest
case 'start': case 'start':
return Permission::ACTION_CONTROL_START; return Permission::ACTION_CONTROL_START;
case 'stop': case 'stop':
case 'kill':
return Permission::ACTION_CONTROL_STOP; return Permission::ACTION_CONTROL_STOP;
case 'restart': case 'restart':
return Permission::ACTION_CONTROL_RESTART; return Permission::ACTION_CONTROL_RESTART;
case 'kill':
return Permission::ACTION_CONTROL_KILL;
} }
return '__invalid'; return '__invalid';

View file

@ -0,0 +1,17 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Settings;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class ReinstallServerRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_SETTINGS_REINSTALL;
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers;
use Pterodactyl\Models\Permission;
class DeleteSubuserRequest extends SubuserRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_USER_DELETE;
}
}

View file

@ -3,9 +3,8 @@
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers; namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers;
use Pterodactyl\Models\Permission; use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class GetSubuserRequest extends ClientApiRequest class GetSubuserRequest extends SubuserRequest
{ {
/** /**
* Confirm that a user is able to view subusers for the specified server. * Confirm that a user is able to view subusers for the specified server.

View file

@ -0,0 +1,28 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers;
use Pterodactyl\Models\Permission;
class StoreSubuserRequest extends SubuserRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_USER_CREATE;
}
/**
* @return array
*/
public function rules(): array
{
return [
'email' => 'required|email',
'permissions' => 'required|array',
'permissions.*' => 'string',
];
}
}

View file

@ -0,0 +1,131 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers;
use Illuminate\Http\Request;
use Pterodactyl\Models\Server;
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
abstract class SubuserRequest extends ClientApiRequest
{
/**
* @var \Pterodactyl\Models\Subuser|null
*/
protected $model;
/**
* Authorize the request and ensure that a user is not trying to modify themselves.
*
* @return bool
*/
public function authorize(): bool
{
if (! parent::authorize()) {
return false;
}
// If there is a subuser present in the URL, validate that it is not the same as the
// current request user. You're not allowed to modify yourself.
if ($this->route()->hasParameter('subuser')) {
if ($this->endpointSubuser()->user_id === $this->user()->id) {
return false;
}
}
// If this is a POST request, validate that the user can even assign the permissions they
// have selected to assign.
if ($this->method() === Request::METHOD_POST && $this->has('permissions')) {
$this->validatePermissionsCanBeAssigned(
$this->input('permissions') ?? []
);
}
return true;
}
/**
* Validates that the permissions we are trying to assign can actually be assigned
* by the user making the request.
*
* @param array $permissions
*/
protected function validatePermissionsCanBeAssigned(array $permissions)
{
$user = $this->user();
/** @var \Pterodactyl\Models\Server $server */
$server = $this->route()->parameter('server');
// If we are a root admin or the server owner, no need to perform these checks.
if ($user->root_admin || $user->id === $server->owner_id) {
return;
}
// Otherwise, get the current subuser's permission set, and ensure that the
// permissions they are trying to assign are not _more_ than the ones they
// already have.
if (count(array_diff($permissions, $this->currentUserPermissions())) > 0) {
throw new HttpForbiddenException(
'Cannot assign permissions to a subuser that your account does not actively possess.'
);
}
}
/**
* Returns the currently authenticated user's permissions.
*
* @return array
*/
public function currentUserPermissions(): array
{
/** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */
$repository = $this->container->make(SubuserRepository::class);
/* @var \Pterodactyl\Models\Subuser $model */
try {
$model = $repository->findFirstWhere([
['server_id', $this->route()->parameter('server')->id],
['user_id' => $this->user()->id],
]);
} catch (RecordNotFoundException $exception) {
return [];
}
return $model->permissions;
}
/**
* Return the subuser model for the given request which can then be validated. If
* required request parameters are missing a 404 error will be returned, otherwise
* a model exception will be returned if the model is not found.
*
* This returns the subuser based on the endpoint being hit, not the actual subuser
* for the account making the request.
*
* @return \Pterodactyl\Models\Subuser
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function endpointSubuser()
{
/** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */
$repository = $this->container->make(SubuserRepository::class);
$parameters = $this->route()->parameters();
if (
! isset($parameters['server'], $parameters['server'])
|| ! is_string($parameters['subuser'])
|| ! $parameters['server'] instanceof Server
) {
throw new NotFoundHttpException;
}
return $this->model ?: $this->model = $repository->getUserForServer(
$parameters['server']->id, $parameters['subuser']
);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers;
use Pterodactyl\Models\Permission;
class UpdateSubuserRequest extends SubuserRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_USER_UPDATE;
}
/**
* @return array
*/
public function rules(): array
{
return [
'permissions' => 'required|array',
'permissions.*' => 'string',
];
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Remote;
use Illuminate\Foundation\Http\FormRequest;
class ReportBackupCompleteRequest extends FormRequest
{
/**
* @return string[]
*/
public function rules()
{
return [
'successful' => 'boolean',
'sha256_hash' => 'string|required_if:successful,true',
'file_size' => 'numeric|required_if:successful,true',
];
}
}

View file

@ -18,7 +18,7 @@ namespace Pterodactyl\Models;
* @property \Pterodactyl\Models\Server|null $server * @property \Pterodactyl\Models\Server|null $server
* @property \Pterodactyl\Models\Node $node * @property \Pterodactyl\Models\Node $node
*/ */
class Allocation extends Validable class Allocation extends Model
{ {
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an
@ -75,7 +75,7 @@ class Allocation extends Validable
/** /**
* Accessor to automatically provide the IP alias if defined. * Accessor to automatically provide the IP alias if defined.
* *
* @param null|string $value * @param string|null $value
* @return string * @return string
*/ */
public function getAliasAttribute($value) public function getAliasAttribute($value)
@ -86,7 +86,7 @@ class Allocation extends Validable
/** /**
* Accessor to quickly determine if this allocation has an alias. * Accessor to quickly determine if this allocation has an alias.
* *
* @param null|string $value * @param string|null $value
* @return bool * @return bool
*/ */
public function getHasAliasAttribute($value) public function getHasAliasAttribute($value)

View file

@ -16,7 +16,7 @@ use Pterodactyl\Services\Acl\Api\AdminAcl;
* @property \Carbon\Carbon $created_at * @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at * @property \Carbon\Carbon $updated_at
*/ */
class ApiKey extends Validable class ApiKey extends Model
{ {
const RESOURCE_NAME = 'api_key'; const RESOURCE_NAME = 'api_key';
@ -53,7 +53,7 @@ class ApiKey extends Validable
* @var array * @var array
*/ */
protected $casts = [ protected $casts = [
'allowed_ips' => 'json', 'allowed_ips' => 'array',
'user_id' => 'int', 'user_id' => 'int',
'r_' . AdminAcl::RESOURCE_USERS => 'int', 'r_' . AdminAcl::RESOURCE_USERS => 'int',
'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'int', 'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'int',
@ -99,7 +99,8 @@ class ApiKey extends Validable
'identifier' => 'required|string|size:16|unique:api_keys,identifier', 'identifier' => 'required|string|size:16|unique:api_keys,identifier',
'token' => 'required|string', 'token' => 'required|string',
'memo' => 'required|nullable|string|max:500', 'memo' => 'required|nullable|string|max:500',
'allowed_ips' => 'nullable|json', 'allowed_ips' => 'nullable|array',
'allowed_ips.*' => 'string',
'last_used_at' => 'nullable|date', 'last_used_at' => 'nullable|date',
'r_' . AdminAcl::RESOURCE_USERS => 'integer|min:0|max:3', 'r_' . AdminAcl::RESOURCE_USERS => 'integer|min:0|max:3',
'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'integer|min:0|max:3', 'r_' . AdminAcl::RESOURCE_ALLOCATIONS => 'integer|min:0|max:3',

85
app/Models/Backup.php Normal file
View file

@ -0,0 +1,85 @@
<?php
namespace Pterodactyl\Models;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property int $id
* @property int $server_id
* @property int $uuid
* @property string $name
* @property string $ignored_files
* @property string $disk
* @property string|null $sha256_hash
* @property int $bytes
* @property \Carbon\CarbonImmutable|null $completed_at
* @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at
* @property \Carbon\CarbonImmutable|null $deleted_at
*
* @property \Pterodactyl\Models\Server $server
*/
class Backup extends Model
{
use SoftDeletes;
const RESOURCE_NAME = 'backup';
const DISK_LOCAL = 'local';
const DISK_AWS_S3 = 's3';
/**
* @var string
*/
protected $table = 'backups';
/**
* @var bool
*/
protected $immutableDates = true;
/**
* @var array
*/
protected $casts = [
'id' => 'int',
'bytes' => 'int',
];
/**
* @var array
*/
protected $dates = [
'completed_at',
];
/**
* @var array
*/
protected $attributes = [
'sha256_hash' => null,
'bytes' => 0,
];
/**
* @var array
*/
public static $validationRules = [
'server_id' => 'bail|required|numeric|exists:servers,id',
'uuid' => 'required|uuid',
'name' => 'required|string',
'ignored_files' => 'string',
'disk' => 'required|string',
'sha256_hash' => 'nullable|string',
'bytes' => 'numeric',
];
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function server()
{
return $this->belongsTo(Server::class);
}
}

View file

@ -4,7 +4,7 @@ namespace Pterodactyl\Models;
use Znck\Eloquent\Traits\BelongsToThrough; use Znck\Eloquent\Traits\BelongsToThrough;
class DaemonKey extends Validable class DaemonKey extends Model
{ {
use BelongsToThrough; use BelongsToThrough;

View file

@ -2,7 +2,7 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
class Database extends Validable class Database extends Model
{ {
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an

View file

@ -2,7 +2,7 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
class DatabaseHost extends Validable class DatabaseHost extends Model
{ {
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an

View file

@ -39,7 +39,7 @@ namespace Pterodactyl\Models;
* @property \Pterodactyl\Models\Egg|null $scriptFrom * @property \Pterodactyl\Models\Egg|null $scriptFrom
* @property \Pterodactyl\Models\Egg|null $configFrom * @property \Pterodactyl\Models\Egg|null $configFrom
*/ */
class Egg extends Validable class Egg extends Model
{ {
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an

View file

@ -2,7 +2,7 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
class EggVariable extends Validable class EggVariable extends Model
{ {
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an

View file

@ -2,7 +2,7 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
class Location extends Validable class Location extends Model
{ {
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an

View file

@ -5,11 +5,18 @@ namespace Pterodactyl\Models;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Container\Container; use Illuminate\Container\Container;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Contracts\Validation\Factory; use Illuminate\Contracts\Validation\Factory;
use Illuminate\Database\Eloquent\Model as IlluminateModel;
abstract class Validable extends Model abstract class Model extends IlluminateModel
{ {
/**
* Set to true to return immutable Carbon date instances from the model.
*
* @var bool
*/
protected $immutableDates = false;
/** /**
* Determines if the model should undergo data validation before it is saved * Determines if the model should undergo data validation before it is saved
* to the database. * to the database.
@ -47,7 +54,7 @@ abstract class Validable extends Model
static::$validatorFactory = Container::getInstance()->make(Factory::class); static::$validatorFactory = Container::getInstance()->make(Factory::class);
static::saving(function (Validable $model) { static::saving(function (Model $model) {
return $model->validate(); return $model->validate();
}); });
} }
@ -140,7 +147,27 @@ abstract class Validable extends Model
} }
return $this->getValidator()->setData( return $this->getValidator()->setData(
$this->getAttributes() // 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()
)
)->passes(); )->passes();
} }
/**
* Return a timestamp as DateTime object.
*
* @param mixed $value
* @return \Illuminate\Support\Carbon|\Carbon\CarbonImmutable
*/
protected function asDateTime($value)
{
if (! $this->immutableDates) {
return parent::asDateTime($value);
}
return parent::asDateTime($value)->toImmutable();
}
} }

View file

@ -15,7 +15,7 @@ namespace Pterodactyl\Models;
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Egg[] $eggs * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Egg[] $eggs
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Pack[] $packs * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Pack[] $packs
*/ */
class Nest extends Validable class Nest extends Model
{ {
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an

View file

@ -32,9 +32,10 @@ use Pterodactyl\Models\Traits\Searchable;
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations * @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
*/ */
class Node extends Validable class Node extends Model
{ {
use Notifiable, Searchable; use Notifiable;
use Searchable;
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an
@ -170,6 +171,7 @@ class Node extends Validable
], ],
'system' => [ 'system' => [
'data' => $this->daemonBase, 'data' => $this->daemonBase,
'archive_directory' => $this->daemonBase . '/.archives',
'username' => 'pterodactyl', 'username' => 'pterodactyl',
'timezone_path' => '/etc/timezone', 'timezone_path' => '/etc/timezone',
'set_permissions_on_boot' => true, 'set_permissions_on_boot' => true,
@ -235,4 +237,19 @@ class Node extends Validable
{ {
return $this->hasMany(Allocation::class); return $this->hasMany(Allocation::class);
} }
/**
* Returns a boolean if the node is viable for an additional server to be placed on it.
*
* @param int $memory
* @param int $disk
* @return bool
*/
public function isViable(int $memory, int $disk): bool
{
$memoryLimit = $this->memory * (1 + ($this->memory_overallocate / 100));
$diskLimit = $this->disk * (1 + ($this->disk_overallocate / 100));
return ($this->sum_memory + $memory) <= $memoryLimit && ($this->sum_disk + $disk) <= $diskLimit;
}
} }

View file

@ -20,7 +20,7 @@ use Pterodactyl\Models\Traits\Searchable;
* @property \Pterodactyl\Models\Egg|null $egg * @property \Pterodactyl\Models\Egg|null $egg
* @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers * @property \Illuminate\Database\Eloquent\Collection|\Pterodactyl\Models\Server[] $servers
*/ */
class Pack extends Validable class Pack extends Model
{ {
use Searchable; use Searchable;

View file

@ -4,7 +4,7 @@ namespace Pterodactyl\Models;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class Permission extends Validable class Permission extends Model
{ {
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an
@ -20,7 +20,6 @@ class Permission extends Validable
const ACTION_CONTROL_START = 'control.start'; const ACTION_CONTROL_START = 'control.start';
const ACTION_CONTROL_STOP = 'control.stop'; const ACTION_CONTROL_STOP = 'control.stop';
const ACTION_CONTROL_RESTART = 'control.restart'; const ACTION_CONTROL_RESTART = 'control.restart';
const ACTION_CONTROL_KILL = 'control.kill';
const ACTION_DATABASE_READ = 'database.read'; const ACTION_DATABASE_READ = 'database.read';
const ACTION_DATABASE_CREATE = 'database.create'; const ACTION_DATABASE_CREATE = 'database.create';
@ -38,6 +37,12 @@ class Permission extends Validable
const ACTION_USER_UPDATE = 'user.update'; const ACTION_USER_UPDATE = 'user.update';
const ACTION_USER_DELETE = 'user.delete'; const ACTION_USER_DELETE = 'user.delete';
const ACTION_BACKUP_READ = 'backup.read';
const ACTION_BACKUP_CREATE = 'backup.create';
const ACTION_BACKUP_UPDATE = 'backup.update';
const ACTION_BACKUP_DELETE = 'backup.delete';
const ACTION_BACKUP_DOWNLOAD = 'backup.download';
const ACTION_ALLOCATION_READ = 'allocation.read'; const ACTION_ALLOCATION_READ = 'allocation.read';
const ACTION_ALLOCIATION_UPDATE = 'allocation.update'; const ACTION_ALLOCIATION_UPDATE = 'allocation.update';
@ -98,105 +103,100 @@ class Permission extends Validable
*/ */
protected static $permissions = [ protected static $permissions = [
'websocket' => [ 'websocket' => [
// Allows the user to connect to the server websocket, this will give them 'description' => 'Allows the user to connect to the server websocket, giving them access to view console output and realtime server stats.',
// access to view the console output as well as realtime server stats (CPU 'keys' => [
// and Memory usage). '*' => 'Gives user full read access to the websocket.',
'*', ],
], ],
'control' => [ 'control' => [
// Allows the user to send data to the server console process. A user with this 'description' => 'Permissions that control a user\'s ability to control the power state of a server, or send commands.',
// permission will not be able to stop the server directly by issuing the specified 'keys' => [
// stop command for the Egg, however depending on plugins and server configuration 'console' => 'Allows a user to send commands to the server instance via the console.',
// they may still be able to control the server power state. 'start' => 'Allows a user to start the server if it is stopped.',
'console', // power.send-command 'stop' => 'Allows a user to stop a server if it is running.',
'restart' => 'Allows a user to perform a server restart. This allows them to start the server if it is offline, but not put the server in a completely stopped state.',
// Allows the user to start/stop/restart/kill the server process. ],
'start', // power.power-start
'stop', // power.power-stop
'restart', // power.power-restart
'kill', // power.power-kill
], ],
'user' => [ 'user' => [
// Allows a user to create a new user assigned to the server. They will not be able 'description' => 'Permissions that allow a user to manage other subusers on a server. They will never be able to edit their own account, or assign permissions they do not have themselves.',
// to assign any permissions they do not already have on their account as well. 'keys' => [
'create', // subuser.create-subuser 'create' => 'Allows a user to create new subusers for the server.',
'read', // subuser.list-subusers, subuser.view-subuser 'read' => 'Allows the user to view subusers and their permissions for the server.',
'update', // subuser.edit-subuser 'update' => 'Allows a user to modify other subusers.',
'delete', // subuser.delete-subuser 'delete' => 'Allows a user to delete a subuser from the server.',
],
], ],
'file' => [ 'file' => [
// Allows a user to create additional files and folders either via the Panel, 'description' => 'Permissions that control a user\'s ability to modify the filesystem for this server.',
// or via a direct upload. 'keys' => [
'create', // files.create-files, files.upload-files, files.copy-files, files.move-files 'create' => 'Allows a user to create additional files and folders via the Panel or direct upload.',
'read' => 'Allows a user to view the contents of a directory and read the contents of a file. Users with this permission can also download files.',
'update' => 'Allows a user to update the contents of an existing file or directory.',
'delete' => 'Allows a user to delete files or directories.',
'archive' => 'Allows a user to archive the contents of a directory as well as decompress existing archives on the system.',
'sftp' => 'Allows a user to connect to SFTP and manage server files using the other assigned file permissions.',
],
],
// Allows a user to view the contents of a directory as well as read the contents 'backup' => [
// of a given file. A user with this permission will be able to download files 'description' => 'Permissions that control a user\'s ability to generate and manage server backups.',
// as well. 'keys' => [
'read', // files.list-files, files.download-files 'create' => 'Allows a user to create new backups for this server.',
'read' => 'Allows a user to view all backups that exist for this server.',
// Allows a user to update the contents of an existing file or directory. 'update' => '',
'update', // files.edit-files, files.save-files 'delete' => 'Allows a user to remove backups from the system.',
'download' => 'Allows a user to download backups.',
// Allows a user to delete a file or directory. ],
'delete', // files.delete-files
// Allows a user to archive the contents of a directory as well as decompress existing
// archives on the system.
'archive', // files.compress-files, files.decompress-files
// Allows the user to connect and manage server files using their account
// credentials and a SFTP client.
'sftp', // files.access-sftp
], ],
// Controls permissions for editing or viewing a server's allocations. // Controls permissions for editing or viewing a server's allocations.
'allocation' => [ 'allocation' => [
'read', // server.view-allocations 'description' => 'Permissions that control a user\'s ability to modify the port allocations for this server.',
'update', // server.edit-allocation 'keys' => [
'read' => 'Allows a user to view the allocations assigned to this server.',
'update' => 'Allows a user to modify the allocations assigned to this server.',
],
], ],
// Controls permissions for editing or viewing a server's startup parameters. // Controls permissions for editing or viewing a server's startup parameters.
'startup' => [ 'startup' => [
'read', // server.view-startup 'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
'update', // server.edit-startup 'keys' => [
'read' => '',
'update' => '',
],
], ],
'database' => [ 'database' => [
// Allows a user to create a new database for a server. 'description' => 'Permissions that control a user\'s access to the database management for this server.',
'create', // database.create-database 'keys' => [
'create' => 'Allows a user to create a new database for this server.',
// Allows a user to view the databases associated with the server. If they do not also 'read' => 'Allows a user to view the database associated with this server.',
// have the view_password permission they will only be able to see the connection address 'update' => 'Allows a user to rotate the password on a database instance. If the user does not have the view_password permission they will not see the updated password.',
// and the name of the user. 'delete' => 'Allows a user to remove a database instance from this server.',
'read', // database.view-databases 'view_password' => 'Allows a user to view the password associated with a database instance for this server.',
],
// Allows a user to rotate the password on a database instance. If the user does not
// alow have the view_password permission they will not be able to see the updated password
// anywhere, but it will still be rotated.
'update', // database.reset-db-password
// Allows a user to delete a database instance.
'delete', // database.delete-database
// Allows a user to view the password associated with a database instance for the
// server. Note that a user without this permission may still be able to access these
// credentials by viewing files or the console.
'view_password', // database.reset-db-password
], ],
'schedule' => [ 'schedule' => [
'create', // task.create-schedule 'description' => 'Permissions that control a user\'s access to the schedule management for this server.',
'read', // task.view-schedule, task.list-schedules 'keys' => [
'update', // task.edit-schedule, task.queue-schedule, task.toggle-schedule 'create' => '', // task.create-schedule
'delete', // task.delete-schedule 'read' => '', // task.view-schedule, task.list-schedules
'update' => '', // task.edit-schedule, task.queue-schedule, task.toggle-schedule
'delete' => '', // task.delete-schedule
],
], ],
'settings' => [ 'settings' => [
'rename', 'description' => 'Permissions that control a user\'s access to the settings for this server.',
'reinstall', 'keys' => [
'rename' => '',
'reinstall' => '',
],
], ],
]; ];

View file

@ -25,7 +25,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
* @property \Pterodactyl\Models\Server $server * @property \Pterodactyl\Models\Server $server
* @property \Pterodactyl\Models\Task[]|\Illuminate\Support\Collection $tasks * @property \Pterodactyl\Models\Task[]|\Illuminate\Support\Collection $tasks
*/ */
class Schedule extends Validable class Schedule extends Model
{ {
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an

View file

@ -23,6 +23,7 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property int $disk * @property int $disk
* @property int $io * @property int $io
* @property int $cpu * @property int $cpu
* @property string $threads
* @property bool $oom_disabled * @property bool $oom_disabled
* @property int $allocation_id * @property int $allocation_id
* @property int $nest_id * @property int $nest_id
@ -50,10 +51,14 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property \Pterodactyl\Models\Location $location * @property \Pterodactyl\Models\Location $location
* @property \Pterodactyl\Models\DaemonKey $key * @property \Pterodactyl\Models\DaemonKey $key
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys * @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
* @property \Pterodactyl\Models\ServerTransfer $transfer
* @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups
*/ */
class Server extends Validable class Server extends Model
{ {
use BelongsToThrough, Notifiable, Searchable; use BelongsToThrough;
use Notifiable;
use Searchable;
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an
@ -61,6 +66,10 @@ class Server extends Validable
*/ */
const RESOURCE_NAME = 'server'; const RESOURCE_NAME = 'server';
const STATUS_INSTALLING = 0;
const STATUS_INSTALLED = 1;
const STATUS_INSTALL_FAILED = 2;
/** /**
* The table associated with the model. * The table associated with the model.
* *
@ -105,6 +114,7 @@ class Server extends Validable
'swap' => 'required|numeric|min:-1', 'swap' => 'required|numeric|min:-1',
'io' => 'required|numeric|between:10,1000', 'io' => 'required|numeric|between:10,1000',
'cpu' => 'required|numeric|min:0', 'cpu' => 'required|numeric|min:0',
'threads' => 'nullable|regex:/^[0-9-,]+$/',
'oom_disabled' => 'sometimes|boolean', 'oom_disabled' => 'sometimes|boolean',
'disk' => 'required|numeric|min:0', 'disk' => 'required|numeric|min:0',
'allocation_id' => 'required|bail|unique:servers|exists:allocations,id', 'allocation_id' => 'required|bail|unique:servers|exists:allocations,id',
@ -177,7 +187,7 @@ class Server extends Validable
*/ */
public function getAllocationMappings(): array public function getAllocationMappings(): array
{ {
return $this->allocations->groupBy('ip')->map(function ($item) { return $this->allocations->where('node_id', $this->node_id)->groupBy('ip')->map(function ($item) {
return $item->pluck('port'); return $item->pluck('port');
})->toArray(); })->toArray();
} }
@ -331,4 +341,22 @@ class Server extends Validable
{ {
return $this->hasMany(DaemonKey::class); return $this->hasMany(DaemonKey::class);
} }
/**
* Returns the associated server transfer.
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function transfer()
{
return $this->hasOne(ServerTransfer::class)->orderByDesc('id');
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function backups()
{
return $this->hasMany(Backup::class);
}
} }

View file

@ -0,0 +1,81 @@
<?php
namespace Pterodactyl\Models;
/**
* @property int $id
* @property int $server_id
* @property int $old_node
* @property int $new_node
* @property int $old_allocation
* @property int $new_allocation
* @property string $old_additional_allocations
* @property string $new_additional_allocations
* @property bool $successful
* @property \Carbon\Carbon $created_at
* @property \Carbon\Carbon $updated_at
*
* @property \Pterodactyl\Models\Server $server
*/
class ServerTransfer extends Validable
{
/**
* The resource name for this model when it is transformed into an
* API representation using fractal.
*/
const RESOURCE_NAME = 'server_transfer';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'server_transfers';
/**
* Fields that are not mass assignable.
*
* @var array
*/
protected $guarded = ['id', 'created_at', 'updated_at'];
/**
* Cast values to correct type.
*
* @var array
*/
protected $casts = [
'server_id' => 'int',
'old_node' => 'int',
'new_node' => 'int',
'old_allocation' => 'int',
'new_allocation' => 'int',
'old_additional_allocations' => 'string',
'new_additional_allocations' => 'string',
'successful' => 'bool',
];
/**
* @var array
*/
public static $validationRules = [
'server_id' => 'required|numeric|exists:servers,id',
'old_node' => 'required|numeric',
'new_node' => 'required|numeric',
'old_allocation' => 'required|numeric',
'new_allocation' => 'required|numeric',
'old_additional_allocations' => 'nullable',
'new_additional_allocations' => 'nullable',
'successful' => 'sometimes|boolean',
];
/**
* Gets the server associated with a server transfer.
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function server()
{
return $this->belongsTo(Server::class);
}
}

View file

@ -2,7 +2,7 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
class Setting extends Validable class Setting extends Model
{ {
/** /**
* The table associated with the model. * The table associated with the model.

View file

@ -15,7 +15,7 @@ use Illuminate\Notifications\Notifiable;
* @property \Pterodactyl\Models\User $user * @property \Pterodactyl\Models\User $user
* @property \Pterodactyl\Models\Server $server * @property \Pterodactyl\Models\Server $server
*/ */
class Subuser extends Validable class Subuser extends Model
{ {
use Notifiable; use Notifiable;

View file

@ -22,7 +22,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
* @property \Pterodactyl\Models\Schedule $schedule * @property \Pterodactyl\Models\Schedule $schedule
* @property \Pterodactyl\Models\Server $server * @property \Pterodactyl\Models\Server $server
*/ */
class Task extends Validable class Task extends Model
{ {
use BelongsToThrough; use BelongsToThrough;

View file

@ -37,17 +37,20 @@ use Pterodactyl\Notifications\SendPasswordReset as ResetPasswordNotification;
* *
* @property string $name * @property string $name
* @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys * @property \Pterodactyl\Models\ApiKey[]|\Illuminate\Database\Eloquent\Collection $apiKeys
* @property \Pterodactyl\Models\Permission[]|\Illuminate\Database\Eloquent\Collection $permissions
* @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers * @property \Pterodactyl\Models\Server[]|\Illuminate\Database\Eloquent\Collection $servers
* @property \Pterodactyl\Models\Subuser[]|\Illuminate\Database\Eloquent\Collection $subuserOf
* @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys * @property \Pterodactyl\Models\DaemonKey[]|\Illuminate\Database\Eloquent\Collection $keys
*/ */
class User extends Validable implements class User extends Model implements
AuthenticatableContract, AuthenticatableContract,
AuthorizableContract, AuthorizableContract,
CanResetPasswordContract CanResetPasswordContract
{ {
use Authenticatable, Authorizable, AvailableLanguages, CanResetPassword, Notifiable, Searchable; use Authenticatable;
use Authorizable;
use AvailableLanguages;
use CanResetPassword;
use Notifiable;
use Searchable;
const USER_LEVEL_USER = 0; const USER_LEVEL_USER = 0;
const USER_LEVEL_ADMIN = 1; const USER_LEVEL_ADMIN = 1;
@ -220,16 +223,6 @@ class User extends Validable implements
return trim($this->name_first . ' ' . $this->name_last); return trim($this->name_first . ' ' . $this->name_last);
} }
/**
* Returns all permissions that a user has.
*
* @return \Illuminate\Database\Eloquent\Relations\HasManyThrough
*/
public function permissions()
{
return $this->hasManyThrough(Permission::class, Subuser::class);
}
/** /**
* Returns all servers that a user owns. * Returns all servers that a user owns.
* *
@ -240,16 +233,6 @@ class User extends Validable implements
return $this->hasMany(Server::class, 'owner_id'); return $this->hasMany(Server::class, 'owner_id');
} }
/**
* Return all servers that user is listed as a subuser of directly.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function subuserOf()
{
return $this->hasMany(Subuser::class);
}
/** /**
* Return all of the daemon keys that a user belongs to. * Return all of the daemon keys that a user belongs to.
* *

View file

@ -1,21 +1,29 @@
<?php <?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\Policies; namespace Pterodactyl\Policies;
use Cache; use Carbon\Carbon;
use Carbon;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
class ServerPolicy class ServerPolicy
{ {
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
private $cache;
/**
* ServerPolicy constructor.
*
* @param \Illuminate\Contracts\Cache\Repository $cache
*/
public function __construct(CacheRepository $cache)
{
$this->cache = $cache;
}
/** /**
* Checks if the user has the given permission on/for the server. * Checks if the user has the given permission on/for the server.
* *
@ -26,13 +34,16 @@ class ServerPolicy
*/ */
protected function checkPermission(User $user, Server $server, $permission) protected function checkPermission(User $user, Server $server, $permission)
{ {
$permissions = Cache::remember('ServerPolicy.' . $user->uuid . $server->uuid, Carbon::now()->addSeconds(5), function () use ($user, $server) { $key = sprintf('ServerPolicy.%s.%s', $user->uuid, $server->uuid);
return $user->permissions()->server($server)->get()->transform(function ($item) {
return $item->permission; $permissions = $this->cache->remember($key, Carbon::now()->addSeconds(5), function () use ($user, $server) {
})->values(); /** @var \Pterodactyl\Models\Subuser|null $subuser */
$subuser = $server->subusers()->where('user_id', $user->id)->first();
return $subuser ? $subuser->permissions : [];
}); });
return $permissions->search($permission, true) !== false; return in_array($permission, $permissions);
} }
/** /**

View file

@ -0,0 +1,16 @@
<?php
namespace Pterodactyl\Repositories\Eloquent;
use Pterodactyl\Models\Backup;
class BackupRepository extends EloquentRepository
{
/**
* @return string
*/
public function model()
{
return Backup::class;
}
}

View file

@ -177,6 +177,18 @@ abstract class EloquentRepository extends Repository implements RepositoryInterf
return ($this->withFresh) ? $instance->fresh() : $saved; return ($this->withFresh) ? $instance->fresh() : $saved;
} }
/**
* Update a model using the attributes passed.
*
* @param array|\Closure $attributes
* @param array $values
* @return int
*/
public function updateWhere($attributes, array $values)
{
return $this->getBuilder()->where($attributes)->update($values);
}
/** /**
* Perform a mass update where matching records are updated using whereIn. * Perform a mass update where matching records are updated using whereIn.
* This does not perform any model data validation. * This does not perform any model data validation.

View file

@ -174,6 +174,23 @@ class NodeRepository extends EloquentRepository implements NodeRepositoryInterfa
})->values(); })->values();
} }
/**
* Returns a node with the given id with the Node's resource usage.
*
* @param int $node_id
* @return Node
*/
public function getNodeWithResourceUsage(int $node_id): Node
{
$instance = $this->getBuilder()
->select(['nodes.id', 'nodes.fqdn', 'nodes.scheme', 'nodes.daemonSecret', 'nodes.daemonListen', '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.id', $node_id);
return $instance->first();
}
/** /**
* Return the IDs of all nodes that exist in the provided locations and have the space * 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. * available to support the additional disk and memory provided.

View file

@ -3,7 +3,6 @@
namespace Pterodactyl\Repositories\Eloquent; namespace Pterodactyl\Repositories\Eloquent;
use Pterodactyl\Models\Subuser; use Pterodactyl\Models\Subuser;
use Illuminate\Support\Collection;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
use Pterodactyl\Contracts\Repository\SubuserRepositoryInterface; use Pterodactyl\Contracts\Repository\SubuserRepositoryInterface;
@ -20,19 +19,27 @@ class SubuserRepository extends EloquentRepository implements SubuserRepositoryI
} }
/** /**
* Returns the subusers for the given server instance with the associated user * Returns a subuser model for the given user and server combination. If no record
* and permission relationships pre-loaded. * exists an exception will be thrown.
* *
* @param int $server * @param int $server
* @return \Illuminate\Support\Collection * @param string $uuid
* @return \Pterodactyl\Models\Subuser
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/ */
public function getSubusersForServer(int $server): Collection public function getUserForServer(int $server, string $uuid): Subuser
{ {
return $this->getBuilder() /** @var \Pterodactyl\Models\Subuser $model */
->with('user', 'permissions') $model = $this->getBuilder()
->where('server_id', $server) ->with('server', 'user')
->get() ->select('subusers.*')
->toBase(); ->join('users', 'users.id', '=', 'subusers.user_id')
->where('subusers.server_id', $server)
->where('users.uuid', $uuid)
->firstOrFail();
return $model;
} }
/** /**

View file

@ -29,7 +29,7 @@ class UserRepository extends EloquentRepository implements UserRepositoryInterfa
*/ */
public function getAllUsersWithCounts(): LengthAwarePaginator public function getAllUsersWithCounts(): LengthAwarePaginator
{ {
return $this->getBuilder()->withCount('servers', 'subuserOf') return $this->getBuilder()->withCount('servers')
->search($this->getSearchTerm()) ->search($this->getSearchTerm())
->paginate(50, $this->getColumns()); ->paginate(50, $this->getColumns());
} }

View file

@ -0,0 +1,63 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class DaemonBackupRepository extends DaemonRepository
{
/**
* Tells the remote Daemon to begin generating a backup for the server.
*
* @param \Pterodactyl\Models\Backup $backup
* @return \Psr\Http\Message\ResponseInterface
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function backup(Backup $backup): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/backup', $this->server->uuid),
[
'json' => [
'uuid' => $backup->uuid,
'ignored_files' => explode(PHP_EOL, $backup->ignored_files),
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/**
* Returns a stream of a backup's contents from the Wings instance so that we
* do not need to send the user directly to the Daemon.
*
* @param string $backup
* @return \Psr\Http\Message\ResponseInterface
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function getBackup(string $backup): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->get(
sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup),
['stream' => true]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View file

@ -2,7 +2,6 @@
namespace Pterodactyl\Repositories\Wings; namespace Pterodactyl\Repositories\Wings;
use BadMethodCallException;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use GuzzleHttp\Exception\TransferException; use GuzzleHttp\Exception\TransferException;
@ -13,7 +12,6 @@ class DaemonServerRepository extends DaemonRepository
/** /**
* Returns details about a server from the Daemon instance. * Returns details about a server from the Daemon instance.
* *
* @return array
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function getDetails(): array public function getDetails(): array
@ -89,10 +87,20 @@ class DaemonServerRepository extends DaemonRepository
/** /**
* Reinstall a server on the daemon. * Reinstall a server on the daemon.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function reinstall(): void public function reinstall(): void
{ {
throw new BadMethodCallException('Method is not implemented.'); Assert::isInstanceOf($this->server, Server::class);
try {
$this->getHttpClient()->post(sprintf(
'/api/servers/%s/reinstall', $this->server->uuid
));
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
} }
/** /**
@ -116,4 +124,24 @@ class DaemonServerRepository extends DaemonRepository
throw new DaemonConnectionException($exception); throw new DaemonConnectionException($exception);
} }
} }
/**
* Requests the daemon to create a full archive of the server.
* Once the daemon is finished they will send a POST request to
* "/api/remote/servers/{uuid}/archive" with a boolean.
*
* @throws DaemonConnectionException
*/
public function requestArchive(): void
{
Assert::isInstanceOf($this->server, Server::class);
try {
$this->getHttpClient()->post(sprintf(
'/api/servers/%s/archive', $this->server->uuid
));
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
} }

View file

@ -0,0 +1,35 @@
<?php
namespace Pterodactyl\Repositories\Wings;
use Pterodactyl\Models\Node;
use Pterodactyl\Models\Server;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class DaemonTransferRepository extends DaemonRepository
{
/**
* @param Server $server
* @param array $data
* @param Node $node
* @param string $token
*
* @throws DaemonConnectionException
*/
public function notify(Server $server, array $data, Node $node, string $token): void
{
try {
$this->getHttpClient()->post('/api/transfer', [
'json' => [
'server_id' => $server->uuid,
'url' => $node->getConnectionAddress() . sprintf('/api/servers/%s/archive', $server->uuid),
'token' => 'Bearer ' . $token,
'server' => $data,
],
]);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace Pterodactyl\Services\Backups;
use Ramsey\Uuid\Uuid;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
class InitiateBackupService
{
/**
* @var string|null
*/
private $ignoredFiles;
/**
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
*/
private $repository;
/**
* @var \Illuminate\Database\ConnectionInterface
*/
private $connection;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonBackupRepository
*/
private $daemonBackupRepository;
/**
* InitiateBackupService constructor.
*
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
*/
public function __construct(
BackupRepository $repository,
ConnectionInterface $connection,
DaemonBackupRepository $daemonBackupRepository
) {
$this->repository = $repository;
$this->connection = $connection;
$this->daemonBackupRepository = $daemonBackupRepository;
}
/**
* Sets the files to be ignored by this backup.
*
* @param string|null $ignored
* @return $this
*/
public function setIgnoredFiles(?string $ignored)
{
$this->ignoredFiles = $ignored;
return $this;
}
/**
* Initiates the backup process for a server on the daemon.
*
* @param \Pterodactyl\Models\Server $server
* @param string|null $name
* @return \Pterodactyl\Models\Backup
*
* @throws \Throwable
*/
public function handle(Server $server, string $name = null): Backup
{
return $this->connection->transaction(function () use ($server, $name) {
/** @var \Pterodactyl\Models\Backup $backup */
$backup = $this->repository->create([
'server_id' => $server->id,
'uuid' => Uuid::uuid4()->toString(),
'name' => trim($name) ?: sprintf('Backup at %s', CarbonImmutable::now()->toDateTimeString()),
'ignored_files' => $this->ignoredFiles ?? '',
'disk' => 'local',
], true, true);
$this->daemonBackupRepository->setServer($server)->backup($backup);
return $backup;
});
}
}

View file

@ -98,6 +98,7 @@ class BuildModificationService
'swap' => array_get($data, 'swap'), 'swap' => array_get($data, 'swap'),
'io' => array_get($data, 'io'), 'io' => array_get($data, 'io'),
'cpu' => array_get($data, 'cpu'), 'cpu' => array_get($data, 'cpu'),
'threads' => array_get($data, 'threads'),
'disk' => array_get($data, 'disk'), 'disk' => array_get($data, 'disk'),
'allocation_id' => array_get($data, 'allocation_id'), 'allocation_id' => array_get($data, 'allocation_id'),
'database_limit' => array_get($data, 'database_limit'), 'database_limit' => array_get($data, 'database_limit'),

View file

@ -3,11 +3,9 @@
namespace Pterodactyl\Services\Servers; namespace Pterodactyl\Services\Servers;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class ReinstallServerService class ReinstallServerService
{ {
@ -44,28 +42,23 @@ class ReinstallServerService
} }
/** /**
* @param int|\Pterodactyl\Models\Server $server * Reinstall a server on the remote daemon.
* *
* @throws \Pterodactyl\Exceptions\DisplayException * @param \Pterodactyl\Models\Server $server
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @return \Pterodactyl\Models\Server
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException *
* @throws \Throwable
*/ */
public function reinstall($server) public function reinstall(Server $server)
{ {
if (! $server instanceof Server) { $this->database->transaction(function () use ($server) {
$server = $this->repository->find($server);
}
$this->database->beginTransaction();
$this->repository->withoutFreshModel()->update($server->id, [ $this->repository->withoutFreshModel()->update($server->id, [
'installed' => 0, 'installed' => Server::STATUS_INSTALLING,
], true, true); ]);
try {
$this->daemonServerRepository->setServer($server)->reinstall(); $this->daemonServerRepository->setServer($server)->reinstall();
$this->database->commit(); });
} catch (RequestException $exception) {
throw new DaemonConnectionException($exception); return $server->refresh();
}
} }
} }

View file

@ -81,6 +81,7 @@ class ServerConfigurationStructureService
'swap' => $server->swap, 'swap' => $server->swap,
'io_weight' => $server->io, 'io_weight' => $server->io,
'cpu_limit' => $server->cpu, 'cpu_limit' => $server->cpu,
'threads' => $server->threads,
'disk_space' => $server->disk, 'disk_space' => $server->disk,
], ],
'service' => [ 'service' => [
@ -130,6 +131,7 @@ class ServerConfigurationStructureService
'swap' => (int) $server->swap, 'swap' => (int) $server->swap,
'io' => (int) $server->io, 'io' => (int) $server->io,
'cpu' => (int) $server->cpu, 'cpu' => (int) $server->cpu,
'threads' => $server->threads,
'disk' => (int) $server->disk, 'disk' => (int) $server->disk,
'image' => $server->image, 'image' => $server->image,
], ],

View file

@ -4,7 +4,6 @@ namespace Pterodactyl\Services\Servers;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Pterodactyl\Models\Node;
use Pterodactyl\Models\User; use Pterodactyl\Models\User;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -242,6 +241,7 @@ class ServerCreationService
'disk' => Arr::get($data, 'disk'), 'disk' => Arr::get($data, 'disk'),
'io' => Arr::get($data, 'io'), 'io' => Arr::get($data, 'io'),
'cpu' => Arr::get($data, 'cpu'), 'cpu' => Arr::get($data, 'cpu'),
'threads' => Arr::get($data, 'threads'),
'oom_disabled' => Arr::get($data, 'oom_disabled', true), 'oom_disabled' => Arr::get($data, 'oom_disabled', true),
'allocation_id' => Arr::get($data, 'allocation_id'), 'allocation_id' => Arr::get($data, 'allocation_id'),
'nest_id' => Arr::get($data, 'nest_id'), 'nest_id' => Arr::get($data, 'nest_id'),

View file

@ -0,0 +1,46 @@
<?php
namespace Pterodactyl\Services\Servers;
use Pterodactyl\Models\Server;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
class TransferService
{
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonServerRepository
*/
private $daemonServerRepository;
/**
* TransferService constructor.
*
* @param \Pterodactyl\Repositories\Wings\DaemonServerRepository $daemonServerRepository
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
*/
public function __construct(
DaemonServerRepository $daemonServerRepository,
ServerRepositoryInterface $repository
) {
$this->repository = $repository;
$this->daemonServerRepository = $daemonServerRepository;
}
/**
* Requests an archive from the daemon.
*
* @param int|\Pterodactyl\Models\Server $server
*
* @throws \Throwable
*/
public function requestArchive(Server $server)
{
$this->daemonServerRepository->setServer($server)->requestArchive();
}
}

View file

@ -1,63 +0,0 @@
<?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\Subusers;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Permission;
use Pterodactyl\Contracts\Repository\PermissionRepositoryInterface;
class PermissionCreationService
{
/**
* @var \Pterodactyl\Contracts\Repository\PermissionRepositoryInterface
*/
protected $repository;
/**
* PermissionCreationService constructor.
*
* @param \Pterodactyl\Contracts\Repository\PermissionRepositoryInterface $repository
*/
public function __construct(PermissionRepositoryInterface $repository)
{
$this->repository = $repository;
}
/**
* Assign permissions to a given subuser.
*
* @param int $subuser
* @param array $permissions
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
*/
public function handle($subuser, array $permissions)
{
Assert::integerish($subuser, 'First argument passed to handle must be an integer, received %s.');
$permissionMappings = Permission::getPermissions(true);
$insertPermissions = [];
foreach ($permissions as $permission) {
if (array_key_exists($permission, $permissionMappings)) {
Assert::stringNotEmpty($permission, 'Permission argument provided must be a non-empty string, received %s.');
array_push($insertPermissions, [
'subuser_id' => $subuser,
'permission' => $permission,
]);
}
}
if (! empty($insertPermissions)) {
$this->repository->withoutFreshModel()->insert($insertPermissions);
}
}
}

View file

@ -1,22 +1,14 @@
<?php <?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\Subusers; namespace Pterodactyl\Services\Subusers;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Models\Subuser;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Services\Users\UserCreationService; use Pterodactyl\Services\Users\UserCreationService;
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
use Pterodactyl\Contracts\Repository\UserRepositoryInterface; use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
use Pterodactyl\Services\DaemonKeys\DaemonKeyCreationService;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Pterodactyl\Contracts\Repository\SubuserRepositoryInterface;
use Pterodactyl\Exceptions\Service\Subuser\UserIsServerOwnerException; use Pterodactyl\Exceptions\Service\Subuser\UserIsServerOwnerException;
use Pterodactyl\Exceptions\Service\Subuser\ServerSubuserExistsException; use Pterodactyl\Exceptions\Service\Subuser\ServerSubuserExistsException;
@ -25,86 +17,61 @@ class SubuserCreationService
/** /**
* @var \Illuminate\Database\ConnectionInterface * @var \Illuminate\Database\ConnectionInterface
*/ */
protected $connection; private $connection;
/** /**
* @var \Pterodactyl\Services\DaemonKeys\DaemonKeyCreationService * @var \Pterodactyl\Repositories\Eloquent\SubuserRepository
*/ */
protected $keyCreationService; private $subuserRepository;
/**
* @var \Pterodactyl\Services\Subusers\PermissionCreationService
*/
protected $permissionService;
/**
* @var \Pterodactyl\Contracts\Repository\SubuserRepositoryInterface
*/
protected $subuserRepository;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
protected $serverRepository;
/** /**
* @var \Pterodactyl\Services\Users\UserCreationService * @var \Pterodactyl\Services\Users\UserCreationService
*/ */
protected $userCreationService; private $userCreationService;
/** /**
* @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface * @var \Pterodactyl\Contracts\Repository\UserRepositoryInterface
*/ */
protected $userRepository; private $userRepository;
/** /**
* SubuserCreationService constructor. * SubuserCreationService constructor.
* *
* @param \Illuminate\Database\ConnectionInterface $connection * @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Services\DaemonKeys\DaemonKeyCreationService $keyCreationService * @param \Pterodactyl\Repositories\Eloquent\SubuserRepository $subuserRepository
* @param \Pterodactyl\Services\Subusers\PermissionCreationService $permissionService
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $serverRepository
* @param \Pterodactyl\Contracts\Repository\SubuserRepositoryInterface $subuserRepository
* @param \Pterodactyl\Services\Users\UserCreationService $userCreationService * @param \Pterodactyl\Services\Users\UserCreationService $userCreationService
* @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $userRepository * @param \Pterodactyl\Contracts\Repository\UserRepositoryInterface $userRepository
*/ */
public function __construct( public function __construct(
ConnectionInterface $connection, ConnectionInterface $connection,
DaemonKeyCreationService $keyCreationService, SubuserRepository $subuserRepository,
PermissionCreationService $permissionService,
ServerRepositoryInterface $serverRepository,
SubuserRepositoryInterface $subuserRepository,
UserCreationService $userCreationService, UserCreationService $userCreationService,
UserRepositoryInterface $userRepository UserRepositoryInterface $userRepository
) { ) {
$this->connection = $connection; $this->connection = $connection;
$this->keyCreationService = $keyCreationService;
$this->permissionService = $permissionService;
$this->serverRepository = $serverRepository;
$this->subuserRepository = $subuserRepository; $this->subuserRepository = $subuserRepository;
$this->userRepository = $userRepository; $this->userRepository = $userRepository;
$this->userCreationService = $userCreationService; $this->userCreationService = $userCreationService;
} }
/** /**
* @param int|\Pterodactyl\Models\Server $server * Creates a new user on the system and assigns them access to the provided server.
* If the email address already belongs to a user on the system a new user will not
* be created.
*
* @param \Pterodactyl\Models\Server $server
* @param string $email * @param string $email
* @param array $permissions * @param array $permissions
* @return \Pterodactyl\Models\Subuser * @return \Pterodactyl\Models\Subuser
* *
* @throws \Exception
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Pterodactyl\Exceptions\Service\Subuser\ServerSubuserExistsException * @throws \Pterodactyl\Exceptions\Service\Subuser\ServerSubuserExistsException
* @throws \Pterodactyl\Exceptions\Service\Subuser\UserIsServerOwnerException * @throws \Pterodactyl\Exceptions\Service\Subuser\UserIsServerOwnerException
* @throws \Throwable
*/ */
public function handle($server, $email, array $permissions) public function handle(Server $server, string $email, array $permissions): Subuser
{ {
if (! $server instanceof Server) { return $this->connection->transaction(function () use ($server, $email, $permissions) {
$server = $this->serverRepository->find($server);
}
$this->connection->beginTransaction();
try { try {
$user = $this->userRepository->findFirstWhere([['email', '=', $email]]); $user = $this->userRepository->findFirstWhere([['email', '=', $email]]);
@ -117,21 +84,20 @@ class SubuserCreationService
throw new ServerSubuserExistsException(trans('exceptions.subusers.subuser_exists')); throw new ServerSubuserExistsException(trans('exceptions.subusers.subuser_exists'));
} }
} catch (RecordNotFoundException $exception) { } catch (RecordNotFoundException $exception) {
$username = preg_replace('/([^\w\.-]+)/', '', strtok($email, '@'));
$user = $this->userCreationService->handle([ $user = $this->userCreationService->handle([
'email' => $email, 'email' => $email,
'username' => $username . str_random(3), 'username' => preg_replace('/([^\w\.-]+)/', '', strtok($email, '@')) . str_random(3),
'name_first' => 'Server', 'name_first' => 'Server',
'name_last' => 'Subuser', 'name_last' => 'Subuser',
'root_admin' => false, 'root_admin' => false,
]); ]);
} }
$subuser = $this->subuserRepository->create(['user_id' => $user->id, 'server_id' => $server->id]); return $this->subuserRepository->create([
$this->keyCreationService->handle($server->id, $user->id); 'user_id' => $user->id,
$this->permissionService->handle($subuser->id, $permissions); 'server_id' => $server->id,
$this->connection->commit(); 'permissions' => array_unique($permissions),
]);
return $subuser; });
} }
} }

View file

@ -1,35 +0,0 @@
<?php
namespace Pterodactyl\Services\Subusers;
use Pterodactyl\Models\Subuser;
use Pterodactyl\Contracts\Repository\SubuserRepositoryInterface;
class SubuserDeletionService
{
/**
* @var \Pterodactyl\Contracts\Repository\SubuserRepositoryInterface
*/
private $repository;
/**
* SubuserDeletionService constructor.
*
* @param \Pterodactyl\Contracts\Repository\SubuserRepositoryInterface $repository
*/
public function __construct(
SubuserRepositoryInterface $repository
) {
$this->repository = $repository;
}
/**
* Delete a subuser and their associated permissions from the Panel and Daemon.
*
* @param \Pterodactyl\Models\Subuser $subuser
*/
public function handle(Subuser $subuser)
{
$this->repository->delete($subuser->id);
}
}

View file

@ -1,107 +0,0 @@
<?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\Subusers;
use Pterodactyl\Models\Subuser;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Services\DaemonKeys\DaemonKeyProviderService;
use Pterodactyl\Contracts\Repository\SubuserRepositoryInterface;
use Pterodactyl\Contracts\Repository\PermissionRepositoryInterface;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
use Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface as DaemonServerRepositoryInterface;
class SubuserUpdateService
{
/**
* @var \Illuminate\Database\ConnectionInterface
*/
private $connection;
/**
* @var \Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface
*/
private $daemonRepository;
/**
* @var \Pterodactyl\Services\DaemonKeys\DaemonKeyProviderService
*/
private $keyProviderService;
/**
* @var \Pterodactyl\Contracts\Repository\PermissionRepositoryInterface
*/
private $permissionRepository;
/**
* @var \Pterodactyl\Services\Subusers\PermissionCreationService
*/
private $permissionService;
/**
* @var \Pterodactyl\Contracts\Repository\SubuserRepositoryInterface
*/
private $repository;
/**
* SubuserUpdateService constructor.
*
* @param \Illuminate\Database\ConnectionInterface $connection
* @param \Pterodactyl\Services\DaemonKeys\DaemonKeyProviderService $keyProviderService
* @param \Pterodactyl\Contracts\Repository\Daemon\ServerRepositoryInterface $daemonRepository
* @param \Pterodactyl\Services\Subusers\PermissionCreationService $permissionService
* @param \Pterodactyl\Contracts\Repository\PermissionRepositoryInterface $permissionRepository
* @param \Pterodactyl\Contracts\Repository\SubuserRepositoryInterface $repository
*/
public function __construct(
ConnectionInterface $connection,
DaemonKeyProviderService $keyProviderService,
DaemonServerRepositoryInterface $daemonRepository,
PermissionCreationService $permissionService,
PermissionRepositoryInterface $permissionRepository,
SubuserRepositoryInterface $repository
) {
$this->connection = $connection;
$this->daemonRepository = $daemonRepository;
$this->keyProviderService = $keyProviderService;
$this->permissionRepository = $permissionRepository;
$this->permissionService = $permissionService;
$this->repository = $repository;
}
/**
* Update permissions for a given subuser.
*
* @param \Pterodactyl\Models\Subuser $subuser
* @param array $permissions
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function handle(Subuser $subuser, array $permissions)
{
$subuser = $this->repository->loadServerAndUserRelations($subuser);
$this->connection->beginTransaction();
$this->permissionRepository->deleteWhere([['subuser_id', '=', $subuser->id]]);
$this->permissionService->handle($subuser->id, $permissions);
try {
$token = $this->keyProviderService->handle($subuser->getRelation('server'), $subuser->getRelation('user'), false);
$this->daemonRepository->setServer($subuser->getRelation('server'))->revokeAccessKey($token);
} catch (RequestException $exception) {
$this->connection->rollBack();
throw new DaemonConnectionException($exception);
}
$this->connection->commit();
}
}

View file

@ -75,6 +75,7 @@ class ServerTransformer extends BaseTransformer
'disk' => $server->disk, 'disk' => $server->disk,
'io' => $server->io, 'io' => $server->io,
'cpu' => $server->cpu, 'cpu' => $server->cpu,
'threads' => $server->threads,
], ],
'feature_limits' => [ 'feature_limits' => [
'databases' => $server->database_limit, 'databases' => $server->database_limit,

View file

@ -0,0 +1,33 @@
<?php
namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\Backup;
class BackupTransformer extends BaseClientTransformer
{
/**
* @return string
*/
public function getResourceName(): string
{
return Backup::RESOURCE_NAME;
}
/**
* @param \Pterodactyl\Models\Backup $backup
* @return array
*/
public function transform(Backup $backup)
{
return [
'uuid' => $backup->uuid,
'name' => $backup->name,
'ignored_files' => $backup->ignored_files,
'sha256_hash' => $backup->sha256_hash,
'bytes' => $backup->bytes,
'created_at' => $backup->created_at->toIso8601String(),
'completed_at' => $backup->completed_at ? $backup->completed_at->toIso8601String() : null,
];
}
}

View file

@ -2,16 +2,10 @@
namespace Pterodactyl\Transformers\Api\Client; namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Subuser; use Pterodactyl\Models\Subuser;
class SubuserTransformer extends BaseClientTransformer class SubuserTransformer extends BaseClientTransformer
{ {
/**
* @var array
*/
protected $defaultIncludes = ['user'];
/** /**
* Return the resource name for the JSONAPI output. * Return the resource name for the JSONAPI output.
* *
@ -27,23 +21,13 @@ class SubuserTransformer extends BaseClientTransformer
* *
* @param \Pterodactyl\Models\Subuser $model * @param \Pterodactyl\Models\Subuser $model
* @return array|void * @return array|void
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/ */
public function transform(Subuser $model) public function transform(Subuser $model)
{ {
return [ return array_merge(
'permissions' => $model->permissions->pluck('permission'), $this->makeTransformer(UserTransformer::class)->transform($model->user),
]; ['permissions' => $model->permissions]
} );
/**
* Include the permissions associated with this subuser.
*
* @param \Pterodactyl\Models\Subuser $model
* @return \League\Fractal\Resource\Item
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeUser(Subuser $model)
{
return $this->item($model->user, $this->makeTransformer(UserTransformer::class), User::RESOURCE_NAME);
} }
} }

View file

@ -15,16 +15,16 @@
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-pdo_mysql": "*", "ext-pdo_mysql": "*",
"ext-zip": "*", "ext-zip": "*",
"appstract/laravel-blade-directives": "^1.6", "appstract/laravel-blade-directives": "^1.8",
"aws/aws-sdk-php": "^3.110", "aws/aws-sdk-php": "^3.134",
"cakephp/chronos": "^1.2", "cakephp/chronos": "^1.3",
"doctrine/dbal": "^2.9", "doctrine/dbal": "^2.10",
"fideloper/proxy": "^4.2", "fideloper/proxy": "^4.2",
"guzzlehttp/guzzle": "^6.3", "guzzlehttp/guzzle": "^6.5",
"hashids/hashids": "^4.0", "hashids/hashids": "^4.0",
"laracasts/utilities": "^3.0", "laracasts/utilities": "^3.1",
"laravel/framework": "^6.0.0", "laravel/framework": "^6.18",
"laravel/helpers": "^1.1", "laravel/helpers": "^1.2",
"laravel/tinker": "^1.0", "laravel/tinker": "^1.0",
"lcobucci/jwt": "^3.3", "lcobucci/jwt": "^3.3",
"matriphe/iso-639": "^1.2", "matriphe/iso-639": "^1.2",
@ -32,18 +32,18 @@
"predis/predis": "^1.1", "predis/predis": "^1.1",
"prologue/alerts": "^0.4", "prologue/alerts": "^0.4",
"s1lentium/iptools": "^1.1", "s1lentium/iptools": "^1.1",
"spatie/laravel-fractal": "^5.6", "spatie/laravel-fractal": "^5.7",
"staudenmeir/belongs-to-through": "^2.6", "staudenmeir/belongs-to-through": "^2.9",
"symfony/yaml": "^4.0", "symfony/yaml": "^4.4",
"webmozart/assert": "^1.5" "webmozart/assert": "^1.7"
}, },
"require-dev": { "require-dev": {
"barryvdh/laravel-debugbar": "^3.2", "barryvdh/laravel-debugbar": "^3.2",
"barryvdh/laravel-ide-helper": "^2.6", "barryvdh/laravel-ide-helper": "^2.6",
"codedungeon/phpunit-result-printer": "0.25.1", "codedungeon/phpunit-result-printer": "0.25.1",
"friendsofphp/php-cs-fixer": "^2.15.1", "friendsofphp/php-cs-fixer": "^2.16.1",
"laravel/dusk": "^5.5", "laravel/dusk": "^5.11",
"php-mock/php-mock-phpunit": "^2.4", "php-mock/php-mock-phpunit": "^2.6",
"phpunit/phpunit": "^7" "phpunit/phpunit": "^7"
}, },
"autoload": { "autoload": {

2321
composer.lock generated

File diff suppressed because it is too large Load diff

29
config/backups.php Normal file
View file

@ -0,0 +1,29 @@
<?php
return [
// The backup driver to use for this Panel instance. All client generated server backups
// will be stored in this location by default. It is possible to change this once backups
// have been made, without losing data.
'driver' => env('APP_BACKUP_DRIVER', 'local'),
'disks' => [
// There is no configuration for the local disk for Wings. That configuration
// is determined by the Daemon configuration, and not the Panel.
'local' => [],
// Configuration for storing backups in Amazon S3.
's3' => [
'region' => '',
'access_key' => '',
'access_secret_key' => '',
// The S3 bucket to use for backups.
'bucket' => '',
// The location within the S3 bucket where backups will be stored. Backups
// are stored within a folder using the server's UUID as the name. Each
// backup for that server lives within that folder.
'location' => '',
],
],
];

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddThreadsColumnToServersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('servers', function (Blueprint $table) {
$table->string('threads')->nullable();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('threads');
});
}
}

View file

@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateBackupsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('backups', function (Blueprint $table) {
$table->bigIncrements('id');
$table->unsignedInteger('server_id');
$table->char('uuid', 36);
$table->string('name');
$table->text('ignored_files');
$table->string('disk');
$table->string('sha256_hash')->nullable();
$table->integer('bytes')->default(0);
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique('uuid');
$table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('backups');
}
}

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddTableServerTransfers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::dropIfExists('server_transfers');
Schema::create('server_transfers', function (Blueprint $table) {
$table->increments('id');
$table->integer('server_id')->unsigned();
$table->integer('old_node')->unsigned();
$table->integer('new_node')->unsigned();
$table->integer('old_allocation')->unsigned();
$table->integer('new_allocation')->unsigned();
$table->string('old_additional_allocations')->nullable();
$table->string('new_additional_allocations')->nullable();
$table->timestamps();
});
Schema::table('server_transfers', function (Blueprint $table) {
$table->foreign('server_id')->references('id')->on('servers');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('server_transfers');
}
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddSuccessfulColumnToServerTransfers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->tinyInteger('successful')->unsigned()->default(0);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('server_transfers', function (Blueprint $table) {
$table->dropColumn('successful');
});
}
}

View file

@ -0,0 +1,56 @@
$(document).ready(function () {
$('#pNodeId').select2({
placeholder: 'Select a Node',
}).change();
$('#pAllocation').select2({
placeholder: 'Select a Default Allocation',
});
$('#pAllocationAdditional').select2({
placeholder: 'Select Additional Allocations',
});
});
$('#pNodeId').on('change', function () {
let currentNode = $(this).val();
$.each(Pterodactyl.nodeData, function (i, v) {
if (v.id == currentNode) {
$('#pAllocation').html('').select2({
data: v.allocations,
placeholder: 'Select a Default Allocation',
});
updateAdditionalAllocations();
}
});
});
$('#pAllocation').on('change', function () {
updateAdditionalAllocations();
});
function updateAdditionalAllocations() {
let currentAllocation = $('#pAllocation').val();
let currentNode = $('#pNodeId').val();
$.each(Pterodactyl.nodeData, function (i, v) {
if (v.id == currentNode) {
let allocations = [];
for (let i = 0; i < v.allocations.length; i++) {
const allocation = v.allocations[i];
if (allocation.id != currentAllocation) {
allocations.push(allocation);
}
}
$('#pAllocationAdditional').html('').select2({
data: allocations,
placeholder: 'Select Additional Allocations',
});
}
});
}

View file

@ -27,5 +27,8 @@ return [
'details_updated' => 'Server details have been successfully updated.', 'details_updated' => 'Server details have been successfully updated.',
'docker_image_updated' => 'Successfully changed the default Docker image to use for this server. A reboot is required to apply this change.', 'docker_image_updated' => 'Successfully changed the default Docker image to use for this server. A reboot is required to apply this change.',
'node_required' => 'You must have at least one node configured before you can add a server to this panel.', 'node_required' => 'You must have at least one node configured before you can add a server to this panel.',
'transfer_nodes_required' => 'You must have at least two nodes configured before you can transfer servers.',
'transfer_started' => 'Server transfer has been started.',
'transfer_not_viable' => 'The node you selected is not viable for this transfer.',
], ],
]; ];

View file

@ -1,9 +1,9 @@
import { rawDataToServerObject, Server } from '@/api/server/getServer'; import { rawDataToServerObject, Server } from '@/api/server/getServer';
import http, { getPaginationSet, PaginatedResult } from '@/api/http'; import http, { getPaginationSet, PaginatedResult } from '@/api/http';
export default (): Promise<PaginatedResult<Server>> => { export default (query?: string): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client`, { params: { include: [ 'allocation' ] } }) http.get(`/api/client`, { params: { include: [ 'allocation' ], query } })
.then(({ data }) => resolve({ .then(({ data }) => resolve({
items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)), items: (data.data || []).map((datum: any) => rawDataToServerObject(datum.attributes)),
pagination: getPaginationSet(data.meta.pagination), pagination: getPaginationSet(data.meta.pagination),

View file

@ -1,7 +1,7 @@
import { SubuserPermission } from '@/state/server/subusers'; import { PanelPermissions } from '@/state/permissions';
import http from '@/api/http'; import http from '@/api/http';
export default (): Promise<SubuserPermission[]> => { export default (): Promise<PanelPermissions> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/permissions`) http.get(`/api/client/permissions`)
.then(({ data }) => resolve(data.attributes.permissions)) .then(({ data }) => resolve(data.attributes.permissions))

View file

@ -0,0 +1,12 @@
import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups';
import http from '@/api/http';
export default (uuid: string, name?: string, ignore?: string): Promise<ServerBackup> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/backups`, {
name, ignore,
})
.then(({ data }) => resolve(rawDataToServerBackup(data)))
.catch(reject);
});
};

View file

@ -0,0 +1,32 @@
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
export interface ServerBackup {
uuid: string;
name: string;
ignoredFiles: string;
sha256Hash: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
name: attributes.name,
ignoredFiles: attributes.ignored_files,
sha256Hash: attributes.sha256_hash,
bytes: attributes.bytes,
createdAt: new Date(attributes.created_at),
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
});
export default (uuid: string, page?: number | string): Promise<PaginatedResult<ServerBackup>> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/backups`, { params: { page } })
.then(({ data }) => resolve({
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
}))
.catch(reject);
});
};

View file

@ -24,6 +24,7 @@ export interface Server {
disk: number; disk: number;
io: number; io: number;
cpu: number; cpu: number;
threads: string;
}; };
featureLimits: { featureLimits: {
databases: number; databases: number;
@ -41,20 +42,23 @@ export const rawDataToServerObject = (data: any): Server => ({
port: data.sftp_details.port, port: data.sftp_details.port,
}, },
description: data.description ? ((data.description.length > 0) ? data.description : null) : null, description: data.description ? ((data.description.length > 0) ? data.description : null) : null,
allocations: [{ allocations: [ {
ip: data.allocation.ip, ip: data.allocation.ip,
alias: null, alias: null,
port: data.allocation.port, port: data.allocation.port,
default: true, default: true,
}], } ],
limits: { ...data.limits }, limits: { ...data.limits },
featureLimits: { ...data.feature_limits }, featureLimits: { ...data.feature_limits },
}); });
export default (uuid: string): Promise<Server> => { export default (uuid: string): Promise<[ Server, string[] ]> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}`) http.get(`/api/client/servers/${uuid}`)
.then(response => resolve(rawDataToServerObject(response.data.attributes))) .then(({ data }) => resolve([
rawDataToServerObject(data.attributes),
data.meta?.is_server_owner ? ['*'] : (data.meta?.user_permissions || []),
]))
.catch(reject); .catch(reject);
}); });
}; };

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (uuid: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/settings/reinstall`)
.then(() => resolve())
.catch(reject);
});
}

View file

@ -0,0 +1,18 @@
import http from '@/api/http';
import { rawDataToServerSubuser } from '@/api/server/users/getServerSubusers';
import { Subuser } from '@/state/server/subusers';
interface Params {
email: string;
permissions: string[];
}
export default (uuid: string, params: Params, subuser?: Subuser): Promise<Subuser> => {
return new Promise((resolve, reject) => {
http.post(`/api/client/servers/${uuid}/users${subuser ? `/${subuser.uuid}` : ''}`, {
...params,
})
.then(data => resolve(rawDataToServerSubuser(data.data)))
.catch(reject);
});
}

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (uuid: string, userId: string): Promise<void> => {
return new Promise((resolve, reject) => {
http.delete(`/api/client/servers/${uuid}/users/${userId}`)
.then(() => resolve())
.catch(reject);
});
};

View file

@ -8,13 +8,13 @@ export const rawDataToServerSubuser = (data: FractalResponseData): Subuser => ({
image: data.attributes.image, image: data.attributes.image,
twoFactorEnabled: data.attributes['2fa_enabled'], twoFactorEnabled: data.attributes['2fa_enabled'],
createdAt: new Date(data.attributes.created_at), createdAt: new Date(data.attributes.created_at),
permissions: data.attributes.relationships!.permissions.attributes.permissions, permissions: data.attributes.permissions || [],
can: permission => data.attributes.relationships!.permissions.attributes.permissions.indexOf(permission) >= 0, can: permission => (data.attributes.permissions || []).indexOf(permission) >= 0,
}); });
export default (uuid: string): Promise<Subuser[]> => { export default (uuid: string): Promise<Subuser[]> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/users`, { params: { include: [ 'permissions' ] } }) http.get(`/api/client/servers/${uuid}/users`)
.then(({ data }) => resolve((data.data || []).map(rawDataToServerSubuser))) .then(({ data }) => resolve((data.data || []).map(rawDataToServerSubuser)))
.catch(reject); .catch(reject);
}); });

View file

@ -8,6 +8,8 @@ import { faSwatchbook } from '@fortawesome/free-solid-svg-icons/faSwatchbook';
import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs'; import { faCogs } from '@fortawesome/free-solid-svg-icons/faCogs';
import { useStoreState } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import SearchContainer from '@/components/dashboard/search/SearchContainer';
export default () => { export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!); const user = useStoreState((state: ApplicationStore) => state.user.data!);
@ -22,6 +24,7 @@ export default () => {
</Link> </Link>
</div> </div>
<div className={'right-navigation'}> <div className={'right-navigation'}>
<SearchContainer/>
<NavLink to={'/'} exact={true}> <NavLink to={'/'} exact={true}>
<FontAwesomeIcon icon={faLayerGroup}/> <FontAwesomeIcon icon={faLayerGroup}/>
</NavLink> </NavLink>

View file

@ -4,25 +4,24 @@ import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer'; import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { Actions, useStoreActions } from 'easy-peasy'; import { Actions, useStoreActions } from 'easy-peasy';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import Field from '@/components/elements/Field';
import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
interface Values {
email: string;
}
export default () => { export default () => {
const [ isSubmitting, setSubmitting ] = React.useState(false);
const [ email, setEmail ] = React.useState('');
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const handleFieldUpdate = (e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value); const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
const handleSubmission = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSubmitting(true); setSubmitting(true);
clearFlashes(); clearFlashes();
requestPasswordResetEmail(email) requestPasswordResetEmail(email)
.then(response => { .then(response => {
setEmail(''); resetForm();
addFlash({ type: 'success', title: 'Success', message: response }); addFlash({ type: 'success', title: 'Success', message: response });
}) })
.catch(error => { .catch(error => {
@ -33,29 +32,31 @@ export default () => {
}; };
return ( return (
<div> <Formik
<h2 className={'text-center text-neutral-100 font-medium py-4'}> onSubmit={handleSubmission}
Request Password Reset initialValues={{ email: '' }}
</h2> validationSchema={object().shape({
<FlashMessageRender/> email: string().email('A valid email address must be provided to continue.')
<LoginFormContainer onSubmit={handleSubmission}> .required('A valid email address must be provided to continue.'),
<label htmlFor={'email'}>Email</label> })}
<input >
id={'email'} {({ isSubmitting }) => (
<LoginFormContainer
title={'Request Password Reset'}
className={'w-full flex'}
>
<Field
light={true}
label={'Email'}
description={'Enter your account email address to receive instructions on resetting your password.'}
name={'email'}
type={'email'} type={'email'}
required={true}
className={'input'}
value={email}
onChange={handleFieldUpdate}
autoFocus={true}
/> />
<p className={'input-help'}>
Enter your account email address to receive instructions on resetting your password.
</p>
<div className={'mt-6'}> <div className={'mt-6'}>
<button <button
type={'submit'}
className={'btn btn-primary btn-jumbo flex justify-center'} className={'btn btn-primary btn-jumbo flex justify-center'}
disabled={isSubmitting || email.length < 5} disabled={isSubmitting}
> >
{isSubmitting ? {isSubmitting ?
<div className={'spinner-circle spinner-sm spinner-white'}></div> <div className={'spinner-circle spinner-sm spinner-white'}></div>
@ -66,6 +67,7 @@ export default () => {
</div> </div>
<div className={'mt-6 text-center'}> <div className={'mt-6 text-center'}>
<Link <Link
type={'button'}
to={'/auth/login'} to={'/auth/login'}
className={'text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'} className={'text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
> >
@ -73,6 +75,7 @@ export default () => {
</Link> </Link>
</div> </div>
</LoginFormContainer> </LoginFormContainer>
</div> )}
</Formik>
); );
}; };

View file

@ -2,7 +2,6 @@ import React, { useRef } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom'; import { Link, RouteComponentProps } from 'react-router-dom';
import login, { LoginData } from '@/api/auth/login'; import login, { LoginData } from '@/api/auth/login';
import LoginFormContainer from '@/components/auth/LoginFormContainer'; import LoginFormContainer from '@/components/auth/LoginFormContainer';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ActionCreator, Actions, useStoreActions, useStoreState } from 'easy-peasy'; import { ActionCreator, Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import { FormikProps, withFormik } from 'formik'; import { FormikProps, withFormik } from 'formik';
@ -12,33 +11,12 @@ import { httpErrorToHuman } from '@/api/http';
import { FlashMessage } from '@/state/flashes'; import { FlashMessage } from '@/state/flashes';
import ReCAPTCHA from 'react-google-recaptcha'; import ReCAPTCHA from 'react-google-recaptcha';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import styled from 'styled-components';
import { breakpoint } from 'styled-components-breakpoint';
type OwnProps = RouteComponentProps & { type OwnProps = RouteComponentProps & {
clearFlashes: ActionCreator<void>; clearFlashes: ActionCreator<void>;
addFlash: ActionCreator<FlashMessage>; addFlash: ActionCreator<FlashMessage>;
} }
const Container = styled.div`
${breakpoint('sm')`
${tw`w-4/5 mx-auto`}
`};
${breakpoint('md')`
${tw`p-10`}
`};
${breakpoint('lg')`
${tw`w-3/5`}
`};
${breakpoint('xl')`
${tw`w-full`}
max-width: 660px;
`};
`;
const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handleSubmit }: OwnProps & FormikProps<LoginData>) => { const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handleSubmit }: OwnProps & FormikProps<LoginData>) => {
const ref = useRef<ReCAPTCHA | null>(null); const ref = useRef<ReCAPTCHA | null>(null);
const { enabled: recaptchaEnabled, siteKey } = useStoreState<ApplicationStore, any>(state => state.settings.data!.recaptcha); const { enabled: recaptchaEnabled, siteKey } = useStoreState<ApplicationStore, any>(state => state.settings.data!.recaptcha);
@ -56,12 +34,8 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
return ( return (
<React.Fragment> <React.Fragment>
{ref.current && ref.current.render()} {ref.current && ref.current.render()}
<h2 className={'text-center text-neutral-100 font-medium py-4'}>
Login to Continue
</h2>
<Container>
<FlashMessageRender className={'mb-2 px-1'}/>
<LoginFormContainer <LoginFormContainer
title={'Login to Continue'}
className={'w-full flex'} className={'w-full flex'}
onSubmit={submit} onSubmit={submit}
> >
@ -115,7 +89,6 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
</Link> </Link>
</div> </div>
</LoginFormContainer> </LoginFormContainer>
</Container>
</React.Fragment> </React.Fragment>
); );
}; };

View file

@ -1,11 +1,40 @@
import React, { forwardRef } from 'react'; import React, { forwardRef } from 'react';
import { Form } from 'formik'; import { Form } from 'formik';
import styled from 'styled-components';
import { breakpoint } from 'styled-components-breakpoint';
import FlashMessageRender from '@/components/FlashMessageRender';
type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>; type Props = React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement> & {
title?: string;
}
export default forwardRef<any, Props>(({ ...props }, ref) => ( const Container = styled.div`
<Form {...props}> ${breakpoint('sm')`
<div className={'md:flex w-full bg-white shadow-lg rounded-lg p-6 mx-1'}> ${tw`w-4/5 mx-auto`}
`};
${breakpoint('md')`
${tw`p-10`}
`};
${breakpoint('lg')`
${tw`w-3/5`}
`};
${breakpoint('xl')`
${tw`w-full`}
max-width: 700px;
`};
`;
export default forwardRef<HTMLFormElement, Props>(({ title, ...props }, ref) => (
<Container>
{title && <h2 className={'text-center text-neutral-100 font-medium py-4'}>
{title}
</h2>}
<FlashMessageRender className={'mb-2 px-1'}/>
<Form {...props} ref={ref}>
<div className={'md:flex w-full bg-white shadow-lg rounded-lg p-6 md:pl-0 mx-1'}>
<div className={'flex-none select-none mb-6 md:mb-0 self-center'}> <div className={'flex-none select-none mb-6 md:mb-0 self-center'}>
<img src={'/assets/pterodactyl.svg'} className={'block w-48 md:w-64 mx-auto'}/> <img src={'/assets/pterodactyl.svg'} className={'block w-48 md:w-64 mx-auto'}/>
</div> </div>
@ -14,4 +43,5 @@ export default forwardRef<any, Props>(({ ...props }, ref) => (
</div> </div>
</div> </div>
</Form> </Form>
</Container>
)); ));

View file

@ -5,91 +5,93 @@ import { Link } from 'react-router-dom';
import performPasswordReset from '@/api/auth/performPasswordReset'; import performPasswordReset from '@/api/auth/performPasswordReset';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer'; import LoginFormContainer from '@/components/auth/LoginFormContainer';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Actions, useStoreActions } from 'easy-peasy'; import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import { Formik, FormikHelpers } from 'formik';
import { object, ref, string } from 'yup';
import Field from '@/components/elements/Field';
type Props = Readonly<RouteComponentProps<{ token: string }> & {}>; type Props = Readonly<RouteComponentProps<{ token: string }> & {}>;
export default (props: Props) => { interface Values {
const [ isLoading, setIsLoading ] = useState(false); password: string;
passwordConfirmation: string;
}
export default ({ match, history, location }: Props) => {
const [ email, setEmail ] = useState(''); const [ email, setEmail ] = useState('');
const [ password, setPassword ] = useState('');
const [ passwordConfirm, setPasswordConfirm ] = useState('');
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const parsed = parse(props.location.search); const parsed = parse(location.search);
if (email.length === 0 && parsed.email) { if (email.length === 0 && parsed.email) {
setEmail(parsed.email as string); setEmail(parsed.email as string);
} }
const canSubmit = () => password && email && password.length >= 8 && password === passwordConfirm; const submit = ({ password, passwordConfirmation }: Values, { setSubmitting }: FormikHelpers<Values>) => {
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!password || !email || !passwordConfirm) {
return;
}
setIsLoading(true);
clearFlashes(); clearFlashes();
performPasswordReset(email, { token: match.params.token, password, passwordConfirmation })
performPasswordReset(email, {
token: props.match.params.token, password, passwordConfirmation: passwordConfirm,
})
.then(() => { .then(() => {
addFlash({ type: 'success', message: 'Your password has been reset, please login to continue.' }); // @ts-ignore
props.history.push('/auth/login'); window.location = '/';
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
setSubmitting(false);
addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) }); addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
}) });
.then(() => setIsLoading(false));
}; };
return ( return (
<Formik
onSubmit={submit}
initialValues={{
password: '',
passwordConfirmation: '',
}}
validationSchema={object().shape({
password: string().required('A new password is required.')
.min(8, 'Your new password should be at least 8 characters in length.'),
passwordConfirmation: string()
.required('Your new password does not match.')
.oneOf([ref('password'), null], 'Your new password does not match.'),
})}
>
{({ isSubmitting }) => (
<LoginFormContainer
title={'Reset Password'}
className={'w-full flex'}
>
<div> <div>
<h2 className={'text-center text-neutral-100 font-medium py-4'}>
Reset Password
</h2>
<FlashMessageRender/>
<LoginFormContainer onSubmit={submit}>
<label>Email</label> <label>Email</label>
<input className={'input'} value={email} disabled={true}/> <input className={'input'} value={email} disabled={true}/>
<div className={'mt-6'}>
<label htmlFor={'new_password'}>New Password</label>
<input
id={'new_password'}
className={'input'}
type={'password'}
required={true}
onChange={e => setPassword(e.target.value)}
/>
<p className={'input-help'}>
Passwords must be at least 8 characters in length.
</p>
</div> </div>
<div className={'mt-6'}> <div className={'mt-6'}>
<label htmlFor={'new_password_confirm'}>Confirm New Password</label> <Field
<input light={true}
id={'new_password_confirm'} label={'New Password'}
className={'input'} name={'password'}
type={'password'}
description={'Passwords must be at least 8 characters in length.'}
/>
</div>
<div className={'mt-6'}>
<Field
light={true}
label={'Confirm New Password'}
name={'passwordConfirmation'}
type={'password'} type={'password'}
required={true}
onChange={e => setPasswordConfirm(e.target.value)}
/> />
</div> </div>
<div className={'mt-6'}> <div className={'mt-6'}>
<button <button
type={'submit'} type={'submit'}
className={'btn btn-primary btn-jumbo'} className={'btn btn-primary btn-jumbo'}
disabled={isLoading || !canSubmit()} disabled={isSubmitting}
> >
{isLoading ? {isSubmitting ?
<Spinner size={'tiny'} className={'mx-auto'}/> <Spinner size={'tiny'} className={'mx-auto'}/>
: :
'Reset Password' 'Reset Password'
@ -105,6 +107,7 @@ export default (props: Props) => {
</Link> </Link>
</div> </div>
</LoginFormContainer> </LoginFormContainer>
</div> )}
</Formik>
); );
}; };

View file

@ -46,10 +46,11 @@ export default () => {
}; };
return ( return (
<div className={'my-10 flex'}> <div className={'my-10'}>
<FlashMessageRender byKey={'account'} className={'mb-4'}/> <FlashMessageRender byKey={'account'} className={'mb-4'}/>
<div className={'flex'}>
<ContentBox title={'Create API Key'} className={'flex-1'}> <ContentBox title={'Create API Key'} className={'flex-1'}>
<CreateApiKeyForm onKeyCreated={key => setKeys(s => ([...s!, key]))}/> <CreateApiKeyForm onKeyCreated={key => setKeys(s => ([ ...s!, key ]))}/>
</ContentBox> </ContentBox>
<ContentBox title={'API Keys'} className={'ml-10 flex-1'}> <ContentBox title={'API Keys'} className={'ml-10 flex-1'}>
<SpinnerOverlay visible={loading}/> <SpinnerOverlay visible={loading}/>
@ -62,7 +63,7 @@ export default () => {
doDeletion(deleteIdentifier); doDeletion(deleteIdentifier);
setDeleteIdentifier(''); setDeleteIdentifier('');
}} }}
onCanceled={() => setDeleteIdentifier('')} onDismissed={() => setDeleteIdentifier('')}
> >
Are you sure you wish to delete this API key? All requests using it will immediately be Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail. invalidated and will fail.
@ -75,7 +76,10 @@ export default () => {
</p> </p>
: :
keys.map(key => ( keys.map(key => (
<div key={key.identifier} className={'grey-row-box bg-neutral-600 mb-2 flex items-center'}> <div
key={key.identifier}
className={'grey-row-box bg-neutral-600 mb-2 flex items-center'}
>
<FontAwesomeIcon icon={faKey} className={'text-neutral-300'}/> <FontAwesomeIcon icon={faKey} className={'text-neutral-300'}/>
<div className={'ml-4 flex-1'}> <div className={'ml-4 flex-1'}>
<p className={'text-sm'}>{key.description}</p> <p className={'text-sm'}>{key.description}</p>
@ -95,7 +99,7 @@ export default () => {
> >
<FontAwesomeIcon <FontAwesomeIcon
icon={faTrashAlt} icon={faTrashAlt}
className={'text-neutral-400 hover:text-red-400 transition-color duration-150'} className={'text-neutral-400 hover:text-red-400 transition-colors duration-150'}
/> />
</button> </button>
</div> </div>
@ -103,5 +107,6 @@ export default () => {
} }
</ContentBox> </ContentBox>
</div> </div>
</div>
); );
}; };

View file

@ -0,0 +1,32 @@
import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSearch } from '@fortawesome/free-solid-svg-icons/faSearch';
import useEventListener from '@/plugins/useEventListener';
import SearchModal from '@/components/dashboard/search/SearchModal';
export default () => {
const [ visible, setVisible ] = useState(false);
useEventListener('keydown', (e: KeyboardEvent) => {
if ([ 'input', 'textarea' ].indexOf(((e.target as HTMLElement).tagName || 'input').toLowerCase()) < 0) {
if (!visible && e.key.toLowerCase() === 'k') {
setVisible(true);
}
}
});
return (
<>
{visible &&
<SearchModal
appear={true}
visible={visible}
onDismissed={() => setVisible(false)}
/>
}
<div className={'navigation-link'} onClick={() => setVisible(true)}>
<FontAwesomeIcon icon={faSearch}/>
</div>
</>
);
};

View file

@ -0,0 +1,123 @@
import React, { useEffect, useRef, useState } from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { Field, Form, Formik, FormikHelpers, useFormikContext } from 'formik';
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { object, string } from 'yup';
import { debounce } from 'lodash-es';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import InputSpinner from '@/components/elements/InputSpinner';
import getServers from '@/api/getServers';
import { Server } from '@/api/server/getServer';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http';
import { Link } from 'react-router-dom';
type Props = RequiredModalProps;
interface Values {
term: string;
}
const SearchWatcher = () => {
const { values, submitForm } = useFormikContext<Values>();
useEffect(() => {
if (values.term.length >= 3) {
submitForm();
}
}, [ values.term ]);
return null;
};
export default ({ ...props }: Props) => {
const ref = useRef<HTMLInputElement>(null);
const [ loading, setLoading ] = useState(false);
const [ servers, setServers ] = useState<Server[]>([]);
const isAdmin = useStoreState(state => state.user.data!.rootAdmin);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const search = debounce(({ term }: Values, { setSubmitting }: FormikHelpers<Values>) => {
setLoading(true);
setSubmitting(false);
clearFlashes('search');
getServers(term)
.then(servers => setServers(servers.items.filter((_, index) => index < 5)))
.catch(error => {
console.error(error);
addError({ key: 'search', message: httpErrorToHuman(error) });
})
.then(() => setLoading(false));
}, 500);
useEffect(() => {
if (props.visible) {
setTimeout(() => ref.current?.focus(), 250);
}
}, [ props.visible ]);
return (
<Formik
onSubmit={search}
validationSchema={object().shape({
term: string()
.min(3, 'Please enter at least three characters to begin searching.')
.required('A search term must be provided.'),
})}
initialValues={{ term: '' } as Values}
>
<Modal {...props}>
<Form>
<FormikFieldWrapper
name={'term'}
label={'Search term'}
description={
isAdmin
? 'Enter a server name, user email, or uuid to begin searching.'
: 'Enter a server name to begin searching.'
}
>
<SearchWatcher/>
<InputSpinner visible={loading}>
<Field
innerRef={ref}
name={'term'}
className={'input-dark'}
/>
</InputSpinner>
</FormikFieldWrapper>
</Form>
{servers.length > 0 &&
<div className={'mt-6'}>
{
servers.map(server => (
<Link
key={server.uuid}
to={`/server/${server.id}`}
className={'flex items-center block bg-neutral-900 p-4 rounded border-l-4 border-neutral-900 no-underline hover:shadow hover:border-cyan-500 transition-colors duration-250'}
onClick={() => props.onDismissed()}
>
<div>
<p className={'text-sm'}>{server.name}</p>
<p className={'mt-1 text-xs text-neutral-400'}>
{
server.allocations.filter(alloc => alloc.default).map(allocation => (
<span key={allocation.ip + allocation.port.toString()}>{allocation.alias || allocation.ip}:{allocation.port}</span>
))
}
</p>
</div>
<div className={'flex-1 text-right'}>
<span className={'text-xs py-1 px-2 bg-cyan-800 text-cyan-100 rounded'}>
{server.node}
</span>
</div>
</Link>
))
}
</div>
}
</Modal>
</Formik>
);
};

View file

@ -0,0 +1,26 @@
import React from 'react';
import { usePermissions } from '@/plugins/usePermissions';
interface Props {
action: string | string[];
matchAny?: boolean;
renderOnError?: React.ReactNode | null;
children: React.ReactNode;
}
const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
const can = usePermissions(action);
return (
<>
{
((matchAny && can.filter(p => p).length > 0) || (!matchAny && can.every(p => p))) ?
children
:
renderOnError
}
</>
);
};
export default Can;

View file

@ -1,25 +1,25 @@
import React from 'react'; import React from 'react';
import Modal from '@/components/elements/Modal'; import Modal, { RequiredModalProps } from '@/components/elements/Modal';
interface Props { type Props = {
title: string; title: string;
buttonText: string; buttonText: string;
children: string; children: string;
visible: boolean;
onConfirmed: () => void; onConfirmed: () => void;
onCanceled: () => void; showSpinnerOverlay?: boolean;
} } & RequiredModalProps;
const ConfirmationModal = ({ title, children, visible, buttonText, onConfirmed, onCanceled }: Props) => ( const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => (
<Modal <Modal
appear={true} appear={appear || true}
visible={visible} visible={visible}
onDismissed={() => onCanceled()} showSpinnerOverlay={showSpinnerOverlay}
onDismissed={() => onDismissed()}
> >
<h3 className={'mb-6'}>{title}</h3> <h3 className={'mb-6'}>{title}</h3>
<p className={'text-sm'}>{children}</p> <p className={'text-sm'}>{children}</p>
<div className={'flex items-center justify-end mt-8'}> <div className={'flex items-center justify-end mt-8'}>
<button className={'btn btn-secondary btn-sm'} onClick={() => onCanceled()}> <button className={'btn btn-secondary btn-sm'} onClick={() => onDismissed()}>
Cancel Cancel
</button> </button>
<button className={'btn btn-red btn-sm ml-4'} onClick={() => onConfirmed()}> <button className={'btn btn-red btn-sm ml-4'} onClick={() => onConfirmed()}>

View file

@ -1,14 +1,16 @@
import * as React from 'react'; import * as React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
type Props = Readonly<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & { type Props = Readonly<React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
title?: string; title?: string;
borderColor?: string; borderColor?: string;
showFlashes?: string | boolean; showFlashes?: string | boolean;
showLoadingOverlay?: boolean;
}>; }>;
const ContentBox = ({ title, borderColor, showFlashes, children, ...props }: Props) => ( const ContentBox = ({ title, borderColor, showFlashes, showLoadingOverlay, children, ...props }: Props) => (
<div {...props}> <div {...props}>
{title && <h2 className={'text-neutral-300 mb-4 px-4'}>{title}</h2>} {title && <h2 className={'text-neutral-300 mb-4 px-4'}>{title}</h2>}
{showFlashes && {showFlashes &&
@ -20,6 +22,7 @@ const ContentBox = ({ title, borderColor, showFlashes, children, ...props }: Pro
<div className={classNames('bg-neutral-700 p-4 rounded shadow-lg relative', borderColor, { <div className={classNames('bg-neutral-700 p-4 rounded shadow-lg relative', borderColor, {
'border-t-4': !!borderColor, 'border-t-4': !!borderColor,
})}> })}>
<SpinnerOverlay visible={showLoadingOverlay || false}/>
{children} {children}
</div> </div>
</div> </div>

View file

@ -4,6 +4,7 @@ import classNames from 'classnames';
interface OwnProps { interface OwnProps {
name: string; name: string;
light?: boolean;
label?: string; label?: string;
description?: string; description?: string;
validate?: (value: any) => undefined | string | Promise<any>; validate?: (value: any) => undefined | string | Promise<any>;
@ -11,19 +12,19 @@ interface OwnProps {
type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>; type Props = OwnProps & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'name'>;
const Field = ({ id, name, label, description, validate, className, ...props }: Props) => ( const Field = ({ id, name, light = false, label, description, validate, className, ...props }: Props) => (
<FormikField name={name} validate={validate}> <FormikField name={name} validate={validate}>
{ {
({ field, form: { errors, touched } }: FieldProps) => ( ({ field, form: { errors, touched } }: FieldProps) => (
<React.Fragment> <React.Fragment>
{label && {label &&
<label htmlFor={id} className={'input-dark-label'}>{label}</label> <label htmlFor={id} className={light ? undefined : 'input-dark-label'}>{label}</label>
} }
<input <input
id={id} id={id}
{...field} {...field}
{...props} {...props}
className={classNames((className || 'input-dark'), { className={classNames((className || (light ? 'input' : 'input-dark')), {
error: touched[field.name] && errors[field.name], error: touched[field.name] && errors[field.name],
})} })}
/> />

View file

@ -0,0 +1,22 @@
import React from 'react';
import Spinner from '@/components/elements/Spinner';
import { CSSTransition } from 'react-transition-group';
const InputSpinner = ({ visible, children }: { visible: boolean, children: React.ReactNode }) => (
<div className={'relative'}>
<CSSTransition
timeout={250}
in={visible}
unmountOnExit={true}
appear={true}
classNames={'fade'}
>
<div className={'absolute pin-r h-full flex items-center justify-end pr-3'}>
<Spinner size={'tiny'}/>
</div>
</CSSTransition>
{children}
</div>
);
export default InputSpinner;

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