Merge branch 'release/v0.7.4'

This commit is contained in:
Dane Everitt 2018-03-02 21:35:58 -06:00
commit 166812b816
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
94 changed files with 2248 additions and 756 deletions

View file

@ -3,6 +3,24 @@ This file is a running track of new features and fixes to each version of the pa
This project follows [Semantic Versioning](http://semver.org) guidelines.
## v0.7.4 (Derelict Dermodactylus)
### Fixed
* Fixes a bug when reinstalling a server that would not mark the server as installing, resulting in some UI issues.
* Handle 404 errors from missing models in the application API bindings correctly.
* Fix validation error returned when no environment variables are passed, even if there are no variables required.
* Fix improper permissions on `PATCH /api/servers/<id>/startup` endpoint which was preventing enditing any start variables.
* Should fix migration issues from 0.6 when there are more than API key in the database.
### Changed
* Changes order that validation of resource existence occurs in API requests to not try and use a non-existent model when validating data.
### Added
* Adds back client API for sending commands or power toggles to a server though the Panel API: `/api/client/servers/<identifier>`
* Added proper transformer for Packs and re-enabled missing includes on server.
* Added support for using Filesystem as a caching driver, although not recommended.
* Added support for user management of server databases.
* **Added bulk power management CLI interface to send start, stop, kill, restart actions to servers across configurable nodes.**
## v0.7.3 (Derelict Dermodactylus)
### Fixed
* Fixes server creation API endpoint not passing the provided `external_id` to the creation service.

View file

@ -22,6 +22,7 @@ class AppSettingsCommand extends Command
const ALLOWED_CACHE_DRIVERS = [
'redis' => 'Redis (recommended)',
'memcached' => 'Memcached',
'file' => 'Filesystem',
];
const ALLOWED_SESSION_DRIVERS = [

View file

@ -0,0 +1,121 @@
<?php
namespace Pterodactyl\Console\Commands\Server;
use Illuminate\Console\Command;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Validation\ValidationException;
use Illuminate\Validation\Factory as ValidatorFactory;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface;
class BulkPowerActionCommand extends Command
{
/**
* @var \Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface
*/
private $powerRepository;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* @var \Illuminate\Validation\Factory
*/
private $validator;
/**
* @var string
*/
protected $signature = 'p:server:bulk-power
{action : The action to perform (start, stop, restart, kill)}
{--servers= : A comma seperated list of servers.}
{--nodes= : A comma seperated list of nodes.}';
/**
* @var string
*/
protected $description = 'Perform bulk power management on large groupings of servers or nodes at once.';
/**
* BulkPowerActionCommand constructor.
*
* @param \Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface $powerRepository
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
* @param \Illuminate\Validation\Factory $validator
*/
public function __construct(
PowerRepositoryInterface $powerRepository,
ServerRepositoryInterface $repository,
ValidatorFactory $validator
) {
parent::__construct();
$this->powerRepository = $powerRepository;
$this->repository = $repository;
$this->validator = $validator;
}
/**
* Handle the bulk power request.
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\Repository\Daemon\InvalidPowerSignalException
*/
public function handle()
{
$action = $this->argument('action');
$nodes = empty($this->option('nodes')) ? [] : explode(',', $this->option('nodes'));
$servers = empty($this->option('servers')) ? [] : explode(',', $this->option('servers'));
$validator = $this->validator->make([
'action' => $action,
'nodes' => $nodes,
'servers' => $servers,
], [
'action' => 'string|in:start,stop,kill,restart',
'nodes' => 'array',
'nodes.*' => 'integer|min:1',
'servers' => 'array',
'servers.*' => 'integer|min:1',
]);
if ($validator->fails()) {
foreach ($validator->getMessageBag()->all() as $message) {
$this->output->error($message);
}
throw new ValidationException($validator);
}
$count = $this->repository->getServersForPowerActionCount($servers, $nodes);
if (! $this->confirm(trans('command/messages.server.power.confirm', ['action' => $action, 'count' => $count]))) {
return;
}
$bar = $this->output->createProgressBar($count);
$servers = $this->repository->getServersForPowerAction($servers, $nodes);
foreach ($servers as $server) {
$bar->clear();
try {
$this->powerRepository->setServer($server)->sendSignal($action);
} catch (RequestException $exception) {
$this->output->error(trans('command/messages.server.power.action_failed', [
'name' => $server->name,
'id' => $server->id,
'node' => $server->node->name,
'message' => $exception->getMessage(),
]));
}
$bar->advance();
$bar->display();
}
$this->line('');
}
}

View file

@ -117,4 +117,23 @@ interface ServerRepositoryInterface extends RepositoryInterface, SearchableInter
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function getByUuid(string $uuid): Server;
/**
* Return all of the servers that should have a power action performed aganist them.
*
* @param int[] $servers
* @param int[] $nodes
* @param bool $returnCount
* @return int|\Generator
*/
public function getServersForPowerAction(array $servers = [], array $nodes = [], bool $returnCount = false);
/**
* Return the total number of servers that will be affected by the query.
*
* @param int[] $servers
* @param int[] $nodes
* @return int
*/
public function getServersForPowerActionCount(array $servers = [], array $nodes = []): int;
}

View file

@ -0,0 +1,13 @@
<?php
namespace Pterodactyl\Exceptions\Service\Database;
use Pterodactyl\Exceptions\PterodactylException;
class DatabaseClientFeatureNotEnabledException extends PterodactylException
{
public function __construct()
{
parent::__construct('Client database creation is not enabled in this Panel.');
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace Pterodactyl\Exceptions\Service\Database;
use Pterodactyl\Exceptions\DisplayException;
class NoSuitableDatabaseHostException extends DisplayException
{
/**
* NoSuitableDatabaseHostException constructor.
*/
public function __construct()
{
parent::__construct('No database host was found that meets the requirements for this server.');
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace Pterodactyl\Exceptions\Service\Database;
use Pterodactyl\Exceptions\DisplayException;
class TooManyDatabasesException extends DisplayException
{
public function __construct()
{
parent::__construct('Operation aborted: creating a new database would put this server over the defined limit.');
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace Pterodactyl\Exceptions\Transformer;
use Pterodactyl\Exceptions\PterodactylException;
class InvalidTransformerLevelException extends PterodactylException
{
}

View file

@ -366,7 +366,7 @@ class NodesController extends Controller
public function setToken(Node $node)
{
$token = bin2hex(random_bytes(16));
$this->cache->tags(['Node:Configuration'])->put($token, $node->id, 5);
$this->cache->put('Node:Configuration:' . $token, $node->id, 5);
return response()->json(['token' => $token]);
}

View file

@ -498,15 +498,17 @@ class ServersController extends Controller
* @param \Illuminate\Http\Request $request
* @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Pterodactyl\Exceptions\DisplayException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @internal param int $id
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function updateBuild(Request $request, Server $server)
{
$this->buildModificationService->handle($server, $request->only([
'allocation_id', 'add_allocations', 'remove_allocations',
'memory', 'swap', 'io', 'cpu', 'disk',
'database_limit', 'allocation_limit',
]));
$this->alert->success(trans('admin/server.alerts.build_updated'))->flash();

View file

@ -3,17 +3,19 @@
namespace Pterodactyl\Http\Controllers\Api\Application;
use Illuminate\Http\Request;
use Webmozart\Assert\Assert;
use Illuminate\Http\Response;
use Illuminate\Container\Container;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Extensions\Spatie\Fractalistic\Fractal;
use Pterodactyl\Transformers\Api\Application\BaseTransformer;
abstract class ApplicationApiController extends Controller
{
/**
* @var \Illuminate\Http\Request
*/
private $request;
protected $request;
/**
* @var \Pterodactyl\Extensions\Spatie\Fractalistic\Fractal
@ -61,6 +63,8 @@ abstract class ApplicationApiController extends Controller
$transformer = Container::getInstance()->make($abstract);
$transformer->setKey($this->request->attributes->get('api_key'));
Assert::isInstanceOf($transformer, BaseTransformer::class);
return $transformer;
}

View file

@ -2,6 +2,7 @@
namespace Pterodactyl\Http\Controllers\Api\Application\Servers;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Pterodactyl\Services\Servers\StartupModificationService;
use Pterodactyl\Transformers\Api\Application\ServerTransformer;
@ -40,7 +41,9 @@ class StartupController extends ApplicationApiController
*/
public function index(UpdateServerStartupRequest $request): array
{
$server = $this->modificationService->handle($request->getModel(Server::class), $request->validated());
$server = $this->modificationService
->setUserLevel(User::USER_LEVEL_ADMIN)
->handle($request->getModel(Server::class), $request->validated());
return $this->fractal->item($server)
->transformWith($this->getTransformer(ServerTransformer::class))

View file

@ -0,0 +1,29 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client;
use Webmozart\Assert\Assert;
use Illuminate\Container\Container;
use Pterodactyl\Transformers\Api\Client\BaseClientTransformer;
use Pterodactyl\Http\Controllers\Api\Application\ApplicationApiController;
abstract class ClientApiController extends ApplicationApiController
{
/**
* Return an instance of an application transformer.
*
* @param string $abstract
* @return \Pterodactyl\Transformers\Api\Client\BaseClientTransformer
*/
public function getTransformer(string $abstract)
{
/** @var \Pterodactyl\Transformers\Api\Client\BaseClientTransformer $transformer */
$transformer = Container::getInstance()->make($abstract);
Assert::isInstanceOf($transformer, BaseClientTransformer::class);
$transformer->setKey($this->request->attributes->get('api_key'));
$transformer->setUser($this->request->user());
return $transformer;
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client;
use Pterodactyl\Models\User;
use Pterodactyl\Transformers\Api\Client\ServerTransformer;
use Pterodactyl\Http\Requests\Api\Client\GetServersRequest;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
class ClientController extends ClientApiController
{
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* ClientController constructor.
*
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
*/
public function __construct(ServerRepositoryInterface $repository)
{
parent::__construct();
$this->repository = $repository;
}
/**
* Return all of the servers available to the client making the API
* request, including servers the user has access to as a subuser.
*
* @param \Pterodactyl\Http\Requests\Api\Client\GetServersRequest $request
* @return array
*/
public function index(GetServersRequest $request): array
{
$servers = $this->repository->filterUserAccessServers($request->user(), User::FILTER_LEVEL_SUBUSER);
return $this->fractal->collection($servers)
->transformWith($this->getTransformer(ServerTransformer::class))
->toArray();
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\RequestException;
use Pterodactyl\Services\DaemonKeys\DaemonKeyProviderService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\SendCommandRequest;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
use Pterodactyl\Contracts\Repository\Daemon\CommandRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\PreconditionFailedHttpException;
class CommandController extends ClientApiController
{
/**
* @var \Pterodactyl\Services\DaemonKeys\DaemonKeyProviderService
*/
private $keyProviderService;
/**
* @var \Pterodactyl\Contracts\Repository\Daemon\CommandRepositoryInterface
*/
private $repository;
/**
* CommandController constructor.
*
* @param \Pterodactyl\Contracts\Repository\Daemon\CommandRepositoryInterface $repository
* @param \Pterodactyl\Services\DaemonKeys\DaemonKeyProviderService $keyProviderService
*/
public function __construct(CommandRepositoryInterface $repository, DaemonKeyProviderService $keyProviderService)
{
parent::__construct();
$this->keyProviderService = $keyProviderService;
$this->repository = $repository;
}
/**
* Send a command to a running server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\SendCommandRequest $request
* @return \Illuminate\Http\Response
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function index(SendCommandRequest $request): Response
{
$server = $request->getModel(Server::class);
$token = $this->keyProviderService->handle($server, $request->user());
try {
$this->repository->setServer($server)
->setToken($token)
->send($request->input('command'));
} catch (RequestException $exception) {
if ($exception instanceof ClientException) {
if ($exception->getResponse() instanceof ResponseInterface && $exception->getResponse()->getStatusCode() === 412) {
throw new PreconditionFailedHttpException('Server is not online.');
}
}
throw new DaemonConnectionException($exception);
}
return $this->returnNoContent();
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Pterodactyl\Services\DaemonKeys\DaemonKeyProviderService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\SendPowerRequest;
use Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface;
class PowerController extends ClientApiController
{
/**
* @var \Pterodactyl\Services\DaemonKeys\DaemonKeyProviderService
*/
private $keyProviderService;
/**
* @var \Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface
*/
private $repository;
/**
* PowerController constructor.
*
* @param \Pterodactyl\Services\DaemonKeys\DaemonKeyProviderService $keyProviderService
* @param \Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface $repository
*/
public function __construct(DaemonKeyProviderService $keyProviderService, PowerRepositoryInterface $repository)
{
parent::__construct();
$this->keyProviderService = $keyProviderService;
$this->repository = $repository;
}
/**
* Send a power action to a server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\SendPowerRequest $request
* @return \Illuminate\Http\Response
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Pterodactyl\Exceptions\Repository\Daemon\InvalidPowerSignalException
*/
public function index(SendPowerRequest $request): Response
{
$server = $request->getModel(Server::class);
$token = $this->keyProviderService->handle($server, $request->user());
$this->repository->setServer($server)->setToken($token)->sendSignal($request->input('signal'));
return $this->returnNoContent();
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Pterodactyl\Models\Server;
use Pterodactyl\Transformers\Api\Client\ServerTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\GetServerRequest;
class ServerController extends ClientApiController
{
/**
* Transform an individual server into a response that can be consumed by a
* client using the API.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\GetServerRequest $request
* @return array
*/
public function index(GetServerRequest $request): array
{
return $this->fractal->item($request->getModel(Server::class))
->transformWith($this->getTransformer(ServerTransformer::class))
->toArray();
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Remote;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Http\Controllers\Controller;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class FileDownloadController extends Controller
{
/**
* @var \Illuminate\Contracts\Cache\Repository
*/
private $cache;
/**
* FileDownloadController constructor.
*
* @param \Illuminate\Contracts\Cache\Repository $cache
*/
public function __construct(CacheRepository $cache)
{
$this->cache = $cache;
}
/**
* Handle a request to authenticate a download using a token and return
* the path of the file to the daemon.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
public function index(Request $request): JsonResponse
{
$download = $this->cache->pull('Server:Downloads:' . $request->input('token', ''));
if (is_null($download)) {
throw new NotFoundHttpException('No file was found using the token provided.');
}
return response()->json([
'path' => array_get($download, 'path'),
'server' => array_get($download, 'server'),
]);
}
}

View file

@ -0,0 +1,109 @@
<?php
namespace Pterodactyl\Http\Controllers\Base;
use Illuminate\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Pterodactyl\Models\ApiKey;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Api\KeyCreationService;
use Pterodactyl\Http\Requests\Base\CreateClientApiKeyRequest;
use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface;
class ClientApiController extends Controller
{
/**
* @var \Prologue\Alerts\AlertsMessageBag
*/
private $alert;
/**
* @var \Pterodactyl\Services\Api\KeyCreationService
*/
private $creationService;
/**
* @var \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface
*/
private $repository;
/**
* ClientApiController constructor.
*
* @param \Prologue\Alerts\AlertsMessageBag $alert
* @param \Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface $repository
* @param \Pterodactyl\Services\Api\KeyCreationService $creationService
*/
public function __construct(AlertsMessageBag $alert, ApiKeyRepositoryInterface $repository, KeyCreationService $creationService)
{
$this->alert = $alert;
$this->creationService = $creationService;
$this->repository = $repository;
}
/**
* Return all of the API keys available to this user.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\View\View
*/
public function index(Request $request): View
{
return view('base.api.index', [
'keys' => $this->repository->getAccountKeys($request->user()),
]);
}
/**
* Render UI to allow creation of an API key.
*
* @return \Illuminate\View\View
*/
public function create(): View
{
return view('base.api.new');
}
/**
* Create the API key and return the user to the key listing page.
*
* @param \Pterodactyl\Http\Requests\Base\CreateClientApiKeyRequest $request
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
*/
public function store(CreateClientApiKeyRequest $request): RedirectResponse
{
$allowedIps = null;
if (! is_null($request->input('allowed_ips'))) {
$allowedIps = json_encode(explode(PHP_EOL, $request->input('allowed_ips')));
}
$this->creationService->setKeyType(ApiKey::TYPE_ACCOUNT)->handle([
'memo' => $request->input('memo'),
'allowed_ips' => $allowedIps,
'user_id' => $request->user()->id,
]);
$this->alert->success('A new client API key has been generated for your account.')->flash();
return redirect()->route('account.api');
}
/**
* Delete a client's API key from the panel.
*
* @param \Illuminate\Http\Request $request
* @param $identifier
* @return \Illuminate\Http\Response
*/
public function delete(Request $request, $identifier): Response
{
$this->repository->deleteAccountKey($request->user(), $identifier);
return response('', 204);
}
}

View file

@ -1,11 +1,4 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Http\Controllers\Daemon;
@ -17,28 +10,6 @@ use Pterodactyl\Http\Controllers\Controller;
class ActionController extends Controller
{
/**
* Handles download request from daemon.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function authenticateDownload(Request $request)
{
$download = Cache::tags(['Server:Downloads'])->pull($request->input('token'));
if (is_null($download)) {
return response()->json([
'error' => 'An invalid request token was recieved with this request.',
], 403);
}
return response()->json([
'path' => $download['path'],
'server' => $download['server'],
]);
}
/**
* Handles install toggle request from daemon.
*
@ -78,7 +49,7 @@ class ActionController extends Controller
*/
public function configuration(Request $request, $token)
{
$nodeId = Cache::tags(['Node:Configuration'])->pull($token);
$nodeId = Cache::pull('Node:Configuration:' . $token);
if (is_null($nodeId)) {
return response()->json(['error' => 'token_invalid'], 403);
}

View file

@ -4,34 +4,76 @@ namespace Pterodactyl\Http\Controllers\Server;
use Illuminate\View\View;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Prologue\Alerts\AlertsMessageBag;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Traits\Controllers\JavascriptInjection;
use Pterodactyl\Services\Databases\DatabasePasswordService;
use Pterodactyl\Services\Databases\DatabaseManagementService;
use Pterodactyl\Services\Databases\DeployServerDatabaseService;
use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface;
use Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface;
use Pterodactyl\Http\Requests\Server\Database\StoreServerDatabaseRequest;
use Pterodactyl\Http\Requests\Server\Database\DeleteServerDatabaseRequest;
class DatabaseController extends Controller
{
use JavascriptInjection;
/**
* @var \Prologue\Alerts\AlertsMessageBag
*/
private $alert;
/**
* @var \Pterodactyl\Services\Databases\DeployServerDatabaseService
*/
private $deployServerDatabaseService;
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface
*/
private $databaseHostRepository;
/**
* @var \Pterodactyl\Services\Databases\DatabaseManagementService
*/
private $managementService;
/**
* @var \Pterodactyl\Services\Databases\DatabasePasswordService
*/
protected $passwordService;
private $passwordService;
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface
*/
protected $repository;
private $repository;
/**
* DatabaseController constructor.
*
* @param \Pterodactyl\Services\Databases\DatabasePasswordService $passwordService
* @param \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface $repository
* @param \Prologue\Alerts\AlertsMessageBag $alert
* @param \Pterodactyl\Services\Databases\DeployServerDatabaseService $deployServerDatabaseService
* @param \Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface $databaseHostRepository
* @param \Pterodactyl\Services\Databases\DatabaseManagementService $managementService
* @param \Pterodactyl\Services\Databases\DatabasePasswordService $passwordService
* @param \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface $repository
*/
public function __construct(DatabasePasswordService $passwordService, DatabaseRepositoryInterface $repository)
{
public function __construct(
AlertsMessageBag $alert,
DeployServerDatabaseService $deployServerDatabaseService,
DatabaseHostRepositoryInterface $databaseHostRepository,
DatabaseManagementService $managementService,
DatabasePasswordService $passwordService,
DatabaseRepositoryInterface $repository
) {
$this->alert = $alert;
$this->databaseHostRepository = $databaseHostRepository;
$this->deployServerDatabaseService = $deployServerDatabaseService;
$this->managementService = $managementService;
$this->passwordService = $passwordService;
$this->repository = $repository;
}
@ -50,11 +92,42 @@ class DatabaseController extends Controller
$this->authorize('view-databases', $server);
$this->setRequest($request)->injectJavascript();
$canCreateDatabase = config('pterodactyl.client_features.databases.enabled');
$allowRandom = config('pterodactyl.client_features.databases.allow_random');
if ($this->databaseHostRepository->findCountWhere([['node_id', '=', $server->node_id]]) === 0) {
if ($canCreateDatabase && ! $allowRandom) {
$canCreateDatabase = false;
}
}
$databases = $this->repository->getDatabasesForServer($server->id);
return view('server.databases.index', [
'databases' => $this->repository->getDatabasesForServer($server->id),
'allowCreation' => $canCreateDatabase,
'overLimit' => ! is_null($server->database_limit) && count($databases) >= $server->database_limit,
'databases' => $databases,
]);
}
/**
* Handle a request from a user to create a new database for the server.
*
* @param \Pterodactyl\Http\Requests\Server\Database\StoreServerDatabaseRequest $request
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Exception
* @throws \Pterodactyl\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException
*/
public function store(StoreServerDatabaseRequest $request): RedirectResponse
{
$this->deployServerDatabaseService->handle($request->getServer(), $request->validated());
$this->alert->success('Successfully created a new database.')->flash();
return redirect()->route('server.databases.index', $request->getServer()->uuidShort);
}
/**
* Handle a request to update the password for a specific database.
*
@ -74,4 +147,19 @@ class DatabaseController extends Controller
return response()->json(['password' => $password]);
}
/**
* Delete a database for this server from the SQL server and Panel database.
*
* @param \Pterodactyl\Http\Requests\Server\Database\DeleteServerDatabaseRequest $request
* @return \Illuminate\Http\Response
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function delete(DeleteServerDatabaseRequest $request): Response
{
$this->managementService->delete($request->attributes->get('database')->id);
return response('', Response::HTTP_NO_CONTENT);
}
}

View file

@ -9,6 +9,7 @@
namespace Pterodactyl\Http\Controllers\Server\Files;
use Ramsey\Uuid\Uuid;
use Illuminate\Http\Request;
use Illuminate\Cache\Repository;
use Illuminate\Http\RedirectResponse;
@ -46,9 +47,10 @@ class DownloadController extends Controller
$server = $request->attributes->get('server');
$this->authorize('download-files', $server);
$token = str_random(40);
$token = Uuid::uuid4()->toString();
$node = $server->getRelation('node');
$this->cache->tags(['Server:Downloads'])->put($token, ['server' => $server->uuid, 'path' => $file], 5);
$this->cache->put('Server:Downloads:' . $token, ['server' => $server->uuid, 'path' => $file], 5);
return redirect(sprintf('%s://%s:%s/v1/server/file/download/%s', $node->scheme, $node->fqdn, $node->daemonListen, $token));
}

View file

@ -2,6 +2,7 @@
namespace Pterodactyl\Http;
use Pterodactyl\Models\ApiKey;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\Authenticate;
use Pterodactyl\Http\Middleware\TrimStrings;
@ -14,11 +15,14 @@ use Pterodactyl\Http\Middleware\AdminAuthenticate;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Pterodactyl\Http\Middleware\LanguageMiddleware;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Pterodactyl\Http\Middleware\Api\AuthenticateKey;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Pterodactyl\Http\Middleware\AccessingValidServer;
use Pterodactyl\Http\Middleware\Api\SetSessionDriver;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Pterodactyl\Http\Middleware\RedirectIfAuthenticated;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Pterodactyl\Http\Middleware\Api\AuthenticateIPAccess;
use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@ -28,12 +32,10 @@ use Pterodactyl\Http\Middleware\Server\SubuserBelongsToServer;
use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication;
use Pterodactyl\Http\Middleware\Server\DatabaseBelongsToServer;
use Pterodactyl\Http\Middleware\Server\ScheduleBelongsToServer;
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateKey;
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateUser;
use Pterodactyl\Http\Middleware\Api\Application\SetSessionDriver;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateIPAccess;
use Pterodactyl\Http\Middleware\Api\Client\SubstituteClientApiBindings;
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateApplicationUser;
use Pterodactyl\Http\Middleware\DaemonAuthenticate as OldDaemonAuthenticate;
class Kernel extends HttpKernel
@ -71,8 +73,15 @@ class Kernel extends HttpKernel
'throttle:120,1',
ApiSubstituteBindings::class,
SetSessionDriver::class,
AuthenticateKey::class,
AuthenticateUser::class,
'api..key:' . ApiKey::TYPE_APPLICATION,
AuthenticateApplicationUser::class,
AuthenticateIPAccess::class,
],
'client-api' => [
'throttle:60,1',
SubstituteClientApiBindings::class,
SetSessionDriver::class,
'api..key:' . ApiKey::TYPE_ACCOUNT,
AuthenticateIPAccess::class,
],
'daemon' => [
@ -107,5 +116,8 @@ class Kernel extends HttpKernel
'server..database' => DatabaseBelongsToServer::class,
'server..subuser' => SubuserBelongsToServer::class,
'server..schedule' => ScheduleBelongsToServer::class,
// API Specific Middleware
'api..key' => AuthenticateKey::class,
];
}

View file

@ -32,6 +32,11 @@ class ApiSubstituteBindings extends SubstituteBindings
'user' => User::class,
];
/**
* @var \Illuminate\Routing\Router
*/
protected $router;
/**
* Perform substitution of route parameters without triggering
* a 404 error if a model is not found.
@ -45,7 +50,13 @@ class ApiSubstituteBindings extends SubstituteBindings
$route = $request->route();
foreach (self::$mappings as $key => $model) {
$this->router->model($key, $model);
if (! is_null($this->router->getBindingCallback($key))) {
continue;
}
$this->router->model($key, $model, function () use ($request) {
$request->attributes->set('is_missing_model', true);
});
}
$this->router->substituteBindings($route);

View file

@ -6,7 +6,7 @@ use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AuthenticateUser
class AuthenticateApplicationUser
{
/**
* Authenticate that the currently authenticated user is an administrator

View file

@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Application;
namespace Pterodactyl\Http\Middleware\Api;
use Closure;
use IPTools\IP;
@ -29,12 +29,12 @@ class AuthenticateIPAccess
}
$find = new IP($request->ip());
foreach ($model->allowed_ips as $ip) {
foreach (json_decode($model->allowed_ips) as $ip) {
if (Range::parse($ip)->contains($find)) {
return $next($request);
}
}
throw new AccessDeniedHttpException('This IP address does not have permission to access the API using these credentials.');
throw new AccessDeniedHttpException('This IP address (' . $request->ip() . ') does not have permission to access the API using these credentials.');
}
}

View file

@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Application;
namespace Pterodactyl\Http\Middleware\Api;
use Closure;
use Cake\Chronos\Chronos;
@ -50,12 +50,13 @@ class AuthenticateKey
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param int $keyType
* @return mixed
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function handle(Request $request, Closure $next)
public function handle(Request $request, Closure $next, int $keyType)
{
if (is_null($request->bearerToken())) {
throw new HttpException(401, null, null, ['WWW-Authenticate' => 'Bearer']);
@ -68,7 +69,7 @@ class AuthenticateKey
try {
$model = $this->repository->findFirstWhere([
['identifier', '=', $identifier],
['key_type', '=', ApiKey::TYPE_APPLICATION],
['key_type', '=', $keyType],
]);
} catch (RecordNotFoundException $exception) {
throw new AccessDeniedHttpException;

View file

@ -0,0 +1,27 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Client;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AuthenticateClientAccess
{
/**
* Authenticate that the currently authenticated user has permission
* to access the specified server.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
if (is_null($request->user())) {
throw new AccessDeniedHttpException('This account does not have permission to access this resource.');
}
return $next($request);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Client;
use Closure;
use Illuminate\Container\Container;
use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
class SubstituteClientApiBindings extends ApiSubstituteBindings
{
/**
* Perform substitution of route parameters without triggering
* a 404 error if a model is not found.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
// Override default behavior of the model binding to use a specific table
// column rather than the default 'id'.
$this->router->bind('server', function ($value) use ($request) {
try {
return Container::getInstance()->make(ServerRepositoryInterface::class)->findFirstWhere([
['uuidShort', '=', $value],
]);
} catch (RecordNotFoundException $ex) {
$request->attributes->set('is_missing_model', true);
return null;
}
});
return parent::handle($request, $next);
}
}

View file

@ -1,6 +1,6 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Application;
namespace Pterodactyl\Http\Middleware\Api;
use Closure;
use Illuminate\Http\Request;

View file

@ -38,8 +38,13 @@ class DatabaseBelongsToServer
public function handle(Request $request, Closure $next)
{
$server = $request->attributes->get('server');
$database = $request->input('database') ?? $request->route()->parameter('database');
$database = $this->repository->find($request->input('database'));
if (! is_digit($database)) {
throw new NotFoundHttpException;
}
$database = $this->repository->find($database);
if (is_null($database) || $database->server_id !== $server->id) {
throw new NotFoundHttpException;
}

View file

@ -3,7 +3,6 @@
namespace Pterodactyl\Http\Requests\Api\Application;
use Pterodactyl\Models\ApiKey;
use Illuminate\Database\Eloquent\Model;
use Pterodactyl\Services\Acl\Api\AdminAcl;
use Illuminate\Foundation\Http\FormRequest;
use Pterodactyl\Exceptions\PterodactylException;
@ -13,6 +12,14 @@ use Symfony\Component\Routing\Exception\InvalidParameterException;
abstract class ApplicationApiRequest extends FormRequest
{
/**
* Tracks if the request has been validated internally or not to avoid
* making duplicate validation calls.
*
* @var bool
*/
private $hasValidated = false;
/**
* The resource that should be checked when performing the authorization
* function for this request.
@ -96,6 +103,21 @@ abstract class ApplicationApiRequest extends FormRequest
return $this->route()->parameter($parameterKey);
}
/**
* Validate that the resource exists and can be accessed prior to booting
* the validator and attempting to use the data.
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
protected function prepareForValidation()
{
if (! $this->passesAuthorization()) {
$this->failedAuthorization();
}
$this->hasValidated = true;
}
/*
* Determine if the request passes the authorization check as well
* as the exists check.
@ -110,6 +132,14 @@ abstract class ApplicationApiRequest extends FormRequest
*/
protected function passesAuthorization()
{
// If we have already validated we do not need to call this function
// again. This is needed to work around Laravel's normal auth validation
// that occurs after validating the request params since we are doing auth
// validation in the prepareForValidation() function.
if ($this->hasValidated) {
return true;
}
if (! parent::passesAuthorization()) {
return false;
}

View file

@ -2,7 +2,6 @@
namespace Pterodactyl\Http\Requests\Api\Application\Servers;
use Pterodactyl\Models\Server;
use Pterodactyl\Services\Acl\Api\AdminAcl;
use Pterodactyl\Http\Requests\Api\Application\ApplicationApiRequest;
@ -17,16 +16,4 @@ class ServerWriteRequest extends ApplicationApiRequest
* @var int
*/
protected $permission = AdminAcl::WRITE;
/**
* Determine if the requested server exists on the Panel.
*
* @return bool
*/
public function resourceExists(): bool
{
$server = $this->route()->parameter('server');
return $server instanceof Server && $server->exists;
}
}

View file

@ -39,7 +39,7 @@ class StoreServerRequest extends ApplicationApiRequest
'pack' => $rules['pack_id'],
'docker_image' => $rules['image'],
'startup' => $rules['startup'],
'environment' => 'required|array',
'environment' => 'present|array',
'skip_scripts' => 'sometimes|boolean',
// Resource limitations

View file

@ -13,7 +13,7 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
*/
public function rules(): array
{
$rules = Server::getUpdateRulesForId($this->route()->parameter('server')->id);
$rules = Server::getUpdateRulesForId($this->getModel(Server::class)->id);
return [
'allocation' => $rules['allocation_id'],
@ -26,6 +26,9 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
'add_allocations.*' => 'integer',
'remove_allocations' => 'bail|array',
'remove_allocations.*' => 'integer',
'feature_limits' => 'required|array',
'feature_limits.databases' => $rules['database_limit'],
'feature_limits.allocations' => $rules['allocation_limit'],
];
}
@ -39,7 +42,9 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
$data = parent::validated();
$data['allocation_id'] = $data['allocation'];
unset($data['allocation']);
$data['database_limit'] = $data['feature_limits']['databases'];
$data['allocation_limit'] = $data['feature_limits']['allocations'];
unset($data['allocation'], $data['feature_limits']);
return $data;
}
@ -56,6 +61,8 @@ class UpdateServerBuildConfigurationRequest extends ServerWriteRequest
'remove_allocations' => 'allocations to remove',
'add_allocations.*' => 'allocation to add',
'remove_allocations.*' => 'allocation to remove',
'feature_limits.databases' => 'Database Limit',
'feature_limits.allocations' => 'Allocation Limit',
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client;
use Pterodactyl\Http\Requests\Api\Application\ApplicationApiRequest;
abstract class ClientApiRequest extends ApplicationApiRequest
{
/**
* Determine if the current user is authorized to perform
* the requested action aganist the API.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client;
class GetServersRequest extends ClientApiRequest
{
/**
* @return bool
*/
public function authorize(): bool
{
return true;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers;
use Pterodactyl\Models\Server;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class GetServerRequest extends ClientApiRequest
{
/**
* Determine if a client has permission to view this server on the API. This
* should never be false since this would be checking the same permission as
* resourceExists().
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* Determine if the user should even know that this server exists.
*
* @return bool
*/
public function resourceExists(): bool
{
return $this->user()->can('view-server', $this->getModel(Server::class));
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers;
use Pterodactyl\Models\Server;
class SendCommandRequest extends GetServerRequest
{
/**
* Determine if the API user has permission to perform this action.
*
* @return bool
*/
public function authorize(): bool
{
return $this->user()->can('send-command', $this->getModel(Server::class));
}
/**
* Rules to validate this request aganist.
*
* @return array
*/
public function rules(): array
{
return [
'command' => 'required|string|min:1',
];
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers;
use Pterodactyl\Models\Server;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class SendPowerRequest extends ClientApiRequest
{
/**
* Determine if the user has permission to send a power command to a server.
*
* @return bool
*/
public function authorize(): bool
{
return $this->user()->can('power-' . $this->input('signal', '_undefined'), $this->getModel(Server::class));
}
/**
* Rules to validate this request aganist.
*
* @return array
*/
public function rules(): array
{
return [
'signal' => 'required|string|in:start,stop,restart,kill',
];
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Pterodactyl\Http\Requests\Base;
use Pterodactyl\Http\Requests\FrontendUserFormRequest;
class CreateClientApiKeyRequest extends FrontendUserFormRequest
{
/**
* Validate the data being provided.
*
* @return array
*/
public function rules()
{
return [
'memo' => 'required|string|max:255',
'allowed_ips' => 'nullable|string',
];
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace Pterodactyl\Http\Requests\Server\Database;
use Pterodactyl\Http\Requests\Server\ServerFormRequest;
class DeleteServerDatabaseRequest extends ServerFormRequest
{
/**
* @return bool
*/
public function authorize()
{
if (! parent::authorize()) {
return false;
}
return config('pterodactyl.client_features.databases.enabled');
}
/**
* Return the user permission to validate this request aganist.
*
* @return string
*/
protected function permission(): string
{
return 'delete-database';
}
/**
* Rules to validate this request aganist.
*
* @return array
*/
public function rules()
{
return [];
}
}

View file

@ -0,0 +1,43 @@
<?php
namespace Pterodactyl\Http\Requests\Server\Database;
use Pterodactyl\Http\Requests\Server\ServerFormRequest;
class StoreServerDatabaseRequest extends ServerFormRequest
{
/**
* @return bool
*/
public function authorize()
{
if (! parent::authorize()) {
return false;
}
return config('pterodactyl.client_features.databases.enabled');
}
/**
* Return the user permission to validate this request aganist.
*
* @return string
*/
protected function permission(): string
{
return 'create-database';
}
/**
* Rules to validate this request aganist.
*
* @return array
*/
public function rules()
{
return [
'database' => 'required|string|min:1',
'remote' => 'required|string|regex:/^[0-9%.]{1,15}$/',
];
}
}

View file

@ -2,6 +2,7 @@
namespace Pterodactyl\Http\Requests\Server;
use Pterodactyl\Models\Server;
use Pterodactyl\Http\Requests\FrontendUserFormRequest;
abstract class ServerFormRequest extends FrontendUserFormRequest
@ -24,6 +25,11 @@ abstract class ServerFormRequest extends FrontendUserFormRequest
return false;
}
return $this->user()->can($this->permission(), $this->attributes->get('server'));
return $this->user()->can($this->permission(), $this->getServer());
}
public function getServer(): Server
{
return $this->attributes->get('server');
}
}

View file

@ -93,6 +93,8 @@ class Permission extends Model implements CleansAttributes, ValidableContract
'database' => [
'view-databases' => null,
'reset-db-password' => null,
'delete-database' => null,
'create-database' => null,
],
'file' => [
'access-sftp' => null,

View file

@ -69,6 +69,8 @@ class Server extends Model implements CleansAttributes, ValidableContract
'skip_scripts' => 'sometimes',
'image' => 'required',
'startup' => 'required',
'database_limit' => 'present',
'allocation_limit' => 'present',
];
/**
@ -93,6 +95,8 @@ class Server extends Model implements CleansAttributes, ValidableContract
'skip_scripts' => 'boolean',
'image' => 'string|max:255',
'installed' => 'boolean',
'database_limit' => 'nullable|integer|min:0',
'allocation_limit' => 'nullable|integer|min:0',
];
/**
@ -116,6 +120,8 @@ class Server extends Model implements CleansAttributes, ValidableContract
'egg_id' => 'integer',
'pack_id' => 'integer',
'installed' => 'integer',
'database_limit' => 'integer',
'allocation_limit' => 'integer',
];
/**

View file

@ -1,21 +1,9 @@
<?php
/**
* Pterodactyl - Panel
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
*
* This software is licensed under the terms of the MIT license.
* https://opensource.org/licenses/MIT
*/
namespace Pterodactyl\Providers;
use File;
use Cache;
use Carbon;
use Request;
use Pterodactyl\Models\ApiKey;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use Pterodactyl\Services\ApiKeyService;
class MacroServiceProvider extends ServiceProvider
{
@ -36,35 +24,5 @@ class MacroServiceProvider extends ServiceProvider
return round($size, ($i < 2) ? 0 : $precision) . ' ' . $units[$i];
});
Request::macro('apiKey', function () {
if (! Request::bearerToken()) {
return false;
}
$parts = explode('.', Request::bearerToken());
if (count($parts) === 2 && strlen($parts[0]) === ApiKeyService::PUB_CRYPTO_BYTES * 2) {
// Because the key itself isn't changing frequently, we simply cache this for
// 15 minutes to speed up the API and keep requests flowing.
return Cache::tags([
'ApiKeyMacro',
'ApiKeyMacro:Key:' . $parts[0],
])->remember('ApiKeyMacro.' . $parts[0], Carbon::now()->addMinutes(15), function () use ($parts) {
return ApiKey::where('public', $parts[0])->first();
});
}
return false;
});
Request::macro('apiKeyHasPermission', function ($permission) {
$key = Request::apiKey();
if (! $key) {
return false;
}
return Request::user()->can($permission, $key);
});
}
}

View file

@ -41,6 +41,10 @@ class RouteServiceProvider extends ServiceProvider
->namespace($this->namespace . '\Api\Application')
->group(base_path('routes/api-application.php'));
Route::middleware(['client-api'])->prefix('/api/client')
->namespace($this->namespace . '\Api\Client')
->group(base_path('routes/api-client.php'));
Route::middleware(['daemon'])->prefix('/api/remote')
->namespace($this->namespace . '\Api\Remote')
->group(base_path('routes/api-remote.php'));

View file

@ -12,7 +12,6 @@ class CommandRepository extends BaseRepository implements CommandRepositoryInter
*
* @param string $command
* @return \Psr\Http\Message\ResponseInterface
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function send(string $command): ResponseInterface
{

View file

@ -264,6 +264,45 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt
}
}
/**
* Return all of the servers that should have a power action performed aganist them.
*
* @param int[] $servers
* @param int[] $nodes
* @param bool $returnCount
* @return int|\Generator
*/
public function getServersForPowerAction(array $servers = [], array $nodes = [], bool $returnCount = false)
{
$instance = $this->getBuilder();
if (! empty($nodes) && ! empty($servers)) {
$instance->whereIn('id', $servers)->orWhereIn('node_id', $nodes);
} elseif (empty($nodes) && ! empty($servers)) {
$instance->whereIn('id', $servers);
} elseif (! empty($nodes) && empty($servers)) {
$instance->whereIn('node_id', $nodes);
}
if ($returnCount) {
return $instance->count();
}
return $instance->with('node')->cursor();
}
/**
* Return the total number of servers that will be affected by the query.
*
* @param int[] $servers
* @param int[] $nodes
* @return int
*/
public function getServersForPowerActionCount(array $servers = [], array $nodes = []): int
{
return $this->getServersForPowerAction($servers, $nodes, true);
}
/**
* Return an array of server IDs that a given user can access based
* on owner and subuser permissions.

View file

@ -13,22 +13,27 @@ class DatabaseManagementService
/**
* @var \Illuminate\Database\DatabaseManager
*/
protected $database;
private $database;
/**
* @var \Pterodactyl\Extensions\DynamicDatabaseConnection
*/
protected $dynamic;
private $dynamic;
/**
* @var \Illuminate\Contracts\Encryption\Encrypter
*/
protected $encrypter;
private $encrypter;
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface
*/
protected $repository;
private $repository;
/**
* @var bool
*/
protected $useRandomHost = false;
/**
* CreationService constructor.
@ -55,7 +60,7 @@ class DatabaseManagementService
*
* @param int $server
* @param array $data
* @return \Illuminate\Database\Eloquent\Model
* @return \Pterodactyl\Models\Database
*
* @throws \Exception
*/

View file

@ -0,0 +1,90 @@
<?php
namespace Pterodactyl\Services\Databases;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Database;
use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface;
use Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface;
use Pterodactyl\Exceptions\Service\Database\TooManyDatabasesException;
use Pterodactyl\Exceptions\Service\Database\NoSuitableDatabaseHostException;
use Pterodactyl\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException;
class DeployServerDatabaseService
{
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface
*/
private $databaseHostRepository;
/**
* @var \Pterodactyl\Services\Databases\DatabaseManagementService
*/
private $managementService;
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface
*/
private $repository;
/**
* ServerDatabaseCreationService constructor.
*
* @param \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface $repository
* @param \Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface $databaseHostRepository
* @param \Pterodactyl\Services\Databases\DatabaseManagementService $managementService
*/
public function __construct(
DatabaseRepositoryInterface $repository,
DatabaseHostRepositoryInterface $databaseHostRepository,
DatabaseManagementService $managementService
) {
$this->databaseHostRepository = $databaseHostRepository;
$this->managementService = $managementService;
$this->repository = $repository;
}
/**
* @param \Pterodactyl\Models\Server $server
* @param array $data
* @return \Pterodactyl\Models\Database
*
* @throws \Pterodactyl\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException
* @throws \Exception
*/
public function handle(Server $server, array $data): Database
{
if (! config('pterodactyl.client_features.databases.enabled')) {
throw new DatabaseClientFeatureNotEnabledException;
}
$databases = $this->repository->findCountWhere([['server_id', '=', $server->id]]);
if (! is_null($server->database_limit) && $databases >= $server->database_limit) {
throw new TooManyDatabasesException;
}
$allowRandom = config('pterodactyl.client_features.databases.allow_random');
$hosts = $this->databaseHostRepository->setColumns(['id'])->findWhere([
['node_id', '=', $server->node_id],
]);
if ($hosts->isEmpty() && ! $allowRandom) {
throw new NoSuitableDatabaseHostException;
}
if ($hosts->isEmpty()) {
$hosts = $this->databaseHostRepository->setColumns(['id'])->all();
if ($hosts->isEmpty()) {
throw new NoSuitableDatabaseHostException;
}
}
$host = $hosts->random();
return $this->managementService->create($server->id, [
'database_host_id' => $host->id,
'database' => array_get($data, 'database'),
'remote' => array_get($data, 'remote'),
]);
}
}

View file

@ -91,6 +91,8 @@ class BuildModificationService
'cpu' => array_get($data, 'cpu'),
'disk' => array_get($data, 'disk'),
'allocation_id' => array_get($data, 'allocation_id'),
'database_limit' => array_get($data, 'database_limit'),
'allocation_limit' => array_get($data, 'allocation_limit'),
]);
$allocations = $this->allocationRepository->findWhere([['server_id', '=', $server->id]]);

View file

@ -66,7 +66,7 @@ class ReinstallServerService
$this->database->beginTransaction();
$this->repository->withoutFreshModel()->update($server->id, [
'installed' => 0,
]);
], true, true);
try {
$this->daemonServerRepository->setServer($server)->reinstall();

View file

@ -7,6 +7,7 @@ use Pterodactyl\Models\ApiKey;
use Illuminate\Container\Container;
use League\Fractal\TransformerAbstract;
use Pterodactyl\Services\Acl\Api\AdminAcl;
use Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException;
abstract class BaseTransformer extends TransformerAbstract
{
@ -78,13 +79,19 @@ abstract class BaseTransformer extends TransformerAbstract
* @param string $abstract
* @param array $parameters
* @return \Pterodactyl\Transformers\Api\Application\BaseTransformer
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
protected function makeTransformer(string $abstract, array $parameters = []): self
protected function makeTransformer(string $abstract, array $parameters = [])
{
/** @var \Pterodactyl\Transformers\Api\Application\BaseTransformer $transformer */
$transformer = Container::getInstance()->makeWith($abstract, $parameters);
$transformer->setKey($this->getKey());
if (! $transformer instanceof self) {
throw new InvalidTransformerLevelException('Calls to ' . __METHOD__ . ' must return a transformer that is an instance of ' . __CLASS__);
}
return $transformer;
}

View file

@ -1,90 +1,40 @@
<?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\Transformers\Admin;
namespace Pterodactyl\Transformers\Api\Application;
use Illuminate\Http\Request;
use Pterodactyl\Models\Pack;
use League\Fractal\TransformerAbstract;
class PackTransformer extends TransformerAbstract
class PackTransformer extends BaseTransformer
{
/**
* List of resources that can be included.
* Return the resource name for the JSONAPI output.
*
* @var array
* @return string
*/
protected $availableIncludes = [
'option',
'servers',
];
/**
* The Illuminate Request object if provided.
*
* @var \Illuminate\Http\Request|bool
*/
protected $request;
/**
* Setup request object for transformer.
*
* @param \Illuminate\Http\Request|bool $request
*/
public function __construct($request = false)
public function getResourceName(): string
{
if (! $request instanceof Request && $request !== false) {
throw new DisplayException('Request passed to constructor must be of type Request or false.');
}
$this->request = $request;
return Pack::RESOURCE_NAME;
}
/**
* Return a generic transformed pack array.
* Return a transformed User model that can be consumed by external services.
*
* @param \Pterodactyl\Models\Pack $pack
* @return array
*/
public function transform($pack)
public function transform(Pack $pack): array
{
if (! $pack instanceof Pack) {
return ['id' => null];
}
return $pack->toArray();
}
/**
* Return the packs associated with this service.
*
* @return \Leauge\Fractal\Resource\Item
*/
public function includeOption(Pack $pack)
{
if ($this->request && ! $this->request->apiKeyHasPermission('option-view')) {
return;
}
return $this->item($pack->option, new OptionTransformer($this->request), 'option');
}
/**
* Return the packs associated with this service.
*
* @return \Leauge\Fractal\Resource\Collection
*/
public function includeServers(Pack $pack)
{
if ($this->request && ! $this->request->apiKeyHasPermission('server-list')) {
return;
}
return $this->collection($pack->servers, new ServerTransformer($this->request), 'server');
return [
'id' => $pack->id,
'uuid' => $pack->uuid,
'egg' => $pack->egg_id,
'name' => $pack->name,
'description' => $pack->description,
'is_selectable' => (bool) $pack->selectable,
'is_visible' => (bool) $pack->visible,
'is_locked' => (bool) $pack->locked,
'created_at' => $this->formatTimestamp($pack->created_at),
'updated_at' => $this->formatTimestamp($pack->updated_at),
];
}
}

View file

@ -75,6 +75,10 @@ class ServerTransformer extends BaseTransformer
'io' => $server->io,
'cpu' => $server->cpu,
],
'feature_limits' => [
'databases' => $server->database_limit,
'allocations' => $server->allocation_limit,
],
'user' => $server->owner_id,
'node' => $server->node_id,
'allocation' => $server->allocation_id,
@ -97,6 +101,8 @@ class ServerTransformer extends BaseTransformer
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeAllocations(Server $server)
{
@ -114,6 +120,8 @@ class ServerTransformer extends BaseTransformer
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeSubusers(Server $server)
{
@ -131,6 +139,8 @@ class ServerTransformer extends BaseTransformer
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeUser(Server $server)
{
@ -148,40 +158,49 @@ class ServerTransformer extends BaseTransformer
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
// public function includePack(Server $server)
// {
// if (! $this->authorize(AdminAcl::RESOURCE_PACKS)) {
// return $this->null();
// }
//
// $server->loadMissing('pack');
//
// return $this->item($server->getRelation('pack'), $this->makeTransformer(PackTransformer::class), 'pack');
// }
public function includePack(Server $server)
{
if (! $this->authorize(AdminAcl::RESOURCE_PACKS)) {
return $this->null();
}
$server->loadMissing('pack');
if (is_null($server->getRelation('pack'))) {
return $this->null();
}
return $this->item($server->getRelation('pack'), $this->makeTransformer(PackTransformer::class), 'pack');
}
/**
* Return a generic array with nest information for this server.
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
// public function includeNest(Server $server)
// {
// if (! $this->authorize(AdminAcl::RESOURCE_NESTS)) {
// return $this->null();
// }
//
// $server->loadMissing('nest');
//
// return $this->item($server->getRelation('nest'), $this->makeTransformer(NestTransformer::class), 'nest');
// }
public function includeNest(Server $server)
{
if (! $this->authorize(AdminAcl::RESOURCE_NESTS)) {
return $this->null();
}
$server->loadMissing('nest');
return $this->item($server->getRelation('nest'), $this->makeTransformer(NestTransformer::class), 'nest');
}
/**
* Return a generic array with service option information for this server.
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeOption(Server $server)
{
@ -199,6 +218,8 @@ class ServerTransformer extends BaseTransformer
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeVariables(Server $server)
{
@ -216,6 +237,8 @@ class ServerTransformer extends BaseTransformer
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeLocation(Server $server)
{
@ -233,6 +256,8 @@ class ServerTransformer extends BaseTransformer
*
* @param \Pterodactyl\Models\Server $server
* @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
public function includeNode(Server $server)
{

View file

@ -1,60 +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\Transformers\Admin;
use Illuminate\Http\Request;
use Pterodactyl\Models\Subuser;
use League\Fractal\TransformerAbstract;
class SubuserTransformer extends TransformerAbstract
{
/**
* The Illuminate Request object if provided.
*
* @var \Illuminate\Http\Request|bool
*/
protected $request;
/**
* Setup request object for transformer.
*
* @param \Illuminate\Http\Request|bool $request
*/
public function __construct($request = false)
{
if (! $request instanceof Request && $request !== false) {
throw new DisplayException('Request passed to constructor must be of type Request or false.');
}
$this->request = $request;
}
/**
* Return a generic transformed subuser array.
*
* @return array
*/
public function transform(Subuser $subuser)
{
if ($this->request && ! $this->request->apiKeyHasPermission('server-view')) {
return;
}
return [
'id' => $subuser->id,
'username' => $subuser->user->username,
'email' => $subuser->user->email,
'2fa' => (bool) $subuser->user->use_totp,
'permissions' => $subuser->permissions->pluck('permission'),
'created_at' => $subuser->created_at,
'updated_at' => $subuser->updated_at,
];
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\User;
use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException;
use Pterodactyl\Transformers\Api\Application\BaseTransformer as BaseApplicationTransformer;
abstract class BaseClientTransformer extends BaseApplicationTransformer
{
/**
* @var \Pterodactyl\Models\User
*/
private $user;
/**
* Return the user model of the user requesting this transformation.
*
* @return \Pterodactyl\Models\User
*/
public function getUser(): User
{
return $this->user;
}
/**
* Set the user model of the user requesting this transformation.
*
* @param \Pterodactyl\Models\User $user
*/
public function setUser(User $user)
{
$this->user = $user;
}
/**
* Determine if the API key loaded onto the transformer has permission
* to access a different resource. This is used when including other
* models on a transformation request.
*
* @param string $ability
* @param \Pterodactyl\Models\Server $server
* @return bool
*/
protected function authorize(string $ability, Server $server = null): bool
{
Assert::isInstanceOf($server, Server::class);
return $this->getUser()->can($ability, [$server]);
}
/**
* Create a new instance of the transformer and pass along the currently
* set API key.
*
* @param string $abstract
* @param array $parameters
* @return self
*
* @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/
protected function makeTransformer(string $abstract, array $parameters = [])
{
$transformer = parent::makeTransformer($abstract, $parameters);
if (! $transformer instanceof self) {
throw new InvalidTransformerLevelException('Calls to ' . __METHOD__ . ' must return a transformer that is an instance of ' . __CLASS__);
}
return $transformer;
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\Server;
class ServerTransformer extends BaseClientTransformer
{
/**
* @return string
*/
public function getResourceName(): string
{
return Server::RESOURCE_NAME;
}
/**
* Transform a server model into a representation that can be returned
* to a client.
*
* @param \Pterodactyl\Models\Server $server
* @return array
*/
public function transform(Server $server): array
{
return [
'server_owner' => $this->getKey()->user_id === $server->owner_id,
'identifier' => $server->uuidShort,
'uuid' => $server->uuid,
'name' => $server->name,
'description' => $server->description,
'limits' => [
'memory' => $server->memory,
'swap' => $server->swap,
'disk' => $server->disk,
'io' => $server->io,
'cpu' => $server->cpu,
],
'feature_limits' => [
'databases' => $server->database_limit,
'allocations' => $server->allocation_limit,
],
];
}
}

View file

@ -1,47 +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\Transformers\User;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Allocation;
use League\Fractal\TransformerAbstract;
class AllocationTransformer extends TransformerAbstract
{
/**
* Server eloquent model.
*
* @return \Pterodactyl\Models\Server
*/
protected $server;
/**
* Setup allocation transformer with access to server data.
*/
public function __construct(Server $server)
{
$this->server = $server;
}
/**
* Return a generic transformed allocation array.
*
* @return array
*/
public function transform(Allocation $allocation)
{
return [
'id' => $allocation->id,
'ip' => $allocation->alias,
'port' => $allocation->port,
'default' => ($allocation->id === $this->server->allocation_id),
];
}
}

View file

@ -1,35 +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\Transformers\User;
use Pterodactyl\Models\Server;
use League\Fractal\TransformerAbstract;
class OverviewTransformer extends TransformerAbstract
{
/**
* Return a generic transformed server array.
*
* @return array
*/
public function transform(Server $server)
{
return [
'id' => $server->uuidShort,
'uuid' => $server->uuid,
'name' => $server->name,
'node' => $server->node->name,
'ip' => $server->allocation->alias,
'port' => $server->allocation->port,
'service' => $server->service->name,
'option' => $server->option->name,
];
}
}

View file

@ -1,85 +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\Transformers\User;
use Pterodactyl\Models\Server;
use League\Fractal\TransformerAbstract;
class ServerTransformer extends TransformerAbstract
{
/**
* List of resources that can be included.
*
* @var array
*/
protected $availableIncludes = [
'allocations',
'subusers',
'stats',
];
/**
* Return a generic transformed server array.
*
* @return array
*/
public function transform(Server $server)
{
return [
'id' => $server->uuidShort,
'uuid' => $server->uuid,
'name' => $server->name,
'description' => $server->description,
'node' => $server->node->name,
'limits' => [
'memory' => $server->memory,
'swap' => $server->swap,
'disk' => $server->disk,
'io' => $server->io,
'cpu' => $server->cpu,
'oom_disabled' => (bool) $server->oom_disabled,
],
];
}
/**
* Return a generic array of allocations for this server.
*
* @return \Leauge\Fractal\Resource\Collection
*/
public function includeAllocations(Server $server)
{
$allocations = $server->allocations;
return $this->collection($allocations, new AllocationTransformer($server), 'allocation');
}
/**
* Return a generic array of subusers for this server.
*
* @return \Leauge\Fractal\Resource\Collection
*/
public function includeSubusers(Server $server)
{
$server->load('subusers.permissions', 'subusers.user');
return $this->collection($server->subusers, new SubuserTransformer, 'subuser');
}
/**
* Return a generic array of allocations for this server.
*
* @return \Leauge\Fractal\Resource\Item
*/
public function includeStats(Server $server)
{
return $this->item($server->guzzleClient(), new StatsTransformer, 'stat');
}
}

View file

@ -1,48 +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\Transformers\User;
use GuzzleHttp\Client;
use League\Fractal\TransformerAbstract;
use GuzzleHttp\Exception\ConnectException;
class StatsTransformer extends TransformerAbstract
{
/**
* Return a generic transformed subuser array.
*
* @return array
*/
public function transform(Client $client)
{
try {
$res = $client->request('GET', '/server', ['http_errors' => false]);
if ($res->getStatusCode() !== 200) {
return [
'error' => 'Error: HttpResponseException. Recieved non-200 HTTP status code from daemon: ' . $res->statusCode(),
];
}
$json = json_decode($res->getBody());
return [
'id' => 1,
'status' => $json->status,
'resources' => $json->proc,
];
} catch (ConnectException $ex) {
return [
'error' => 'Error: ConnectException. Unable to contact the daemon to request server status.',
'exception' => (config('app.debug')) ? $ex->getMessage() : null,
];
}
}
}

View file

@ -1,32 +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\Transformers\User;
use Pterodactyl\Models\Subuser;
use League\Fractal\TransformerAbstract;
class SubuserTransformer extends TransformerAbstract
{
/**
* Return a generic transformed subuser array.
*
* @return array
*/
public function transform(Subuser $subuser)
{
return [
'id' => $subuser->id,
'username' => $subuser->user->username,
'email' => $subuser->user->email,
'2fa' => (bool) $subuser->user->use_totp,
'permissions' => $subuser->permissions->pluck('permission'),
];
}
}

View file

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

View file

@ -163,6 +163,21 @@ return [
'in_context' => env('PHRASE_IN_CONTEXT', false),
],
/*
|--------------------------------------------------------------------------
| Language Editor
|--------------------------------------------------------------------------
|
| Set `PHRASE_IN_CONTEXT` to true to enable the PhaseApp in-context editor
| on this site which allows you to translate the panel, from the panel.
*/
'client_features' => [
'databases' => [
'enabled' => env('PTERODACTYL_CLIENT_DATABASES_ENABLED', true),
'allow_random' => env('PTERODACTYL_CLIENT_DATABASES_ALLOW_RANDOM', true),
],
],
/*
|--------------------------------------------------------------------------
| File Editor

View file

@ -16,7 +16,7 @@ class SetupTableForKeyEncryption extends Migration
public function up()
{
Schema::table('api_keys', function (Blueprint $table) {
$table->char('identifier', 16)->unique()->after('user_id');
$table->char('identifier', 16)->nullable()->unique()->after('user_id');
$table->dropUnique(['token']);
});

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddDatabaseAndPortLimitColumnsToServersTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('servers', function (Blueprint $table) {
$table->unsignedInteger('database_limit')->after('installed')->nullable()->default(0);
$table->unsignedInteger('allocation_limit')->after('installed')->nullable()->default(0);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn(['database_limit', 'allocation_limit']);
});
}
}

File diff suppressed because one or more lines are too long

View file

@ -37,6 +37,10 @@ return [
],
'server' => [
'rebuild_failed' => 'Rebuild request for ":name" (#:id) on node ":node" failed with error: :message',
'power' => [
'confirm' => 'You are about to perform a :action aganist :count servers. Do you wish to continue?',
'action_failed' => 'Power action request for ":name" (#:id) on node ":node" failed with error: :message',
],
],
'environment' => [
'mail' => [

View file

@ -248,6 +248,14 @@ return [
'title' => 'Reset Database Password',
'description' => 'Allows a user to reset passwords for databases.',
],
'delete_database' => [
'title' => 'Delete Databases',
'description' => 'Allows a user to delete databases for this server from the Panel.',
],
'create_database' => [
'title' => 'Create Database',
'description' => 'Allows a user to create additional databases for this server.',
],
],
],
'files' => [

View file

@ -15,7 +15,7 @@
@section('content')
<div class="row">
<form method="POST" action="{{ route('admin.api.new') }}">
<form method="POST" action="{{ route('account.api.new') }}">
<div class="col-sm-8 col-xs-12">
<div class="box box-primary">
<div class="box-header with-border">

View file

@ -111,7 +111,7 @@
<div class="form-group col-sm-4">
<label for="pSwap">Swap</label>
<div class="input-group">
<input type="text" value="{{ old('swap') }}" class="form-control" name="swap" id="pSwap" />
<input type="text" value="{{ old('swap', 0) }}" class="form-control" name="swap" id="pSwap" />
<span class="input-group-addon">MB</span>
</div>
</div>

View file

@ -89,50 +89,79 @@
</div>
</div>
<div class="col-sm-7">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Allocation Management</h3>
</div>
<div class="box-body">
<div class="form-group">
<label for="pAllocation" class="control-label">Game Port</label>
<select id="pAllocation" name="allocation_id" class="form-control">
@foreach ($assigned as $assignment)
<option value="{{ $assignment->id }}"
@if($assignment->id === $server->allocation_id)
selected="selected"
@endif
>{{ $assignment->alias }}:{{ $assignment->port }}</option>
@endforeach
</select>
<p class="text-muted small">The default connection address that will be used for this game server.</p>
</div>
<div class="form-group">
<label for="pAddAllocations" class="control-label">Assign Additional Ports</label>
<div>
<select name="add_allocations[]" class="form-control" multiple id="pAddAllocations">
@foreach ($unassigned as $assignment)
<option value="{{ $assignment->id }}">{{ $assignment->alias }}:{{ $assignment->port }}</option>
@endforeach
</select>
<div class="row">
<div class="col-xs-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Application Feature Limits</h3>
</div>
<p class="text-muted small">Please note that due to software limitations you cannot assign identical ports on different IPs to the same server.</p>
</div>
<div class="form-group">
<label for="pRemoveAllocations" class="control-label">Remove Additional Ports</label>
<div>
<select name="remove_allocations[]" class="form-control" multiple id="pRemoveAllocations">
@foreach ($assigned as $assignment)
<option value="{{ $assignment->id }}">{{ $assignment->alias }}:{{ $assignment->port }}</option>
@endforeach
</select>
<div class="box-body">
<div class="row">
<div class="form-group col-xs-6">
<label for="cpu" class="control-label">Database Limit</label>
<div>
<input type="text" name="database_limit" class="form-control" value="{{ old('database_limit', $server->database_limit) }}"/>
</div>
<p class="text-muted small">The total number of databases a user is allowed to create for this server. Leave blank to allow unlimmited.</p>
</div>
<div class="form-group col-xs-6">
<label for="cpu" class="control-label">Allocation Limit</label>
<div>
<input type="text" name="allocation_limit" class="form-control" value="{{ old('allocation_limit', $server->allocation_limit) }}"/>
</div>
<p class="text-muted small"><strong>This feature is not currently implemented.</strong> The total number of allocations a user is allowed to create for this server. Leave blank to allow unlimited.</p>
</div>
</div>
</div>
<p class="text-muted small">Simply select which ports you would like to remove from the list above. If you want to assign a port on a different IP that is already in use you can select it from the left and delete it here.</p>
</div>
</div>
<div class="box-footer">
{!! csrf_field() !!}
<button type="submit" class="btn btn-primary pull-right">Update Build Configuration</button>
<div class="col-xs-12">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">Allocation Management</h3>
</div>
<div class="box-body">
<div class="form-group">
<label for="pAllocation" class="control-label">Game Port</label>
<select id="pAllocation" name="allocation_id" class="form-control">
@foreach ($assigned as $assignment)
<option value="{{ $assignment->id }}"
@if($assignment->id === $server->allocation_id)
selected="selected"
@endif
>{{ $assignment->alias }}:{{ $assignment->port }}</option>
@endforeach
</select>
<p class="text-muted small">The default connection address that will be used for this game server.</p>
</div>
<div class="form-group">
<label for="pAddAllocations" class="control-label">Assign Additional Ports</label>
<div>
<select name="add_allocations[]" class="form-control" multiple id="pAddAllocations">
@foreach ($unassigned as $assignment)
<option value="{{ $assignment->id }}">{{ $assignment->alias }}:{{ $assignment->port }}</option>
@endforeach
</select>
</div>
<p class="text-muted small">Please note that due to software limitations you cannot assign identical ports on different IPs to the same server.</p>
</div>
<div class="form-group">
<label for="pRemoveAllocations" class="control-label">Remove Additional Ports</label>
<div>
<select name="remove_allocations[]" class="form-control" multiple id="pRemoveAllocations">
@foreach ($assigned as $assignment)
<option value="{{ $assignment->id }}">{{ $assignment->alias }}:{{ $assignment->port }}</option>
@endforeach
</select>
</div>
<p class="text-muted small">Simply select which ports you would like to remove from the list above. If you want to assign a port on a different IP that is already in use you can select it from the left and delete it here.</p>
</div>
</div>
<div class="box-footer">
{!! csrf_field() !!}
<button type="submit" class="btn btn-primary pull-right">Update Build Configuration</button>
</div>
</div>
</div>
</div>
</div>

View file

@ -18,57 +18,70 @@
@endsection
@section('content')
<div class="row">
<div class="col-xs-12">
<div class="box">
<div class="box-header">
<h3 class="box-title">@lang('base.api.index.list')</h3>
<div class="box-tools">
<a href="{{ route('account.api.new') }}"><button class="btn btn-primary btn-sm">Create New</button></a>
<div class="row">
<div class="col-xs-12">
<div class="box box-primary">
<div class="box-header with-border">
<h3 class="box-title">Credentials List</h3>
<div class="box-tools">
<a href="{{ route('account.api.new') }}" class="btn btn-sm btn-primary">Create New</a>
</div>
</div>
</div>
<div class="box-body table-responsive no-padding">
<table class="table table-hover">
<tbody>
<div class="box-body table-responsive no-padding">
<table class="table table-hover">
<tr>
<th>@lang('strings.memo')</th>
<th>@lang('strings.public_key')</th>
<th class="text-right hidden-sm hidden-xs">@lang('strings.last_used')</th>
<th class="text-right hidden-sm hidden-xs">@lang('strings.created')</th>
<th>Key</th>
<th>Memo</th>
<th>Last Used</th>
<th>Created</th>
<th></th>
</tr>
@foreach ($keys as $key)
@foreach($keys as $key)
<tr>
<td>
<code class="toggle-display" style="cursor:pointer" data-toggle="tooltip" data-placement="right" title="Click to Reveal">
<i class="fa fa-key"></i> &bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;
</code>
<code class="hidden" data-attr="api-key">
{{ $key->identifier }}{{ decrypt($key->token) }}
</code>
</td>
<td>{{ $key->memo }}</td>
<td><code>{{ $key->identifier . decrypt($key->token) }}</code></td>
<td class="text-right hidden-sm hidden-xs">
<td>
@if(!is_null($key->last_used_at))
@datetimeHuman($key->last_used_at)
@else
@else
&mdash;
@endif
</td>
<td class="text-right hidden-sm hidden-xs">
@datetimeHuman($key->created_at)
</td>
<td class="text-center">
<a href="#delete" class="text-danger" data-action="delete" data-attr="{{ $key->identifier }}"><i class="fa fa-trash"></i></a>
<td>@datetimeHuman($key->created_at)</td>
<td>
<a href="#" data-action="revoke-key" data-attr="{{ $key->identifier }}">
<i class="fa fa-trash-o text-danger"></i>
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</table>
</div>
</div>
</div>
</div>
</div>
@endsection
@section('footer-scripts')
@parent
<script>
$(document).ready(function() {
$('[data-action="delete"]').click(function (event) {
$(function () {
$('[data-toggle="tooltip"]').tooltip()
});
$('.toggle-display').on('click', function () {
$(this).parent().find('code[data-attr="api-key"]').removeClass('hidden');
$(this).hide();
});
$('[data-action="revoke-key"]').click(function (event) {
var self = $(this);
event.preventDefault();
swal({

View file

@ -8,55 +8,40 @@
<h1>@lang('base.api.new.header')<small>@lang('base.api.new.header_sub')</small></h1>
<ol class="breadcrumb">
<li><a href="{{ route('index') }}">@lang('strings.home')</a></li>
<li><a href="{{ route('account.api') }}">@lang('navigation.account.api_access')</a></li>
<li class="active">@lang('strings.new')</li>
<li class="active">@lang('navigation.account.api_access')</li>
<li class="active">@lang('base.api.new.header')</li>
</ol>
@endsection
@section('footer-scripts')
@parent
<script type="text/javascript">
$(document).ready(function () {
$('#selectAllCheckboxes').on('click', function () {
$('input[type=checkbox]').prop('checked', true);
});
$('#unselectAllCheckboxes').on('click', function () {
$('input[type=checkbox]').prop('checked', false);
});
})
</script>
@endsection
@section('content')
<form action="{{ route('account.api.new') }}" method="POST">
<div class="row">
<div class="col-xs-12">
<div class="box">
<div class="box-header with-border">
<div class="box-title">@lang('base.api.new.form_title')</div>
</div>
<div class="box-body">
<div class="row">
<div class="form-group col-xs-12 col-lg-6">
<label>@lang('base.api.new.descriptive_memo.title')</label>
<input type="text" name="memo" class="form-control" name />
<p class="help-block">@lang('base.api.new.descriptive_memo.description')</p>
</div>
<div class="form-group col-xs-12 col-lg-6">
<label>@lang('base.api.new.allowed_ips.title')</label>
<textarea name="allowed_ips" class="form-control" name></textarea>
<p class="help-block">@lang('base.api.new.allowed_ips.description')</p>
</div>
</div>
<div class="row">
<div class="col-xs-12">
{!! csrf_field() !!}
<button class="btn btn-success pull-right">@lang('strings.create') &rarr;</button>
<form method="POST" action="{{ route('account.api.new') }}">
<div class="col-sm-6 col-xs-12">
<div class="box box-primary">
<div class="box-body">
<div class="form-group">
<label class="control-label" for="memoField">Description <span class="field-required"></span></label>
<input id="memoField" type="text" name="memo" class="form-control" value="{{ old('memo') }}">
</div>
<p class="text-muted">Set an easy to understand description for this API key to help you identify it later on.</p>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xs-12">
<div class="box box-primary">
<div class="box-body">
<div class="form-group">
<label class="control-label" for="allowedIps">Allowed Connection IPs <span class="field-optional"></span></label>
<textarea id="allowedIps" name="allowed_ips" class="form-control" rows="5">{{ old('allowed_ips') }}</textarea>
</div>
<p class="text-muted">If you would like to limit this API key to specific IP addresses enter them above, one per line. CIDR notation is allowed for each IP address. Leave blank to allow any IP address.</p>
</div>
<div class="box-footer">
{{ csrf_field() }}
<button type="submit" class="btn btn-success btn-sm pull-right">Create</button>
</div>
</div>
</div>
</form>
</div>
</form>
@endsection

View file

@ -101,11 +101,11 @@
<i class="fa fa-lock"></i> <span>@lang('navigation.account.security_controls')</span>
</a>
</li>
{{--<li class="{{ (Route::currentRouteName() !== 'account.api' && Route::currentRouteName() !== 'account.api.new') ?: 'active' }}">--}}
{{--<a href="{{ route('account.api')}}">--}}
{{--<i class="fa fa-code"></i> <span>@lang('navigation.account.api_access')</span>--}}
{{--</a>--}}
{{--</li>--}}
<li class="{{ (Route::currentRouteName() !== 'account.api' && Route::currentRouteName() !== 'account.api.new') ?: 'active' }}">
<a href="{{ route('account.api')}}">
<i class="fa fa-code"></i> <span>@lang('navigation.account.api_access')</span>
</a>
</li>
<li class="{{ Route::currentRouteName() !== 'index' ?: 'active' }}">
<a href="{{ route('index')}}">
<i class="fa fa-server"></i> <span>@lang('navigation.account.my_servers')</span>

View file

@ -21,15 +21,10 @@
@section('content')
<div class="row">
<div class="col-xs-12">
<div class="{{ $allowCreation && Gate::allows('create-database', $server) ? 'col-xs-12 col-sm-8' : 'col-xs-12' }}">
<div class="box">
<div class="box-header with-border">
<h3 class="box-title">@lang('server.config.database.your_dbs')</h3>
@if(auth()->user()->root_admin)
<div class="box-tools">
<a href="{{ route('admin.servers.view.database', ['server' => $server->id]) }}" target="_blank" class="btn btn-sm btn-success">Create New</a>
</div>
@endif
</div>
@if(count($databases) > 0)
<div class="box-body table-responsive no-padding">
@ -55,11 +50,20 @@
</code>
</td>
<td class="middle"><code>{{ $database->host->host }}:{{ $database->host->port }}</code></td>
@can('reset-db-password', $server)
@if(Gate::allows('reset-db-password', $server) || Gate::allows('delete-database', $server))
<td>
<button class="btn btn-xs btn-primary pull-right" data-action="reset-password" data-id="{{ $database->id }}"><i class="fa fa-fw fa-refresh"></i> @lang('server.config.database.reset_password')</button>
@can('delete-database', $server)
<button class="btn btn-xs btn-danger pull-right" data-action="delete-database" data-id="{{ $database->id }}">
<i class="fa fa-fw fa-trash-o"></i>
</button>
@endcan
@can('reset-db-password', $server)
<button class="btn btn-xs btn-primary pull-right" style="margin-right:10px;" data-action="reset-password" data-id="{{ $database->id }}">
<i class="fa fa-fw fa-refresh"></i> @lang('server.config.database.reset_password')
</button>
@endcan
</td>
@endcan
@endif
</tr>
@endforeach
</tbody>
@ -69,17 +73,49 @@
<div class="box-body">
<div class="alert alert-info no-margin-bottom">
@lang('server.config.database.no_dbs')
@if(Auth::user()->root_admin === 1)
<a href="{{ route('admin.servers.view', [
'id' => $server->id,
'tab' => 'tab_database'
]) }}" target="_blank">@lang('server.config.database.add_db')</a>
@endif
</div>
</div>
@endif
</div>
</div>
@if($allowCreation && Gate::allows('create-database', $server))
<div class="col-xs-12 col-sm-4">
<div class="box box-success">
<div class="box-header with-border">
<h3 class="box-title">Create New Database</h3>
</div>
@if($overLimit)
<div class="box-body">
<div class="alert alert-danger no-margin">
You are currently using <strong>{{ count($databases) }}</strong> of your <strong>{{ $server->database_limit ?? '&infin;' }}</strong> allowed databases.
</div>
</div>
@else
<form action="{{ route('server.databases.new', $server->uuidShort) }}" method="POST">
<div class="box-body">
<div class="form-group">
<label for="pDatabaseName" class="control-label">Database</label>
<div class="input-group">
<span class="input-group-addon">s{{ $server->id }}_</span>
<input id="pDatabaseName" type="text" name="database" class="form-control" placeholder="database" />
</div>
</div>
<div class="form-group">
<label for="pRemote" class="control-label">Connections</label>
<input id="pRemote" type="text" name="remote" class="form-control" value="%" />
<p class="text-muted small">This should reflect the IP address that connections are allowed from. Uses standard MySQL notation. If unsure leave as <code>%</code>.</p>
</div>
</div>
<div class="box-footer">
{!! csrf_field() !!}
<p class="text-muted small">You are currently using <strong>{{ count($databases) }}</strong> of <strong>{{ $server->database_limit ?? '&infin;' }}</strong> databases. A username and password for this database will be randomly generated after form submission.</p>
<input type="submit" class="btn btn-sm btn-success pull-right" value="Create Database" />
</div>
</form>
@endif
</div>
</div>
@endif
</div>
@endsection
@ -126,5 +162,37 @@
});
});
@endcan
@can('delete-database', $server)
$('[data-action="delete-database"]').click(function (event) {
event.preventDefault();
var self = $(this);
swal({
title: '',
type: 'warning',
text: 'Are you sure that you want to delete this database? There is no going back, all data will immediately be removed.',
showCancelButton: true,
confirmButtonText: 'Delete',
confirmButtonColor: '#d9534f',
closeOnConfirm: false,
showLoaderOnConfirm: true,
}, function () {
$.ajax({
method: 'DELETE',
url: Router.route('server.databases.delete', { server: '{{ $server->uuidShort }}', database: self.data('id') }),
headers: { 'X-CSRF-TOKEN': $('meta[name="_token"]').attr('content') },
}).done(function () {
self.parent().parent().slideUp();
swal.close();
}).fail(function (jqXHR) {
console.error(jqXHR);
swal({
type: 'error',
title: 'Whoops!',
text: (typeof jqXHR.responseJSON.error !== 'undefined') ? jqXHR.responseJSON.error : 'An error occured while processing this request.'
});
});
});
});
@endcan
</script>
@endsection

28
routes/api-client.php Normal file
View file

@ -0,0 +1,28 @@
<?php
use Pterodactyl\Http\Middleware\Api\Client\AuthenticateClientAccess;
/*
|--------------------------------------------------------------------------
| Client Control API
|--------------------------------------------------------------------------
|
| Endpoint: /api/client
|
*/
Route::get('/', 'ClientController@index')->name('api.client.index');
/*
|--------------------------------------------------------------------------
| Client Control API
|--------------------------------------------------------------------------
|
| Endpoint: /api/client/servers/{server}
|
*/
Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateClientAccess::class]], function () {
Route::get('/', 'Servers\ServerController@index')->name('api.client.servers.view');
Route::post('/command', 'Servers\CommandController@index')->name('api.client.servers.command');
Route::post('/power', 'Servers\PowerController@index')->name('api.client.servers.power');
});

View file

@ -1,12 +1,7 @@
<?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
*/
Route::get('/authenticate/{token}', 'ValidateKeyController@index')->name('api.remote.authenticate');
Route::post('/download-file', 'FileDownloadController@index')->name('api.remote.download_file');
Route::group(['prefix' => '/eggs'], function () {
Route::get('/', 'EggRetrievalController@index')->name('api.remote.eggs');

View file

@ -1,27 +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
*/
//Route::get('/', 'CoreController@index')->name('api.user');
//
///*
//|--------------------------------------------------------------------------
//| Server Controller Routes
//|--------------------------------------------------------------------------
//|
//| Endpoint: /api/user/server/{server}
//|
//*/
//Route::group([
// 'prefix' => '/server/{server}',
// 'middleware' => 'server',
//], function () {
// Route::get('/', 'ServerController@index')->name('api.user.server');
//
// Route::post('/power', 'ServerController@power')->name('api.user.server.power');
// Route::post('/command', 'ServerController@command')->name('api.user.server.command');
//});

View file

@ -30,16 +30,15 @@ Route::group(['prefix' => 'account'], function () {
|
| Endpoint: /account/api
|
| Temporarily Disabled
*/
//Route::group(['prefix' => 'account/api'], function () {
// Route::get('/', 'AccountKeyController@index')->name('account.api');
// Route::get('/new', 'AccountKeyController@create')->name('account.api.new');
//
// Route::post('/new', 'AccountKeyController@store');
//
// Route::delete('/revoke/{identifier}', 'AccountKeyController@revoke')->name('account.api.revoke');
//});
Route::group(['prefix' => 'account/api'], function () {
Route::get('/', 'ClientApiController@index')->name('account.api');
Route::get('/new', 'ClientApiController@create')->name('account.api.new');
Route::post('/new', 'ClientApiController@store');
Route::delete('/revoke/{identifier}', 'ClientApiController@delete')->name('account.api.revoke');
});
/*
|--------------------------------------------------------------------------

View file

@ -10,5 +10,4 @@ Route::get('/packs/pull/{uuid}', 'PackController@pull')->name('daemon.pack.pull'
Route::get('/packs/pull/{uuid}/hash', 'PackController@hash')->name('daemon.pack.hash');
Route::get('/configure/{token}', 'ActionController@configuration')->name('daemon.configuration');
Route::post('/download', 'ActionController@authenticateDownload')->name('daemon.download');
Route::post('/install', 'ActionController@markInstall')->name('daemon.install');

View file

@ -38,7 +38,11 @@ Route::group(['prefix' => 'settings'], function () {
Route::group(['prefix' => 'databases'], function () {
Route::get('/', 'DatabaseController@index')->name('server.databases.index');
Route::post('/new', 'DatabaseController@store')->name('server.databases.new');
Route::patch('/password', 'DatabaseController@update')->middleware('server..database')->name('server.databases.password');
Route::delete('/delete/{database}', 'DatabaseController@delete')->middleware('server..database')->name('server.databases.delete');
});
/*

View file

@ -3,6 +3,7 @@
namespace Tests;
use Cake\Chronos\Chronos;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
@ -16,6 +17,7 @@ abstract class TestCase extends BaseTestCase
{
parent::setUp();
Hash::setRounds(4);
$this->setKnownUuidFactory();
}

View file

@ -0,0 +1,164 @@
<?php
namespace Tests\Unit\Commands\Server;
use Mockery as m;
use Pterodactyl\Models\Server;
use Illuminate\Validation\Factory;
use Tests\Unit\Commands\CommandTestCase;
use Pterodactyl\Console\Commands\Server\BulkPowerActionCommand;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface;
class BulkPowerActionCommandTest extends CommandTestCase
{
/**
* @var \Pterodactyl\Contracts\Repository\Daemon\PowerRepositoryInterface|\Mockery\Mock
*/
private $powerRepository;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface|\Mockery\Mock
*/
private $repository;
/**
* Setup test.
*/
public function setUp()
{
parent::setUp();
$this->powerRepository = m::mock(PowerRepositoryInterface::class);
$this->repository = m::mock(ServerRepositoryInterface::class);
}
/**
* Test that an action can be sent to all servers.
*/
public function testSendAction()
{
$servers = factory(Server::class)->times(2)->make();
$this->repository->shouldReceive('getServersForPowerActionCount')
->once()
->with([], [])
->andReturn(2);
$this->repository->shouldReceive('getServersForPowerAction')
->once()
->with([], [])
->andReturn($servers);
for ($i = 0; $i < count($servers); $i++) {
$this->powerRepository->shouldReceive('setServer->sendSignal')
->once()
->with('kill')
->andReturnNull();
}
$display = $this->runCommand($this->getCommand(), ['action' => 'kill'], ['yes']);
$this->assertNotEmpty($display);
$this->assertContains('2/2', $display);
$this->assertContains(trans('command/messages.server.power.confirm', ['action' => 'kill', 'count' => 2]), $display);
}
/**
* Test filtering servers and nodes.
*/
public function testSendWithFilters()
{
$server = factory(Server::class)->make();
$this->repository->shouldReceive('getServersForPowerActionCount')
->once()
->with([1, 2], [3, 4])
->andReturn(1);
$this->repository->shouldReceive('getServersForPowerAction')
->once()
->with([1, 2], [3, 4])
->andReturn([$server]);
$this->powerRepository->shouldReceive('setServer->sendSignal')
->once()
->with('kill')
->andReturnNull();
$display = $this->runCommand($this->getCommand(), [
'action' => 'kill',
'--servers' => '1,2',
'--nodes' => '3,4',
], ['yes']);
$this->assertNotEmpty($display);
$this->assertContains('1/1', $display);
$this->assertContains(trans('command/messages.server.power.confirm', ['action' => 'kill', 'count' => 1]), $display);
}
/**
* Test that sending empty options returns the expected results.
*/
public function testSendWithEmptyOptions()
{
$server = factory(Server::class)->make();
$this->repository->shouldReceive('getServersForPowerActionCount')
->once()
->with([], [])
->andReturn(1);
$this->repository->shouldReceive('getServersForPowerAction')->once()->with([], [])->andReturn([$server]);
$this->powerRepository->shouldReceive('setServer->sendSignal')->once()->with('kill')->andReturnNull();
$display = $this->runCommand($this->getCommand(), [
'action' => 'kill',
'--servers' => '',
'--nodes' => '',
], ['yes']);
$this->assertNotEmpty($display);
$this->assertContains('1/1', $display);
$this->assertContains(trans('command/messages.server.power.confirm', ['action' => 'kill', 'count' => 1]), $display);
}
/**
* Test that validation occurrs correctly.
*
* @param array $data
*
* @dataProvider validationFailureDataProvider
* @expectedException \Illuminate\Validation\ValidationException
*/
public function testValidationErrors(array $data)
{
$this->runCommand($this->getCommand(), $data);
}
/**
* Provide invalid data for the command.
*
* @return array
*/
public function validationFailureDataProvider(): array
{
return [
[['action' => 'hodor']],
[['action' => 'hodor', '--servers' => 'hodor']],
[['action' => 'kill', '--servers' => 'hodor']],
[['action' => 'kill', '--servers' => '1,2,3', '--nodes' => 'hodor']],
[['action' => 'kill', '--servers' => '1,2,3', '--nodes' => '1,2,test']],
];
}
/**
* Return an instance of the command with mocked dependencies.
*
* @return \Pterodactyl\Console\Commands\Server\BulkPowerActionCommand
*/
private function getCommand(): BulkPowerActionCommand
{
return new BulkPowerActionCommand($this->powerRepository, $this->repository, $this->app->make(Factory::class));
}
}

View file

@ -1,17 +1,10 @@
<?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 Tests\Unit\Http\Controllers\Server\Files;
use Mockery as m;
use phpmock\phpunit\PHPMock;
use Pterodactyl\Models\Node;
use Tests\Traits\MocksUuids;
use Pterodactyl\Models\Server;
use Illuminate\Cache\Repository;
use Tests\Unit\Http\Controllers\ControllerTestCase;
@ -19,7 +12,7 @@ use Pterodactyl\Http\Controllers\Server\Files\DownloadController;
class DownloadControllerTest extends ControllerTestCase
{
use PHPMock;
use MocksUuids;
/**
* @var \Illuminate\Cache\Repository|\Mockery\Mock
@ -48,16 +41,20 @@ class DownloadControllerTest extends ControllerTestCase
$this->setRequestAttribute('server', $server);
$controller->shouldReceive('authorize')->with('download-files', $server)->once()->andReturnNull();
$this->getFunctionMock('\\Pterodactyl\\Http\\Controllers\\Server\\Files', 'str_random')
->expects($this->once())->willReturn('randomString');
$this->cache->shouldReceive('tags')->with(['Server:Downloads'])->once()->andReturnSelf();
$this->cache->shouldReceive('put')->with('randomString', ['server' => $server->uuid, 'path' => '/my/file.txt'], 5)->once()->andReturnNull();
$this->cache->shouldReceive('put')
->once()
->with('Server:Downloads:' . $this->getKnownUuid(), ['server' => $server->uuid, 'path' => '/my/file.txt'], 5)
->andReturnNull();
$response = $controller->index($this->request, $server->uuidShort, '/my/file.txt');
$this->assertIsRedirectResponse($response);
$this->assertRedirectUrlEquals(sprintf(
'%s://%s:%s/v1/server/file/download/%s', $server->node->scheme, $server->node->fqdn, $server->node->daemonListen, 'randomString'
'%s://%s:%s/v1/server/file/download/%s',
$server->node->scheme,
$server->node->fqdn,
$server->node->daemonListen,
$this->getKnownUuid()
), $response);
}

View file

@ -1,10 +1,10 @@
<?php
namespace Tests\Unit\Http\Middleware\Api\Application;
namespace Tests\Unit\Http\Middleware\API;
use Pterodactyl\Models\ApiKey;
use Tests\Unit\Http\Middleware\MiddlewareTestCase;
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateIPAccess;
use Pterodactyl\Http\Middleware\Api\AuthenticateIPAccess;
class AuthenticateIPAccessTest extends MiddlewareTestCase
{
@ -25,7 +25,7 @@ class AuthenticateIPAccessTest extends MiddlewareTestCase
*/
public function testWithValidIP()
{
$model = factory(ApiKey::class)->make(['allowed_ips' => ['127.0.0.1']]);
$model = factory(ApiKey::class)->make(['allowed_ips' => '["127.0.0.1"]']);
$this->setRequestAttribute('api_key', $model);
$this->request->shouldReceive('ip')->withNoArgs()->once()->andReturn('127.0.0.1');
@ -38,7 +38,7 @@ class AuthenticateIPAccessTest extends MiddlewareTestCase
*/
public function testValidIPAganistCIDRRange()
{
$model = factory(ApiKey::class)->make(['allowed_ips' => ['192.168.1.1/28']]);
$model = factory(ApiKey::class)->make(['allowed_ips' => '["192.168.1.1/28"]']);
$this->setRequestAttribute('api_key', $model);
$this->request->shouldReceive('ip')->withNoArgs()->once()->andReturn('192.168.1.15');
@ -54,10 +54,10 @@ class AuthenticateIPAccessTest extends MiddlewareTestCase
*/
public function testWithInvalidIP()
{
$model = factory(ApiKey::class)->make(['allowed_ips' => ['127.0.0.1']]);
$model = factory(ApiKey::class)->make(['allowed_ips' => '["127.0.0.1"]']);
$this->setRequestAttribute('api_key', $model);
$this->request->shouldReceive('ip')->withNoArgs()->once()->andReturn('127.0.0.2');
$this->request->shouldReceive('ip')->withNoArgs()->twice()->andReturn('127.0.0.2');
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
}
@ -65,7 +65,7 @@ class AuthenticateIPAccessTest extends MiddlewareTestCase
/**
* Return an instance of the middleware to be used when testing.
*
* @return \Pterodactyl\Http\Middleware\Api\Application\AuthenticateIPAccess
* @return \Pterodactyl\Http\Middleware\Api\AuthenticateIPAccess
*/
private function getMiddleware(): AuthenticateIPAccess
{

View file

@ -1,6 +1,6 @@
<?php
namespace Tests\Unit\Http\Middleware\Api\Application;
namespace Tests\Unit\Http\Middleware\API;
use Mockery as m;
use Cake\Chronos\Chronos;
@ -8,10 +8,10 @@ use Pterodactyl\Models\ApiKey;
use Illuminate\Auth\AuthManager;
use Illuminate\Contracts\Encryption\Encrypter;
use Tests\Unit\Http\Middleware\MiddlewareTestCase;
use Pterodactyl\Http\Middleware\Api\AuthenticateKey;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface;
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateKey;
class AuthenticateKeyTest extends MiddlewareTestCase
{
@ -51,7 +51,7 @@ class AuthenticateKeyTest extends MiddlewareTestCase
$this->request->shouldReceive('bearerToken')->withNoArgs()->once()->andReturnNull();
try {
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions(), ApiKey::TYPE_APPLICATION);
} catch (HttpException $exception) {
$this->assertEquals(401, $exception->getStatusCode());
$this->assertEquals(['WWW-Authenticate' => 'Bearer'], $exception->getHeaders());
@ -68,7 +68,7 @@ class AuthenticateKeyTest extends MiddlewareTestCase
$this->request->shouldReceive('bearerToken')->withNoArgs()->twice()->andReturn('abcd1234');
$this->repository->shouldReceive('findFirstWhere')->andThrow(new RecordNotFoundException);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions(), ApiKey::TYPE_APPLICATION);
}
/**
@ -90,7 +90,30 @@ class AuthenticateKeyTest extends MiddlewareTestCase
'last_used_at' => Chronos::now(),
])->once()->andReturnNull();
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions(), ApiKey::TYPE_APPLICATION);
$this->assertEquals($model, $this->request->attributes->get('api_key'));
}
/**
* Test that a valid token can continue past the middleware when set as a user token.
*/
public function testValidTokenWithUserKey()
{
$model = factory(ApiKey::class)->make();
$this->request->shouldReceive('bearerToken')->withNoArgs()->twice()->andReturn($model->identifier . 'decrypted');
$this->repository->shouldReceive('findFirstWhere')->with([
['identifier', '=', $model->identifier],
['key_type', '=', ApiKey::TYPE_ACCOUNT],
])->once()->andReturn($model);
$this->encrypter->shouldReceive('decrypt')->with($model->token)->once()->andReturn('decrypted');
$this->auth->shouldReceive('guard->loginUsingId')->with($model->user_id)->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel->update')->with($model->id, [
'last_used_at' => Chronos::now(),
])->once()->andReturnNull();
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions(), ApiKey::TYPE_ACCOUNT);
$this->assertEquals($model, $this->request->attributes->get('api_key'));
}
@ -111,13 +134,13 @@ class AuthenticateKeyTest extends MiddlewareTestCase
])->once()->andReturn($model);
$this->encrypter->shouldReceive('decrypt')->with($model->token)->once()->andReturn('decrypted');
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions(), ApiKey::TYPE_APPLICATION);
}
/**
* Return an instance of the middleware with mocked dependencies for testing.
*
* @return \Pterodactyl\Http\Middleware\Api\Application\AuthenticateKey
* @return \Pterodactyl\Http\Middleware\Api\AuthenticateKey
*/
private function getMiddleware(): AuthenticateKey
{

View file

@ -1,13 +1,13 @@
<?php
namespace Tests\Unit\Http\Middleware\Api\Application;
namespace Tests\Unit\Http\Middleware\API;
use Mockery as m;
use Barryvdh\Debugbar\LaravelDebugbar;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Foundation\Application;
use Tests\Unit\Http\Middleware\MiddlewareTestCase;
use Pterodactyl\Http\Middleware\Api\Application\SetSessionDriver;
use Pterodactyl\Http\Middleware\Api\SetSessionDriver;
class SetSessionDriverTest extends MiddlewareTestCase
{
@ -60,7 +60,7 @@ class SetSessionDriverTest extends MiddlewareTestCase
/**
* Return an instance of the middleware with mocked dependencies for testing.
*
* @return \Pterodactyl\Http\Middleware\Api\Application\SetSessionDriver
* @return \Pterodactyl\Http\Middleware\Api\SetSessionDriver
*/
private function getMiddleware(): SetSessionDriver
{

View file

@ -1,9 +1,9 @@
<?php
namespace Tests\Unit\Http\Middleware\Api\Application;
namespace Tests\Unit\Http\Middleware\API\Application;
use Tests\Unit\Http\Middleware\MiddlewareTestCase;
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateUser;
use Pterodactyl\Http\Middleware\Api\Application\AuthenticateApplicationUser;
class AuthenticateUserTest extends MiddlewareTestCase
{
@ -44,10 +44,10 @@ class AuthenticateUserTest extends MiddlewareTestCase
/**
* Return an instance of the middleware for testing.
*
* @return \Pterodactyl\Http\Middleware\Api\Application\AuthenticateUser
* @return \Pterodactyl\Http\Middleware\Api\Application\AuthenticateApplicationUser
*/
private function getMiddleware(): AuthenticateUser
private function getMiddleware(): AuthenticateApplicationUser
{
return new AuthenticateUser;
return new AuthenticateApplicationUser;
}
}

View file

@ -0,0 +1,236 @@
<?php
namespace Tests\Unit\Services\Databases;
use Mockery as m;
use Tests\TestCase;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Database;
use Pterodactyl\Services\Databases\DatabaseManagementService;
use Pterodactyl\Services\Databases\DeployServerDatabaseService;
use Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface;
use Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface;
class DeployServerDatabaseServiceTest extends TestCase
{
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseHostRepositoryInterface|\Mockery\Mock
*/
private $databaseHostRepository;
/**
* @var \Pterodactyl\Services\Databases\DatabaseManagementService|\Mockery\Mock
*/
private $managementService;
/**
* @var \Pterodactyl\Contracts\Repository\DatabaseRepositoryInterface|\Mockery\Mock
*/
private $repository;
/**
* Setup tests.
*/
public function setUp()
{
parent::setUp();
$this->databaseHostRepository = m::mock(DatabaseHostRepositoryInterface::class);
$this->managementService = m::mock(DatabaseManagementService::class);
$this->repository = m::mock(DatabaseRepositoryInterface::class);
// Set configs for testing instances.
config()->set('pterodactyl.client_features.databases.enabled', true);
config()->set('pterodactyl.client_features.databases.allow_random', true);
}
/**
* Test handling of non-random hosts when a host is found.
*
* @dataProvider databaseLimitDataProvider
*/
public function testNonRandomFoundHost($limit, $count)
{
config()->set('pterodactyl.client_features.databases.allow_random', false);
$server = factory(Server::class)->make(['database_limit' => $limit]);
$model = factory(Database::class)->make();
$this->repository->shouldReceive('findCountWhere')
->once()
->with([['server_id', '=', $server->id]])
->andReturn($count);
$this->databaseHostRepository->shouldReceive('setColumns->findWhere')
->once()
->with([['node_id', '=', $server->node_id]])
->andReturn(collect([$model]));
$this->managementService->shouldReceive('create')
->once()
->with($server->id, [
'database_host_id' => $model->id,
'database' => 'testdb',
'remote' => null,
])
->andReturn($model);
$response = $this->getService()->handle($server, ['database' => 'testdb']);
$this->assertInstanceOf(Database::class, $response);
$this->assertSame($model, $response);
}
/**
* Test that an exception is thrown if in non-random mode and no host is found.
*
* @expectedException \Pterodactyl\Exceptions\Service\Database\NoSuitableDatabaseHostException
*/
public function testNonRandomNoHost()
{
config()->set('pterodactyl.client_features.databases.allow_random', false);
$server = factory(Server::class)->make(['database_limit' => 1]);
$this->repository->shouldReceive('findCountWhere')
->once()
->with([['server_id', '=', $server->id]])
->andReturn(0);
$this->databaseHostRepository->shouldReceive('setColumns->findWhere')
->once()
->with([['node_id', '=', $server->node_id]])
->andReturn(collect());
$this->getService()->handle($server, []);
}
/**
* Test handling of random host selection.
*/
public function testRandomFoundHost()
{
$server = factory(Server::class)->make(['database_limit' => 1]);
$model = factory(Database::class)->make();
$this->repository->shouldReceive('findCountWhere')
->once()
->with([['server_id', '=', $server->id]])
->andReturn(0);
$this->databaseHostRepository->shouldReceive('setColumns->findWhere')
->once()
->with([['node_id', '=', $server->node_id]])
->andReturn(collect());
$this->databaseHostRepository->shouldReceive('setColumns->all')
->once()
->andReturn(collect([$model]));
$this->managementService->shouldReceive('create')
->once()
->with($server->id, [
'database_host_id' => $model->id,
'database' => 'testdb',
'remote' => null,
])
->andReturn($model);
$response = $this->getService()->handle($server, ['database' => 'testdb']);
$this->assertInstanceOf(Database::class, $response);
$this->assertSame($model, $response);
}
/**
* Test that an exception is thrown when no host is found and random is allowed.
*
* @expectedException \Pterodactyl\Exceptions\Service\Database\NoSuitableDatabaseHostException
*/
public function testRandomNoHost()
{
$server = factory(Server::class)->make(['database_limit' => 1]);
$this->repository->shouldReceive('findCountWhere')
->once()
->with([['server_id', '=', $server->id]])
->andReturn(0);
$this->databaseHostRepository->shouldReceive('setColumns->findWhere')
->once()
->with([['node_id', '=', $server->node_id]])
->andReturn(collect());
$this->databaseHostRepository->shouldReceive('setColumns->all')
->once()
->andReturn(collect());
$this->getService()->handle($server, []);
}
/**
* Test that a server over the database limit throws an exception.
*
* @dataProvider databaseExceedingLimitDataProvider
* @expectedException \Pterodactyl\Exceptions\Service\Database\TooManyDatabasesException
*/
public function testServerOverDatabaseLimit($limit, $count)
{
$server = factory(Server::class)->make(['database_limit' => $limit]);
$this->repository->shouldReceive('findCountWhere')
->once()
->with([['server_id', '=', $server->id]])
->andReturn($count);
$this->getService()->handle($server, []);
}
/**
* Test that an exception is thrown if the feature is not enabled.
*
* @expectedException \Pterodactyl\Exceptions\Service\Database\DatabaseClientFeatureNotEnabledException
*/
public function testFeatureNotEnabled()
{
config()->set('pterodactyl.client_features.databases.enabled', false);
$this->getService()->handle(factory(Server::class)->make(), []);
}
/**
* Provide limits and current database counts for testing.
*
* @return array
*/
public function databaseLimitDataProvider(): array
{
return [
[null, 10],
[1, 0],
];
}
/**
* Provide data for servers over their database limit.
*
* @return array
*/
public function databaseExceedingLimitDataProvider(): array
{
return [
[2, 2],
[2, 3],
];
}
/**
* Return an instance of the service with mocked dependencies for testing.
*
* @return \Pterodactyl\Services\Databases\DeployServerDatabaseService
*/
private function getService(): DeployServerDatabaseService
{
return new DeployServerDatabaseService($this->repository, $this->databaseHostRepository, $this->managementService);
}
}

View file

@ -81,10 +81,9 @@ class ReinstallServerServiceTest extends TestCase
$this->repository->shouldNotReceive('find');
$this->database->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel')->withNoArgs()->once()->andReturnSelf()
->shouldReceive('update')->with($this->server->id, [
'installed' => 0,
])->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel->update')->with($this->server->id, [
'installed' => 0,
], true, true)->once()->andReturnNull();
$this->daemonServerRepository->shouldReceive('setServer')->with($this->server)->once()->andReturnSelf()
->shouldReceive('reinstall')->withNoArgs()->once()->andReturn(new Response);
@ -101,10 +100,9 @@ class ReinstallServerServiceTest extends TestCase
$this->repository->shouldReceive('find')->with($this->server->id)->once()->andReturn($this->server);
$this->database->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel')->withNoArgs()->once()->andReturnSelf()
->shouldReceive('update')->with($this->server->id, [
'installed' => 0,
])->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel->update')->with($this->server->id, [
'installed' => 0,
], true, true)->once()->andReturnNull();
$this->daemonServerRepository->shouldReceive('setServer')->with($this->server)->once()->andReturnSelf()
->shouldReceive('reinstall')->withNoArgs()->once()->andReturn(new Response);
@ -121,10 +119,9 @@ class ReinstallServerServiceTest extends TestCase
public function testExceptionThrownByGuzzleShouldBeReRenderedAsDisplayable()
{
$this->database->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel')->withNoArgs()->once()->andReturnSelf()
->shouldReceive('update')->with($this->server->id, [
'installed' => 0,
])->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel->update')->with($this->server->id, [
'installed' => 0,
], true, true)->once()->andReturnNull();
$this->daemonServerRepository->shouldReceive('setServer')->with($this->server)->once()->andThrow($this->exception);
@ -139,10 +136,9 @@ class ReinstallServerServiceTest extends TestCase
public function testExceptionNotThrownByGuzzleShouldNotBeTransformedToDisplayable()
{
$this->database->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel')->withNoArgs()->once()->andReturnSelf()
->shouldReceive('update')->with($this->server->id, [
'installed' => 0,
])->once()->andReturnNull();
$this->repository->shouldReceive('withoutFreshModel->update')->with($this->server->id, [
'installed' => 0,
], true, true)->once()->andReturnNull();
$this->daemonServerRepository->shouldReceive('setServer')->with($this->server)->once()->andThrow(new Exception());