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->repository->withoutFreshModel()->update($server->id, [
} 'installed' => Server::STATUS_INSTALLING,
]);
$this->database->beginTransaction();
$this->repository->withoutFreshModel()->update($server->id, [
'installed' => 0,
], 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,113 +17,87 @@ 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); try {
} $user = $this->userRepository->findFirstWhere([['email', '=', $email]]);
$this->connection->beginTransaction(); if ($server->owner_id === $user->id) {
try { throw new UserIsServerOwnerException(trans('exceptions.subusers.user_is_owner'));
$user = $this->userRepository->findFirstWhere([['email', '=', $email]]); }
if ($server->owner_id === $user->id) { $subuserCount = $this->subuserRepository->findCountWhere([['user_id', '=', $user->id], ['server_id', '=', $server->id]]);
throw new UserIsServerOwnerException(trans('exceptions.subusers.user_is_owner')); if ($subuserCount !== 0) {
throw new ServerSubuserExistsException(trans('exceptions.subusers.subuser_exists'));
}
} catch (RecordNotFoundException $exception) {
$user = $this->userCreationService->handle([
'email' => $email,
'username' => preg_replace('/([^\w\.-]+)/', '', strtok($email, '@')) . str_random(3),
'name_first' => 'Server',
'name_last' => 'Subuser',
'root_admin' => false,
]);
} }
$subuserCount = $this->subuserRepository->findCountWhere([['user_id', '=', $user->id], ['server_id', '=', $server->id]]); return $this->subuserRepository->create([
if ($subuserCount !== 0) { 'user_id' => $user->id,
throw new ServerSubuserExistsException(trans('exceptions.subusers.subuser_exists')); 'server_id' => $server->id,
} 'permissions' => array_unique($permissions),
} catch (RecordNotFoundException $exception) {
$username = preg_replace('/([^\w\.-]+)/', '', strtok($email, '@'));
$user = $this->userCreationService->handle([
'email' => $email,
'username' => $username . str_random(3),
'name_first' => 'Server',
'name_last' => 'Subuser',
'root_admin' => false,
]); ]);
} });
$subuser = $this->subuserRepository->create(['user_id' => $user->id, 'server_id' => $server->id]);
$this->keyCreationService->handle($server->id, $user->id);
$this->permissionService->handle($subuser->id, $permissions);
$this->connection->commit();
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": {

2323
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,46 +32,50 @@ 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 }) => (
type={'email'} <LoginFormContainer
required={true} title={'Request Password Reset'}
className={'input'} className={'w-full flex'}
value={email} >
onChange={handleFieldUpdate} <Field
autoFocus={true} light={true}
/> label={'Email'}
<p className={'input-help'}> description={'Enter your account email address to receive instructions on resetting your password.'}
Enter your account email address to receive instructions on resetting your password. name={'email'}
</p> type={'email'}
<div className={'mt-6'}> />
<button <div className={'mt-6'}>
className={'btn btn-primary btn-jumbo flex justify-center'} <button
disabled={isSubmitting || email.length < 5} type={'submit'}
> className={'btn btn-primary btn-jumbo flex justify-center'}
{isSubmitting ? disabled={isSubmitting}
<div className={'spinner-circle spinner-sm spinner-white'}></div> >
: {isSubmitting ?
'Send Email' <div className={'spinner-circle spinner-sm spinner-white'}></div>
} :
</button> 'Send Email'
</div> }
<div className={'mt-6 text-center'}> </button>
<Link </div>
to={'/auth/login'} <div className={'mt-6 text-center'}>
className={'text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'} <Link
> type={'button'}
Return to Login to={'/auth/login'}
</Link> className={'text-xs text-neutral-500 tracking-wide uppercase no-underline hover:text-neutral-700'}
</div> >
</LoginFormContainer> Return to Login
</div> </Link>
</div>
</LoginFormContainer>
)}
</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,66 +34,61 @@ 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'}> <LoginFormContainer
Login to Continue title={'Login to Continue'}
</h2> className={'w-full flex'}
<Container> onSubmit={submit}
<FlashMessageRender className={'mb-2 px-1'}/> >
<LoginFormContainer <label htmlFor={'username'}>Username or Email</label>
className={'w-full flex'} <Field
onSubmit={submit} type={'text'}
> id={'username'}
<label htmlFor={'username'}>Username or Email</label> name={'username'}
className={'input'}
/>
<div className={'mt-6'}>
<label htmlFor={'password'}>Password</label>
<Field <Field
type={'text'} type={'password'}
id={'username'} id={'password'}
name={'username'} name={'password'}
className={'input'} className={'input'}
/> />
<div className={'mt-6'}> </div>
<label htmlFor={'password'}>Password</label> <div className={'mt-6'}>
<Field <button
type={'password'} type={'submit'}
id={'password'} className={'btn btn-primary btn-jumbo'}
name={'password'} >
className={'input'} {isSubmitting ?
/> <Spinner size={'tiny'} className={'mx-auto'}/>
</div> :
<div className={'mt-6'}> 'Login'
<button }
type={'submit'} </button>
className={'btn btn-primary btn-jumbo'} </div>
> {recaptchaEnabled &&
{isSubmitting ? <ReCAPTCHA
<Spinner size={'tiny'} className={'mx-auto'}/> ref={ref}
: size={'invisible'}
'Login' sitekey={siteKey || '_invalid_key'}
} onChange={token => {
</button> ref.current && ref.current.reset();
</div> setFieldValue('recaptchaData', token);
{recaptchaEnabled && submitForm();
<ReCAPTCHA }}
ref={ref} onExpired={() => setFieldValue('recaptchaData', null)}
size={'invisible'} />
sitekey={siteKey || '_invalid_key'} }
onChange={token => { <div className={'mt-6 text-center'}>
ref.current && ref.current.reset(); <Link
setFieldValue('recaptchaData', token); to={'/auth/password'}
submitForm(); className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
}} >
onExpired={() => setFieldValue('recaptchaData', null)} Forgot password?
/> </Link>
} </div>
<div className={'mt-6 text-center'}> </LoginFormContainer>
<Link
to={'/auth/password'}
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
>
Forgot password?
</Link>
</div>
</LoginFormContainer>
</Container>
</React.Fragment> </React.Fragment>
); );
}; };

View file

@ -1,17 +1,47 @@
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`}
<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'}/>
${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'}>
<img src={'/assets/pterodactyl.svg'} className={'block w-48 md:w-64 mx-auto'}/>
</div>
<div className={'flex-1'}>
{props.children}
</div>
</div> </div>
<div className={'flex-1'}> </Form>
{props.children} </Container>
</div>
</div>
</Form>
)); ));

View file

@ -5,106 +5,109 @@ 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 (
<div> <Formik
<h2 className={'text-center text-neutral-100 font-medium py-4'}> onSubmit={submit}
Reset Password initialValues={{
</h2> password: '',
<FlashMessageRender/> passwordConfirmation: '',
<LoginFormContainer onSubmit={submit}> }}
<label>Email</label> validationSchema={object().shape({
<input className={'input'} value={email} disabled={true}/> password: string().required('A new password is required.')
<div className={'mt-6'}> .min(8, 'Your new password should be at least 8 characters in length.'),
<label htmlFor={'new_password'}>New Password</label> passwordConfirmation: string()
<input .required('Your new password does not match.')
id={'new_password'} .oneOf([ref('password'), null], 'Your new password does not match.'),
className={'input'} })}
type={'password'} >
required={true} {({ isSubmitting }) => (
onChange={e => setPassword(e.target.value)} <LoginFormContainer
/> title={'Reset Password'}
<p className={'input-help'}> className={'w-full flex'}
Passwords must be at least 8 characters in length. >
</p> <div>
</div> <label>Email</label>
<div className={'mt-6'}> <input className={'input'} value={email} disabled={true}/>
<label htmlFor={'new_password_confirm'}>Confirm New Password</label> </div>
<input <div className={'mt-6'}>
id={'new_password_confirm'} <Field
className={'input'} light={true}
type={'password'} label={'New Password'}
required={true} name={'password'}
onChange={e => setPasswordConfirm(e.target.value)} type={'password'}
/> description={'Passwords must be at least 8 characters in length.'}
</div> />
<div className={'mt-6'}> </div>
<button <div className={'mt-6'}>
type={'submit'} <Field
className={'btn btn-primary btn-jumbo'} light={true}
disabled={isLoading || !canSubmit()} label={'Confirm New Password'}
> name={'passwordConfirmation'}
{isLoading ? type={'password'}
<Spinner size={'tiny'} className={'mx-auto'}/> />
: </div>
'Reset Password' <div className={'mt-6'}>
} <button
</button> type={'submit'}
</div> className={'btn btn-primary btn-jumbo'}
<div className={'mt-6 text-center'}> disabled={isSubmitting}
<Link >
to={'/auth/login'} {isSubmitting ?
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'} <Spinner size={'tiny'} className={'mx-auto'}/>
> :
Return to Login 'Reset Password'
</Link> }
</div> </button>
</LoginFormContainer> </div>
</div> <div className={'mt-6 text-center'}>
<Link
to={'/auth/login'}
className={'text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600'}
>
Return to Login
</Link>
</div>
</LoginFormContainer>
)}
</Formik>
); );
}; };

View file

@ -46,62 +46,67 @@ 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'}/>
<ContentBox title={'Create API Key'} className={'flex-1'}> <div className={'flex'}>
<CreateApiKeyForm onKeyCreated={key => setKeys(s => ([...s!, key]))}/> <ContentBox title={'Create API Key'} className={'flex-1'}>
</ContentBox> <CreateApiKeyForm onKeyCreated={key => setKeys(s => ([ ...s!, key ]))}/>
<ContentBox title={'API Keys'} className={'ml-10 flex-1'}> </ContentBox>
<SpinnerOverlay visible={loading}/> <ContentBox title={'API Keys'} className={'ml-10 flex-1'}>
{deleteIdentifier && <SpinnerOverlay visible={loading}/>
<ConfirmationModal {deleteIdentifier &&
title={'Confirm key deletion'} <ConfirmationModal
buttonText={'Yes, delete key'} title={'Confirm key deletion'}
visible={true} buttonText={'Yes, delete key'}
onConfirmed={() => { visible={true}
doDeletion(deleteIdentifier); onConfirmed={() => {
setDeleteIdentifier(''); doDeletion(deleteIdentifier);
}} setDeleteIdentifier('');
onCanceled={() => setDeleteIdentifier('')} }}
> onDismissed={() => setDeleteIdentifier('')}
Are you sure you wish to delete this API key? All requests using it will immediately be >
invalidated and will fail. Are you sure you wish to delete this API key? All requests using it will immediately be
</ConfirmationModal> invalidated and will fail.
} </ConfirmationModal>
{ }
keys.length === 0 ? {
<p className={'text-center text-sm'}> keys.length === 0 ?
{loading ? 'Loading...' : 'No API keys exist for this account.'} <p className={'text-center text-sm'}>
</p> {loading ? 'Loading...' : 'No API keys exist for this account.'}
: </p>
keys.map(key => ( :
<div key={key.identifier} className={'grey-row-box bg-neutral-600 mb-2 flex items-center'}> keys.map(key => (
<FontAwesomeIcon icon={faKey} className={'text-neutral-300'}/> <div
<div className={'ml-4 flex-1'}> key={key.identifier}
<p className={'text-sm'}>{key.description}</p> className={'grey-row-box bg-neutral-600 mb-2 flex items-center'}
<p className={'text-2xs text-neutral-300 uppercase'}>
Last
used: {key.lastUsedAt ? format(key.lastUsedAt, 'MMM Do, YYYY HH:mm') : 'Never'}
</p>
</div>
<p className={'text-sm ml-4'}>
<code className={'font-mono py-1 px-2 bg-neutral-900 rounded'}>
{key.identifier}
</code>
</p>
<button
className={'ml-4 p-2 text-sm'}
onClick={() => setDeleteIdentifier(key.identifier)}
> >
<FontAwesomeIcon <FontAwesomeIcon icon={faKey} className={'text-neutral-300'}/>
icon={faTrashAlt} <div className={'ml-4 flex-1'}>
className={'text-neutral-400 hover:text-red-400 transition-color duration-150'} <p className={'text-sm'}>{key.description}</p>
/> <p className={'text-2xs text-neutral-300 uppercase'}>
</button> Last
</div> used: {key.lastUsedAt ? format(key.lastUsedAt, 'MMM Do, YYYY HH:mm') : 'Never'}
)) </p>
} </div>
</ContentBox> <p className={'text-sm ml-4'}>
<code className={'font-mono py-1 px-2 bg-neutral-900 rounded'}>
{key.identifier}
</code>
</p>
<button
className={'ml-4 p-2 text-sm'}
onClick={() => setDeleteIdentifier(key.identifier)}
>
<FontAwesomeIcon
icon={faTrashAlt}
className={'text-neutral-400 hover:text-red-400 transition-colors duration-150'}
/>
</button>
</div>
))
}
</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