diff --git a/app/Exceptions/DisplayException.php b/app/Exceptions/DisplayException.php index 4df73419f..a74f9be26 100644 --- a/app/Exceptions/DisplayException.php +++ b/app/Exceptions/DisplayException.php @@ -11,6 +11,7 @@ namespace Pterodactyl\Exceptions; use Log; use Throwable; +use Illuminate\Http\Response; use Prologue\Alerts\AlertsMessageBag; class DisplayException extends PterodactylException @@ -65,7 +66,7 @@ class DisplayException extends PterodactylException if ($request->expectsJson()) { return response()->json(Handler::convertToArray($this, [ 'detail' => $this->getMessage(), - ]), method_exists($this, 'getStatusCode') ? $this->getStatusCode() : 500); + ]), method_exists($this, 'getStatusCode') ? $this->getStatusCode() : Response::HTTP_INTERNAL_SERVER_ERROR); } app()->make(AlertsMessageBag::class)->danger($this->getMessage())->flash(); diff --git a/app/Exceptions/Service/HasActiveServersException.php b/app/Exceptions/Service/HasActiveServersException.php index 4f52bde0a..5c43101c5 100644 --- a/app/Exceptions/Service/HasActiveServersException.php +++ b/app/Exceptions/Service/HasActiveServersException.php @@ -1,16 +1,17 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Exceptions\Service; +use Illuminate\Http\Response; use Pterodactyl\Exceptions\DisplayException; class HasActiveServersException extends DisplayException { + /** + * @return int + */ + public function getStatusCode() + { + return Response::HTTP_BAD_REQUEST; + } } diff --git a/app/Exceptions/Service/Location/HasActiveNodesException.php b/app/Exceptions/Service/Location/HasActiveNodesException.php index 7960a3387..1270807b8 100644 --- a/app/Exceptions/Service/Location/HasActiveNodesException.php +++ b/app/Exceptions/Service/Location/HasActiveNodesException.php @@ -1,16 +1,17 @@ . - * - * This software is licensed under the terms of the MIT license. - * https://opensource.org/licenses/MIT - */ namespace Pterodactyl\Exceptions\Service\Location; +use Illuminate\Http\Response; use Pterodactyl\Exceptions\DisplayException; class HasActiveNodesException extends DisplayException { + /** + * @return int + */ + public function getStatusCode() + { + return Response::HTTP_BAD_REQUEST; + } } diff --git a/app/Http/Controllers/API/Admin/Nodes/NodeController.php b/app/Http/Controllers/API/Admin/Nodes/NodeController.php index e0500975e..df78e7992 100644 --- a/app/Http/Controllers/API/Admin/Nodes/NodeController.php +++ b/app/Http/Controllers/API/Admin/Nodes/NodeController.php @@ -5,13 +5,29 @@ namespace Pterodactyl\Http\Controllers\API\Admin\Nodes; use Spatie\Fractal\Fractal; use Illuminate\Http\Request; use Pterodactyl\Models\Node; +use Illuminate\Http\Response; +use Illuminate\Http\JsonResponse; use Pterodactyl\Http\Controllers\Controller; +use Pterodactyl\Services\Nodes\NodeUpdateService; +use Pterodactyl\Services\Nodes\NodeCreationService; +use Pterodactyl\Services\Nodes\NodeDeletionService; use Pterodactyl\Transformers\Api\Admin\NodeTransformer; use League\Fractal\Pagination\IlluminatePaginatorAdapter; +use Pterodactyl\Http\Requests\Admin\Node\NodeFormRequest; use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; class NodeController extends Controller { + /** + * @var \Pterodactyl\Services\Nodes\NodeCreationService + */ + private $creationService; + + /** + * @var \Pterodactyl\Services\Nodes\NodeDeletionService + */ + private $deletionService; + /** * @var \Spatie\Fractal\Fractal */ @@ -22,16 +38,32 @@ class NodeController extends Controller */ private $repository; + /** + * @var \Pterodactyl\Services\Nodes\NodeUpdateService + */ + private $updateService; + /** * NodeController constructor. * * @param \Spatie\Fractal\Fractal $fractal + * @param \Pterodactyl\Services\Nodes\NodeCreationService $creationService + * @param \Pterodactyl\Services\Nodes\NodeDeletionService $deletionService + * @param \Pterodactyl\Services\Nodes\NodeUpdateService $updateService * @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository */ - public function __construct(Fractal $fractal, NodeRepositoryInterface $repository) - { + public function __construct( + Fractal $fractal, + NodeCreationService $creationService, + NodeDeletionService $deletionService, + NodeUpdateService $updateService, + NodeRepositoryInterface $repository + ) { $this->fractal = $fractal; $this->repository = $repository; + $this->creationService = $creationService; + $this->deletionService = $deletionService; + $this->updateService = $updateService; } /** @@ -67,4 +99,63 @@ class NodeController extends Controller return $fractal->toArray(); } + + /** + * Create a new node on the Panel. Returns the created node and a HTTP/201 + * status response on success. + * + * @param \Pterodactyl\Http\Requests\Admin\Node\NodeFormRequest $request + * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + */ + public function store(NodeFormRequest $request): JsonResponse + { + $node = $this->creationService->handle($request->normalize()); + + return $this->fractal->item($node) + ->transformWith(new NodeTransformer($request)) + ->withResourceName('node') + ->addMeta([ + 'link' => route('api.admin.node.view', ['node' => $node->id]), + ]) + ->respond(201); + } + + /** + * Update an existing node on the Panel. + * + * @param \Pterodactyl\Http\Requests\Admin\Node\NodeFormRequest $request + * @param \Pterodactyl\Models\Node $node + * @return array + * + * @throws \Pterodactyl\Exceptions\DisplayException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function update(NodeFormRequest $request, Node $node): array + { + $node = $this->updateService->returnUpdatedModel()->handle($node, $request->normalize()); + + return $this->fractal->item($node) + ->transformWith(new NodeTransformer($request)) + ->withResourceName('node') + ->toArray(); + } + + /** + * Deletes a given node from the Panel as long as there are no servers + * currently attached to it. + * + * @param \Pterodactyl\Models\Node $node + * @return \Illuminate\Http\Response + * + * @throws \Pterodactyl\Exceptions\Service\HasActiveServersException + */ + public function delete(Node $node): Response + { + $this->deletionService->handle($node); + + return response('', 201); + } } diff --git a/app/Http/Controllers/API/Admin/Users/UserController.php b/app/Http/Controllers/API/Admin/Users/UserController.php index 74bcdbee9..54cdaae0a 100644 --- a/app/Http/Controllers/API/Admin/Users/UserController.php +++ b/app/Http/Controllers/API/Admin/Users/UserController.php @@ -146,13 +146,17 @@ class UserController extends Controller } } - return $this->fractal->item($collection->get('user')) + $response = $this->fractal->item($collection->get('model')) ->transformWith(new UserTransformer($request)) - ->withResourceName('user') - ->addMeta([ + ->withResourceName('user'); + + if (count($errors) > 0) { + $response->addMeta([ 'revocation_errors' => $errors, - ]) - ->toArray(); + ]); + } + + return $response->toArray(); } /** diff --git a/app/Models/User.php b/app/Models/User.php index 7d064424c..6d83026c8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,11 +4,13 @@ namespace Pterodactyl\Models; use Sofa\Eloquence\Eloquence; use Sofa\Eloquence\Validable; +use Illuminate\Validation\Rules\In; use Illuminate\Auth\Authenticatable; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; use Sofa\Eloquence\Contracts\CleansAttributes; use Illuminate\Auth\Passwords\CanResetPassword; +use Pterodactyl\Traits\Helpers\AvailableLanguages; use Illuminate\Foundation\Auth\Access\Authorizable; use Sofa\Eloquence\Contracts\Validable as ValidableContract; use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract; @@ -23,7 +25,9 @@ class User extends Model implements CleansAttributes, ValidableContract { - use Authenticatable, Authorizable, CanResetPassword, Eloquence, Notifiable, Validable; + use Authenticatable, Authorizable, AvailableLanguages, CanResetPassword, Eloquence, Notifiable, Validable { + gatherRules as eloquenceGatherRules; + } const USER_LEVEL_USER = 0; const USER_LEVEL_ADMIN = 1; @@ -138,11 +142,23 @@ class User extends Model implements 'name_last' => 'string|between:1,255', 'password' => 'nullable|string', 'root_admin' => 'boolean', - 'language' => 'string|between:2,5', + 'language' => 'string', 'use_totp' => 'boolean', 'totp_secret' => 'nullable|string', ]; + /** + * Implement language verification by overriding Eloquence's gather + * rules function. + */ + protected static function gatherRules() + { + $rules = self::eloquenceGatherRules(); + $rules['language'][] = new In(array_keys((new self)->getAvailableLanguages())); + + return $rules; + } + /** * Send the password reset notification. * diff --git a/app/Services/Nodes/NodeUpdateService.php b/app/Services/Nodes/NodeUpdateService.php index ace620b28..76e304dd3 100644 --- a/app/Services/Nodes/NodeUpdateService.php +++ b/app/Services/Nodes/NodeUpdateService.php @@ -9,82 +9,71 @@ namespace Pterodactyl\Services\Nodes; -use Illuminate\Log\Writer; use Pterodactyl\Models\Node; use GuzzleHttp\Exception\RequestException; -use Pterodactyl\Exceptions\DisplayException; +use Pterodactyl\Traits\Services\ReturnsUpdatedModels; use Pterodactyl\Contracts\Repository\NodeRepositoryInterface; +use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface; class NodeUpdateService { + use ReturnsUpdatedModels; + /** * @var \Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface */ - protected $configRepository; + private $configRepository; /** * @var \Pterodactyl\Contracts\Repository\NodeRepositoryInterface */ - protected $repository; - - /** - * @var \Illuminate\Log\Writer - */ - protected $writer; + private $repository; /** * UpdateService constructor. * * @param \Pterodactyl\Contracts\Repository\Daemon\ConfigurationRepositoryInterface $configurationRepository * @param \Pterodactyl\Contracts\Repository\NodeRepositoryInterface $repository - * @param \Illuminate\Log\Writer $writer */ public function __construct( ConfigurationRepositoryInterface $configurationRepository, - NodeRepositoryInterface $repository, - Writer $writer + NodeRepositoryInterface $repository ) { $this->configRepository = $configurationRepository; $this->repository = $repository; - $this->writer = $writer; } /** * Update the configuration values for a given node on the machine. * - * @param int|\Pterodactyl\Models\Node $node - * @param array $data - * @return mixed + * @param \Pterodactyl\Models\Node $node + * @param array $data + * @return \Pterodactyl\Models\Node|mixed * * @throws \Pterodactyl\Exceptions\DisplayException * @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ - public function handle($node, array $data) + public function handle(Node $node, array $data) { - if (! $node instanceof Node) { - $node = $this->repository->find($node); - } - if (! is_null(array_get($data, 'reset_secret'))) { - $data['daemonSecret'] = str_random(NodeCreationService::DAEMON_SECRET_LENGTH); + $data['daemonSecret'] = str_random(Node::DAEMON_SECRET_LENGTH); unset($data['reset_secret']); } - $updateResponse = $this->repository->withoutFresh()->update($node->id, $data); + if ($this->getUpdatedModel()) { + $response = $this->repository->update($node->id, $data); + } else { + $response = $this->repository->withoutFresh()->update($node->id, $data); + } try { $this->configRepository->setNode($node->id)->update(); } catch (RequestException $exception) { - $response = $exception->getResponse(); - $this->writer->warning($exception); - - throw new DisplayException(trans('exceptions.node.daemon_off_config_updated', [ - 'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(), - ])); + throw new DaemonConnectionException($exception); } - return $updateResponse; + return $response; } } diff --git a/app/Traits/Services/ReturnsUpdatedModels.php b/app/Traits/Services/ReturnsUpdatedModels.php new file mode 100644 index 000000000..2d5ee64fd --- /dev/null +++ b/app/Traits/Services/ReturnsUpdatedModels.php @@ -0,0 +1,35 @@ +updatedModel; + } + + /** + * If called a fresh model will be returned from the database. This is used + * for API calls, but is unnecessary for UI based updates where the page is + * being reloaded and a fresh model will be pulled anyways. + * + * @param bool $toggle + * + * @return $this + */ + public function returnUpdatedModel(bool $toggle = true) + { + $this->updatedModel = $toggle; + + return $this; + } +} diff --git a/routes/api-admin.php b/routes/api-admin.php index 772ec7657..13e45a7c4 100644 --- a/routes/api-admin.php +++ b/routes/api-admin.php @@ -13,12 +13,25 @@ Route::group(['prefix' => '/users'], function () { Route::get('/{user}', 'Users\UserController@view')->name('api.admin.user.view'); Route::post('/', 'Users\UserController@store')->name('api.admin.user.store'); - Route::put('/{user}', 'Users\UserController@update')->name('api.admin.user.update'); + Route::patch('/{user}', 'Users\UserController@update')->name('api.admin.user.update'); Route::delete('/{user}', 'Users\UserController@delete')->name('api.admin.user.delete'); }); +/* +|-------------------------------------------------------------------------- +| Node Controller Routes +|-------------------------------------------------------------------------- +| +| Endpoint: /api/admin/nodes +| +*/ Route::group(['prefix' => '/nodes'], function () { Route::get('/', 'Nodes\NodeController@index')->name('api.admin.node.list'); Route::get('/{node}', 'Nodes\NodeController@view')->name('api.admin.node.view'); + + Route::post('/', 'Nodes\NodeController@store')->name('api.admin.node.store'); + Route::patch('/{node}', 'Nodes\NodeController@update')->name('api.admin.node.update'); + + Route::delete('/{node}', 'Nodes\NodeController@delete')->name('api.admin.node.delete'); }); diff --git a/tests/Unit/Services/Nodes/NodeUpdateServiceTest.php b/tests/Unit/Services/Nodes/NodeUpdateServiceTest.php index da851e6c0..b4d649118 100644 --- a/tests/Unit/Services/Nodes/NodeUpdateServiceTest.php +++ b/tests/Unit/Services/Nodes/NodeUpdateServiceTest.php @@ -84,16 +84,17 @@ class NodeUpdateServiceTest extends TestCase $this->getFunctionMock('\\Pterodactyl\\Services\\Nodes', 'str_random') ->expects($this->once())->willReturn('random_string'); - $this->repository->shouldReceive('withoutFresh')->withNoArgs()->once()->andReturnSelf() - ->shouldReceive('update')->with($this->node->id, [ - 'name' => 'NewName', - 'daemonSecret' => 'random_string', - ])->andReturn(true); + $this->repository->->shouldReceive('update')->with($this->node->id, [ + 'name' => 'NewName', + 'daemonSecret' => 'random_string', + ])->andReturn($this->node); $this->configRepository->shouldReceive('setNode')->with($this->node->id)->once()->andReturnSelf() ->shouldReceive('update')->withNoArgs()->once()->andReturnNull(); - $this->assertTrue($this->service->handle($this->node, ['name' => 'NewName', 'reset_secret' => true])); + $response = $this->service->handle($this->node, ['name' => 'NewName', 'reset_secret' => true]); + $this->assertInstanceOf(Node::class, $response); + $this->assertSame($this->node, $response); } /** @@ -101,15 +102,16 @@ class NodeUpdateServiceTest extends TestCase */ public function testNodeIsUpdatedAndDaemonSecretIsNotChanged() { - $this->repository->shouldReceive('withoutFresh')->withNoArgs()->once()->andReturnSelf() - ->shouldReceive('update')->with($this->node->id, [ - 'name' => 'NewName', - ])->andReturn(true); + $this->repository->shouldReceive('update')->with($this->node->id, [ + 'name' => 'NewName', + ])->andReturn($this->node); $this->configRepository->shouldReceive('setNode')->with($this->node->id)->once()->andReturnSelf() ->shouldReceive('update')->withNoArgs()->once()->andReturnNull(); - $this->assertTrue($this->service->handle($this->node, ['name' => 'NewName'])); + $response = $this->service->handle($this->node, ['name' => 'NewName']); + $this->assertInstanceOf(Node::class, $response); + $this->assertSame($this->node, $response); } /** @@ -117,8 +119,7 @@ class NodeUpdateServiceTest extends TestCase */ public function testExceptionCausedByDaemonIsHandled() { - $this->repository->shouldReceive('withoutFresh')->withNoArgs()->once()->andReturnSelf() - ->shouldReceive('update')->with($this->node->id, [ + $this->repository->->shouldReceive('update')->with($this->node->id, [ 'name' => 'NewName', ])->andReturn(true);