diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 785de6c73..985087d16 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ -#github: [DaneEveritt] +github: [DaneEveritt] custom: ["https://paypal.me/PterodactylSoftware"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61df3a539..06582c0e6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: - name: Create release archive run: | rm -rf node_modules/ test/ codecov.yml CODE_OF_CONDUCT.md CONTRIBUTING.md phpunit.dusk.xml phpunit.xml Vagrantfile - tar -czf panel.tar.gz * + tar -czf panel.tar.gz * .env.example - name: Extract changelog id: extract_changelog diff --git a/Dockerfile b/Dockerfile index 0525e5b39..f00d54d5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,7 @@ RUN cp docker/default.conf /etc/nginx/conf.d/default.conf \ && cat docker/www.conf > /usr/local/etc/php-fpm.d/www.conf \ && rm /usr/local/etc/php-fpm.d/www.conf.default \ && cat docker/supervisord.conf > /etc/supervisord.conf \ - && echo "* * * * * /usr/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \ + && echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \ && sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \ && mkdir -p /var/run/php /var/run/nginx @@ -33,4 +33,4 @@ EXPOSE 80 443 ENTRYPOINT ["/bin/ash", "docker/entrypoint.sh"] -CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] \ No newline at end of file +CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] diff --git a/README.md b/README.md index 4dd56ba1a..84d428ee0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,30 @@ What more are you waiting for? Make game servers a first class citizen on your p ![Image](https://cdn.pterodactyl.io/site-assets/mockup-macbook-grey.png) +## Sponsors +I would like to extend my sincere thanks to the following sponsors for funding Pterodactyl's developement. [Interested +in becoming a sponsor?](https://github.com/sponsors/DaneEveritt) + +#### [BloomVPS](https://bloomvps.com) +> BloomVPS offers dedicated core VPS and Minecraft hosting with Ryzen 9 processors. With owned-hardware, we offer truly +> unbeatable prices on high-performance hosting. + +#### [VersatileNode](https://versatilenode.com/) +> Looking to host a minecraft server, vps, or a website? VersatileNode is one of the most affordable hosting providers +> to provide quality yet cheap services with incredible support. + +#### [MineStrator](https://minestrator.com/) +> Looking for a French highend hosting company for you minecraft server? More than 14,000 members on our discord +> trust us. + +#### [DedicatedMC](https://dedicatedmc.io/) +> DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance +> and giving you the best performance money can buy. + +#### [Skynode](https://www.skynode.pro/) +> Skynode provides blazing fast game servers along with a top notch user experience. Whatever our clients are looking +> for, we're able to provide it! + ## Support & Documentation Support for using Pterodactyl can be found on our [Documentation Website](https://pterodactyl.io/project/introduction.html), [Guides Website](https://pterodactyl.io/community/about.html), or via our [Discord Chat](https://discord.gg/QRDZvVm). @@ -43,7 +67,7 @@ In addition to our standard nest of supported games, our community is constantly ## Credits This software would not be possible without the work of other open-source authors who provide tools such as: -[Ace Editor](https://ace.c9.io), [AdminLTE](https://almsaeedstudio.com), [Animate.css](http://daneden.github.io/animate.css/), [AnsiUp](https://github.com/drudru/ansi_up), [Async.js](https://github.com/caolan/async), +[Ace Editor](https://ace.c9.io), [AdminLTE](https://adminlte.io), [Animate.css](http://daneden.github.io/animate.css/), [AnsiUp](https://github.com/drudru/ansi_up), [Async.js](https://github.com/caolan/async), [Bootstrap](http://getbootstrap.com), [Bootstrap Notify](http://bootstrap-notify.remabledesigns.com), [Chart.js](http://www.chartjs.org), [FontAwesome](http://fontawesome.io), [FontAwesome Animations](https://github.com/l-lin/font-awesome-animation), [jQuery](http://jquery.com), [Laravel](https://laravel.com), [Lodash](https://lodash.com), [Select2](https://select2.github.io), [Socket.io](http://socket.io), [Socket.io File Upload](https://github.com/vote539/socketio-file-upload), [SweetAlert](http://t4t5.github.io/sweetalert), diff --git a/app/Console/Commands/Maintenance/PruneOrphanedBackupsCommand.php b/app/Console/Commands/Maintenance/PruneOrphanedBackupsCommand.php new file mode 100644 index 000000000..af4590d47 --- /dev/null +++ b/app/Console/Commands/Maintenance/PruneOrphanedBackupsCommand.php @@ -0,0 +1,51 @@ +option('since-minutes'); + if (! is_digit($since)) { + throw new InvalidArgumentException('The --since-minutes option must be a valid numeric digit.'); + } + + $query = $repository->getBuilder() + ->whereNull('completed_at') + ->whereDate('created_at', '<=', CarbonImmutable::now()->subMinutes($since)); + + $count = $query->count(); + if (! $count) { + $this->info('There are no orphaned backups to be marked as failed.'); + + return; + } + + $this->warn("Marking {$count} backups that have not been marked as completed in the last {$since} minutes as failed."); + + $query->update([ + 'is_successful' => false, + 'completed_at' => CarbonImmutable::now(), + 'updated_at' => CarbonImmutable::now(), + ]); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index c87cd5394..1f83ddf93 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -22,7 +22,16 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule) { + // Execute scheduled commands for servers every minute, as if there was a normal cron running. $schedule->command('p:schedule:process')->everyMinute()->withoutOverlapping(); + + // Every 30 minutes, run the backup pruning command so that any abandoned backups can be removed + // from the UI view for the server. + $schedule->command('p:maintenance:prune-backups', [ + '--since-minutes' => '30', + ])->everyThirtyMinutes(); + + // Every day cleanup any internal backups of service files. $schedule->command('p:maintenance:clean-service-backups')->daily(); } } diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 50ac1a960..d278ce0bc 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -213,6 +213,13 @@ class Handler extends ExceptionHandler 'detail' => 'An error was encountered while processing this request.', ]; + if ($exception instanceof ModelNotFoundException || $exception->getPrevious() instanceof ModelNotFoundException) { + // Show a nicer error message compared to the standard "No query results for model" + // response that is normally returned. If we are in debug mode this will get overwritten + // with a more specific error message to help narrow down things. + $error['detail'] = 'The requested resource could not be found on the server.'; + } + if (config('app.debug')) { $error = array_merge($error, [ 'detail' => $exception->getMessage(), diff --git a/app/Exceptions/Http/Connection/DaemonConnectionException.php b/app/Exceptions/Http/Connection/DaemonConnectionException.php index f2f8ba13d..e6765b8a6 100644 --- a/app/Exceptions/Http/Connection/DaemonConnectionException.php +++ b/app/Exceptions/Http/Connection/DaemonConnectionException.php @@ -2,6 +2,7 @@ namespace Pterodactyl\Exceptions\Http\Connection; +use Illuminate\Support\Arr; use Illuminate\Http\Response; use GuzzleHttp\Exception\GuzzleException; use Pterodactyl\Exceptions\DisplayException; @@ -22,18 +23,34 @@ class DaemonConnectionException extends DisplayException * @param \GuzzleHttp\Exception\GuzzleException $previous * @param bool $useStatusCode */ - public function __construct(GuzzleException $previous, bool $useStatusCode = false) + public function __construct(GuzzleException $previous, bool $useStatusCode = true) { /** @var \GuzzleHttp\Psr7\Response|null $response */ $response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null; if ($useStatusCode) { - $this->statusCode = is_null($response) ? 500 : $response->getStatusCode(); + $this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode(); } - parent::__construct(trans('admin/server.exceptions.daemon_exception', [ + $message = trans('admin/server.exceptions.daemon_exception', [ 'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(), - ]), $previous, DisplayException::LEVEL_WARNING); + ]); + + // Attempt to pull the actual error message off the response and return that if it is not + // a 500 level error. + if ($this->statusCode < 500 && ! is_null($response)) { + $body = $response->getBody(); + if (is_string($body) || (is_object($body) && method_exists($body, '__toString'))) { + $body = json_decode(is_string($body) ? $body : $body->__toString(), true); + $message = "[Wings Error]: " . Arr::get($body, 'error', $message); + } + } + + $level = $this->statusCode >= 500 && $this->statusCode !== 504 + ? DisplayException::LEVEL_ERROR + : DisplayException::LEVEL_WARNING; + + parent::__construct($message, $previous, $level); } /** diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 300770aa5..5eec40b51 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -2,7 +2,6 @@ namespace Pterodactyl\Http\Controllers\Api\Client; -use Pterodactyl\Models\User; use Pterodactyl\Models\Server; use Pterodactyl\Models\Permission; use Spatie\QueryBuilder\QueryBuilder; @@ -39,31 +38,27 @@ class ClientController extends ClientApiController public function index(GetServersRequest $request): array { $user = $request->user(); - $level = $request->getFilterLevel(); $transformer = $this->getTransformer(ServerTransformer::class); // Start the query builder and ensure we eager load any requested relationships from the request. - $builder = Server::query()->with($this->getIncludesForTransformer($transformer, ['node'])); + $builder = QueryBuilder::for( + Server::query()->with($this->getIncludesForTransformer($transformer, ['node'])) + )->allowedFilters('uuid', 'name', 'external_id'); - if ($level === User::FILTER_LEVEL_OWNER) { - $builder = $builder->where('owner_id', $request->user()->id); - } - // If set to all, display all servers they can access, including those they access as an - // admin. If set to subuser, only return the servers they can access because they are owner, - // or marked as a subuser of the server. - elseif (($level === User::FILTER_LEVEL_ALL && ! $user->root_admin) || $level === User::FILTER_LEVEL_SUBUSER) { + // Either return all of the servers the user has access to because they are an admin `?type=admin` or + // just return all of the servers the user has access to because they are the owner or a subuser of the + // server. + if ($request->input('type') === 'admin') { + $builder = $user->root_admin + ? $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all()) + // If they aren't an admin but want all the admin servers don't fail the request, just + // make it a query that will never return any results back. + : $builder->whereRaw('1 = 2'); + } elseif ($request->input('type') === 'owner') { + $builder = $builder->where('owner_id', $user->id); + } else { $builder = $builder->whereIn('id', $user->accessibleServers()->pluck('id')->all()); } - // If set to admin, only display the servers a user can access because they are an administrator. - // This means only servers the user would not have access to if they were not an admin (because they - // are not an owner or subuser) are returned. - elseif ($level === User::FILTER_LEVEL_ADMIN && $user->root_admin) { - $builder = $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all()); - } - - $builder = QueryBuilder::for($builder)->allowedFilters( - 'uuid', 'name', 'external_id' - ); $servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query()); diff --git a/app/Http/Controllers/Api/Client/Servers/CommandController.php b/app/Http/Controllers/Api/Client/Servers/CommandController.php index d4551aa2f..b8390673e 100644 --- a/app/Http/Controllers/Api/Client/Servers/CommandController.php +++ b/app/Http/Controllers/Api/Client/Servers/CommandController.php @@ -5,7 +5,6 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Psr\Http\Message\ResponseInterface; -use GuzzleHttp\Exception\RequestException; use GuzzleHttp\Exception\BadResponseException; use Symfony\Component\HttpKernel\Exception\HttpException; use Pterodactyl\Repositories\Wings\DaemonCommandRepository; @@ -45,11 +44,13 @@ class CommandController extends ClientApiController { try { $this->repository->setServer($server)->send($request->input('command')); - } catch (RequestException $exception) { - if ($exception instanceof BadResponseException) { + } catch (DaemonConnectionException $exception) { + $previous = $exception->getPrevious(); + + if ($previous instanceof BadResponseException) { if ( - $exception->getResponse() instanceof ResponseInterface - && $exception->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY + $previous->getResponse() instanceof ResponseInterface + && $previous->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY ) { throw new HttpException( Response::HTTP_BAD_GATEWAY, 'Server must be online in order to send commands.', $exception @@ -57,7 +58,7 @@ class CommandController extends ClientApiController } } - throw new DaemonConnectionException($exception); + throw $exception; } return $this->returnNoContent(); diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 60fc88777..1f18259ae 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -6,19 +6,18 @@ use Carbon\CarbonImmutable; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use Illuminate\Http\JsonResponse; -use GuzzleHttp\Exception\TransferException; use Pterodactyl\Services\Nodes\NodeJWTService; use Illuminate\Contracts\Routing\ResponseFactory; use Pterodactyl\Repositories\Wings\DaemonFileRepository; use Pterodactyl\Transformers\Daemon\FileObjectTransformer; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; -use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\ListFilesRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CreateFolderRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CompressFilesRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DecompressFilesRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest; @@ -69,13 +68,9 @@ class FileController extends ClientApiController */ public function directory(ListFilesRequest $request, Server $server): array { - try { - $contents = $this->fileRepository - ->setServer($server) - ->getDirectory($request->get('directory') ?? '/'); - } catch (TransferException $exception) { - throw new DaemonConnectionException($exception, true); - } + $contents = $this->fileRepository + ->setServer($server) + ->getDirectory($request->get('directory') ?? '/'); return $this->fractal->collection($contents) ->transformWith($this->getTransformer(FileObjectTransformer::class)) @@ -88,7 +83,9 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\Response + * * @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function contents(GetFileContentsRequest $request, Server $server): Response { @@ -139,6 +136,8 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function write(WriteFileContentRequest $request, Server $server): JsonResponse { @@ -156,6 +155,8 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CreateFolderRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function create(CreateFolderRequest $request, Server $server): JsonResponse { @@ -172,6 +173,8 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function rename(RenameFileRequest $request, Server $server): JsonResponse { @@ -188,6 +191,8 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function copy(CopyFileRequest $request, Server $server): JsonResponse { @@ -202,9 +207,14 @@ class FileController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CompressFilesRequest $request * @param \Pterodactyl\Models\Server $server * @return array + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function compress(CompressFilesRequest $request, Server $server): array { + // Allow up to five minutes for this request to process before timing out. + set_time_limit(300); + $file = $this->fileRepository->setServer($server) ->compressFiles( $request->input('root'), $request->input('files') @@ -215,12 +225,32 @@ class FileController extends ClientApiController ->toArray(); } + /** + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DecompressFilesRequest $request + * @param \Pterodactyl\Models\Server $server + * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function decompress(DecompressFilesRequest $request, Server $server): JsonResponse + { + // Allow up to five minutes for this request to process before timing out. + set_time_limit(300); + + $this->fileRepository->setServer($server) + ->decompressFile($request->input('root'), $request->input('file')); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); + } + /** * Deletes files or folders for the server in the given root directory. * * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function delete(DeleteFileRequest $request, Server $server): JsonResponse { diff --git a/app/Http/Controllers/Api/Client/Servers/PowerController.php b/app/Http/Controllers/Api/Client/Servers/PowerController.php index 82cf8b334..12e2d75bb 100644 --- a/app/Http/Controllers/Api/Client/Servers/PowerController.php +++ b/app/Http/Controllers/Api/Client/Servers/PowerController.php @@ -33,6 +33,8 @@ class PowerController extends ClientApiController * @param \Pterodactyl\Http\Requests\Api\Client\Servers\SendPowerRequest $request * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\Response + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function index(SendPowerRequest $request, Server $server): Response { diff --git a/app/Http/Controllers/Api/Client/Servers/StartupController.php b/app/Http/Controllers/Api/Client/Servers/StartupController.php new file mode 100644 index 000000000..6eb1df0ad --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/StartupController.php @@ -0,0 +1,81 @@ +service = $service; + $this->repository = $repository; + } + + /** + * Updates a single variable for a server. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupVariableRequest $request + * @param \Pterodactyl\Models\Server $server + * @return array + * + * @throws \Illuminate\Validation\ValidationException + * @throws \Pterodactyl\Exceptions\Model\DataValidationException + * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException + */ + public function update(UpdateStartupVariableRequest $request, Server $server) + { + /** @var \Pterodactyl\Models\EggVariable $variable */ + $variable = $server->variables()->where('env_variable', $request->input('key'))->first(); + + if (is_null($variable) || !$variable->user_viewable || !$variable->user_editable) { + throw new BadRequestHttpException( + "The environment variable you are trying to edit [\"{$request->input('key')}\"] does not exist." + ); + } + + // Revalidate the variable value using the egg variable specific validation rules for it. + $this->validate($request, ['value' => $variable->rules]); + + $this->repository->updateOrCreate([ + 'server_id' => $server->id, + 'variable_id' => $variable->id, + ], [ + 'variable_value' => $request->input('value'), + ]); + + $variable = $variable->refresh(); + $variable->server_value = $request->input('value'); + + return $this->fractal->item($variable) + ->transformWith($this->getTransformer(EggVariableTransformer::class)) + ->toArray(); + } +} diff --git a/app/Http/Controllers/Api/Client/Servers/SubuserController.php b/app/Http/Controllers/Api/Client/Servers/SubuserController.php index da6fee428..d8bdcc40a 100644 --- a/app/Http/Controllers/Api/Client/Servers/SubuserController.php +++ b/app/Http/Controllers/Api/Client/Servers/SubuserController.php @@ -3,7 +3,9 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; use Illuminate\Http\Request; +use Pterodactyl\Models\User; use Pterodactyl\Models\Server; +use Pterodactyl\Models\Subuser; use Illuminate\Http\JsonResponse; use Pterodactyl\Models\Permission; use Pterodactyl\Repositories\Eloquent\SubuserRepository; @@ -57,6 +59,21 @@ class SubuserController extends ClientApiController ->toArray(); } + /** + * Returns a single subuser associated with this server instance. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest $request + * @return array + */ + public function view(GetSubuserRequest $request) + { + $subuser = $request->attributes->get('subuser'); + + return $this->fractal->item($subuser) + ->transformWith($this->getTransformer(SubuserTransformer::class)) + ->toArray(); + } + /** * Create a new subuser for the given server. * @@ -84,15 +101,16 @@ class SubuserController extends ClientApiController * Update a given subuser in the system for the server. * * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request - * @param \Pterodactyl\Models\Server $server * @return array * * @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ - public function update(UpdateSubuserRequest $request, Server $server): array + public function update(UpdateSubuserRequest $request): array { - $subuser = $request->endpointSubuser(); + /** @var \Pterodactyl\Models\Subuser $subuser */ + $subuser = $request->attributes->get('subuser'); + $this->repository->update($subuser->id, [ 'permissions' => $this->getDefaultPermissions($request), ]); @@ -106,14 +124,16 @@ class SubuserController extends ClientApiController * Removes a subusers from a server's assignment. * * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request - * @param \Pterodactyl\Models\Server $server * @return \Illuminate\Http\JsonResponse */ - public function delete(DeleteSubuserRequest $request, Server $server) + public function delete(DeleteSubuserRequest $request) { - $this->repository->delete($request->endpointSubuser()->id); + /** @var \Pterodactyl\Models\Subuser $subuser */ + $subuser = $request->attributes->get('subuser'); - return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT); + $this->repository->delete($subuser->id); + + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } /** diff --git a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php index bb7d94c14..be971d605 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Http\Controllers\Api\Remote\Backups; use Carbon\Carbon; +use Carbon\CarbonImmutable; use Illuminate\Http\JsonResponse; use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Repositories\Eloquent\BackupRepository; @@ -31,25 +32,16 @@ class BackupStatusController extends Controller * @param \Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest $request * @param string $backup * @return \Illuminate\Http\JsonResponse - * - * @throws \Pterodactyl\Exceptions\Model\DataValidationException - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function __invoke(ReportBackupCompleteRequest $request, string $backup) { - /** @var \Pterodactyl\Models\Backup $backup */ - $backup = $this->repository->findFirstWhere([['uuid', '=', $backup]]); + $this->repository->updateWhere([['uuid', '=', $backup]], [ + 'is_successful' => $request->input('successful') ? true : false, + 'sha256_hash' => $request->input('checksum'), + 'bytes' => $request->input('size'), + 'completed_at' => CarbonImmutable::now(), + ]); - if ($request->input('successful')) { - $this->repository->update($backup->id, [ - 'sha256_hash' => $request->input('checksum'), - 'bytes' => $request->input('size'), - 'completed_at' => Carbon::now(), - ], true, true); - } else { - $this->repository->delete($backup->id); - } - - return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT); + return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); } } diff --git a/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php b/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php new file mode 100644 index 000000000..a80f6eefd --- /dev/null +++ b/app/Http/Middleware/Api/Client/Server/SubuserBelongsToServer.php @@ -0,0 +1,36 @@ +route()->parameter('server'); + /** @var \Pterodactyl\Models\User $user */ + $user = $request->route()->parameter('user'); + + // Don't do anything if there isn't a user present in the request. + if (is_null($user)) { + return $next($request); + } + + $request->attributes->set('subuser', $server->subusers()->where('user_id', $user->id)->firstOrFail()); + + return $next($request); + } +} diff --git a/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php b/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php index 0bd40eee5..77879c97f 100644 --- a/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php +++ b/app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Http\Middleware\Api\Client; use Closure; +use Pterodactyl\Models\User; use Pterodactyl\Models\Backup; use Pterodactyl\Models\Database; use Illuminate\Container\Container; @@ -52,6 +53,10 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings return Backup::query()->where('uuid', $value)->firstOrFail(); }); + $this->router->model('user', User::class, function ($value) { + return User::query()->where('uuid', $value)->firstOrFail(); + }); + return parent::handle($request, $next); } } diff --git a/app/Http/Requests/Admin/DatabaseHostFormRequest.php b/app/Http/Requests/Admin/DatabaseHostFormRequest.php index 54d3bd0cc..c6b2468a7 100644 --- a/app/Http/Requests/Admin/DatabaseHostFormRequest.php +++ b/app/Http/Requests/Admin/DatabaseHostFormRequest.php @@ -29,10 +29,6 @@ class DatabaseHostFormRequest extends AdminFormRequest $this->merge(['node_id' => null]); } - $this->merge([ - 'host' => gethostbyname($this->input('host')), - ]); - return parent::getValidatorInstance(); } } diff --git a/app/Http/Requests/Admin/Settings/BaseSettingsFormRequest.php b/app/Http/Requests/Admin/Settings/BaseSettingsFormRequest.php index 0b02561dd..777761b67 100644 --- a/app/Http/Requests/Admin/Settings/BaseSettingsFormRequest.php +++ b/app/Http/Requests/Admin/Settings/BaseSettingsFormRequest.php @@ -19,6 +19,7 @@ class BaseSettingsFormRequest extends AdminFormRequest 'app:name' => 'required|string|max:255', 'pterodactyl:auth:2fa_required' => 'required|integer|in:0,1,2', 'app:locale' => ['required', 'string', Rule::in(array_keys($this->getAvailableLanguages()))], + 'app:analytics' => 'nullable|string', ]; } @@ -31,6 +32,7 @@ class BaseSettingsFormRequest extends AdminFormRequest 'app:name' => 'Company Name', 'pterodactyl:auth:2fa_required' => 'Require 2-Factor Authentication', 'app:locale' => 'Default Language', + 'app:analytics' => 'Google Analytics', ]; } } diff --git a/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php b/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php index 00197388a..a82db1ec0 100644 --- a/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php +++ b/app/Http/Requests/Api/Client/Account/StoreApiKeyRequest.php @@ -17,4 +17,14 @@ class StoreApiKeyRequest extends ClientApiRequest 'allowed_ips.*' => 'ip', ]; } + + /** + * @return array|string[] + */ + public function messages() + { + return [ + 'allowed_ips.*' => 'All of the IP addresses entered must be valid IPv4 addresses.', + ]; + } } diff --git a/app/Http/Requests/Api/Client/GetServersRequest.php b/app/Http/Requests/Api/Client/GetServersRequest.php index c28f0a946..9b4601f25 100644 --- a/app/Http/Requests/Api/Client/GetServersRequest.php +++ b/app/Http/Requests/Api/Client/GetServersRequest.php @@ -2,8 +2,6 @@ namespace Pterodactyl\Http\Requests\Api\Client; -use Pterodactyl\Models\User; - class GetServersRequest extends ClientApiRequest { /** @@ -13,28 +11,4 @@ class GetServersRequest extends ClientApiRequest { return true; } - - /** - * Return the filtering method for servers when the client base endpoint is requested. - * - * @return int - */ - public function getFilterLevel(): int - { - switch ($this->input('type')) { - case 'all': - return User::FILTER_LEVEL_ALL; - break; - case 'admin': - return User::FILTER_LEVEL_ADMIN; - break; - case 'owner': - return User::FILTER_LEVEL_OWNER; - break; - case 'subuser-of': - default: - return User::FILTER_LEVEL_SUBUSER; - break; - } - } } diff --git a/app/Http/Requests/Api/Client/Servers/Files/DecompressFilesRequest.php b/app/Http/Requests/Api/Client/Servers/Files/DecompressFilesRequest.php new file mode 100644 index 000000000..f8493ec4a --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Files/DecompressFilesRequest.php @@ -0,0 +1,32 @@ + 'sometimes|nullable|string', + 'file' => 'required|string', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Startup/UpdateStartupVariableRequest.php b/app/Http/Requests/Api/Client/Servers/Startup/UpdateStartupVariableRequest.php new file mode 100644 index 000000000..63005c78b --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Startup/UpdateStartupVariableRequest.php @@ -0,0 +1,30 @@ + 'required|string', + 'value' => 'present|string', + ]; + } +} diff --git a/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php b/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php index e43b7178e..98d0d9643 100644 --- a/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Subusers/SubuserRequest.php @@ -3,12 +3,10 @@ namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers; use Illuminate\Http\Request; -use Pterodactyl\Models\Server; +use Pterodactyl\Models\User; use Pterodactyl\Exceptions\Http\HttpForbiddenException; -use Pterodactyl\Repositories\Eloquent\SubuserRepository; use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest; -use Pterodactyl\Exceptions\Repository\RecordNotFoundException; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Pterodactyl\Services\Servers\GetUserPermissionsService; abstract class SubuserRequest extends ClientApiRequest { @@ -30,10 +28,10 @@ abstract class SubuserRequest extends ClientApiRequest return false; } - // If there is a subuser present in the URL, validate that it is not the same as the - // current request user. You're not allowed to modify yourself. - if ($this->route()->hasParameter('subuser')) { - if ($this->endpointSubuser()->user_id === $this->user()->id) { + $user = $this->route()->parameter('user'); + // Don't allow a user to edit themselves on the server. + if ($user instanceof User) { + if ($user->uuid === $this->user()->uuid) { return false; } } @@ -71,68 +69,14 @@ abstract class SubuserRequest extends ClientApiRequest // Otherwise, get the current subuser's permission set, and ensure that the // permissions they are trying to assign are not _more_ than the ones they // already have. - if (count(array_diff($permissions, $this->currentUserPermissions())) > 0) { + /** @var \Pterodactyl\Models\Subuser|null $subuser */ + /** @var \Pterodactyl\Services\Servers\GetUserPermissionsService $service */ + $service = $this->container->make(GetUserPermissionsService::class); + + if (count(array_diff($permissions, $service->handle($server, $user))) > 0) { throw new HttpForbiddenException( 'Cannot assign permissions to a subuser that your account does not actively possess.' ); } } - - /** - * Returns the currently authenticated user's permissions. - * - * @return array - * - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function currentUserPermissions(): array - { - /** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */ - $repository = $this->container->make(SubuserRepository::class); - - /* @var \Pterodactyl\Models\Subuser $model */ - try { - $model = $repository->findFirstWhere([ - ['server_id', $this->route()->parameter('server')->id], - ['user_id', $this->user()->id], - ]); - } catch (RecordNotFoundException $exception) { - return []; - } - - return $model->permissions; - } - - /** - * Return the subuser model for the given request which can then be validated. If - * required request parameters are missing a 404 error will be returned, otherwise - * a model exception will be returned if the model is not found. - * - * This returns the subuser based on the endpoint being hit, not the actual subuser - * for the account making the request. - * - * @return \Pterodactyl\Models\Subuser - * - * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - * @throws \Illuminate\Contracts\Container\BindingResolutionException - */ - public function endpointSubuser() - { - /** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */ - $repository = $this->container->make(SubuserRepository::class); - - $parameters = $this->route()->parameters(); - if ( - ! isset($parameters['server'], $parameters['server']) - || ! is_string($parameters['subuser']) - || ! $parameters['server'] instanceof Server - ) { - throw new NotFoundHttpException; - } - - return $this->model ?: $this->model = $repository->getUserForServer( - $parameters['server']->id, $parameters['subuser'] - ); - } } diff --git a/app/Http/ViewComposers/AssetComposer.php b/app/Http/ViewComposers/AssetComposer.php index 7e8f82dbc..6da825ad4 100644 --- a/app/Http/ViewComposers/AssetComposer.php +++ b/app/Http/ViewComposers/AssetComposer.php @@ -37,6 +37,7 @@ class AssetComposer 'enabled' => config('recaptcha.enabled', false), 'siteKey' => config('recaptcha.website_key') ?? '', ], + 'analytics' => config('app.analytics') ?? '', ]); } } diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 14cb4dde9..e164b1ae3 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; * @property int $id * @property int $server_id * @property int $uuid + * @property bool $is_successful * @property string $name * @property string[] $ignored_files * @property string $disk @@ -44,6 +45,7 @@ class Backup extends Model */ protected $casts = [ 'id' => 'int', + 'is_successful' => 'bool', 'bytes' => 'int', 'ignored_files' => 'array', ]; @@ -59,6 +61,7 @@ class Backup extends Model * @var array */ protected $attributes = [ + 'is_successful' => true, 'sha256_hash' => null, 'bytes' => 0, ]; @@ -69,6 +72,7 @@ class Backup extends Model public static $validationRules = [ 'server_id' => 'bail|required|numeric|exists:servers,id', 'uuid' => 'required|uuid', + 'is_successful' => 'boolean', 'name' => 'required|string', 'ignored_files' => 'array', 'disk' => 'required|string', diff --git a/app/Models/DatabaseHost.php b/app/Models/DatabaseHost.php index 6fafce2f0..d76fed494 100644 --- a/app/Models/DatabaseHost.php +++ b/app/Models/DatabaseHost.php @@ -2,6 +2,8 @@ namespace Pterodactyl\Models; +use Pterodactyl\Rules\ResolvesToIPAddress; + class DatabaseHost extends Model { /** @@ -51,13 +53,25 @@ class DatabaseHost extends Model */ public static $validationRules = [ 'name' => 'required|string|max:255', - 'host' => 'required|unique:database_hosts,host', + 'host' => 'required|string', 'port' => 'required|numeric|between:1,65535', 'username' => 'required|string|max:32', 'password' => 'nullable|string', 'node_id' => 'sometimes|nullable|integer|exists:nodes,id', ]; + /** + * @return array + */ + public static function getRules() + { + $rules = parent::getRules(); + + $rules['host'] = array_merge($rules['host'], [ new ResolvesToIPAddress() ]); + + return $rules; + } + /** * Gets the node associated with a database host. * diff --git a/app/Models/EggVariable.php b/app/Models/EggVariable.php index 2db891dc9..c6cc45b56 100644 --- a/app/Models/EggVariable.php +++ b/app/Models/EggVariable.php @@ -2,6 +2,27 @@ namespace Pterodactyl\Models; +/** + * @property int $id + * @property int $egg_id + * @property string $name + * @property string $description + * @property string $env_variable + * @property string $default_value + * @property bool $user_viewable + * @property bool $user_editable + * @property string $rules + * @property \Carbon\CarbonImmutable $created_at + * @property \Carbon\CarbonImmutable $updated_at + * + * @property bool $required + * @property \Pterodactyl\Models\Egg $egg + * @property \Pterodactyl\Models\ServerVariable $serverVariable + * + * The "server_value" variable is only present on the object if you've loaded this model + * using the server relationship. + * @property string|null $server_value + */ class EggVariable extends Model { /** @@ -17,6 +38,11 @@ class EggVariable extends Model */ const RESERVED_ENV_NAMES = 'SERVER_MEMORY,SERVER_IP,SERVER_PORT,ENV,HOME,USER,STARTUP,SERVER_UUID,UUID'; + /** + * @var bool + */ + protected $immutableDates = true; + /** * The table associated with the model. * @@ -38,8 +64,8 @@ class EggVariable extends Model */ protected $casts = [ 'egg_id' => 'integer', - 'user_viewable' => 'integer', - 'user_editable' => 'integer', + 'user_viewable' => 'bool', + 'user_editable' => 'bool', ]; /** @@ -65,12 +91,19 @@ class EggVariable extends Model ]; /** - * @param $value * @return bool */ - public function getRequiredAttribute($value) + public function getRequiredAttribute() { - return $this->rules === 'required' || str_contains($this->rules, ['required|', '|required']); + return in_array('required', explode('|', $this->rules)); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\HasOne + */ + public function egg() + { + return $this->hasOne(Egg::class); } /** diff --git a/app/Models/Permission.php b/app/Models/Permission.php index af3dc5cf9..a7eb2709b 100644 --- a/app/Models/Permission.php +++ b/app/Models/Permission.php @@ -55,6 +55,9 @@ class Permission extends Model const ACTION_FILE_ARCHIVE = 'file.archive'; const ACTION_FILE_SFTP = 'file.sftp'; + const ACTION_STARTUP_READ = 'startup.read'; + const ACTION_STARTUP_UPDATE = 'startup.update'; + const ACTION_SETTINGS_RENAME = 'settings.rename'; const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; @@ -169,8 +172,8 @@ class Permission extends Model 'startup' => [ 'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.', 'keys' => [ - 'read' => '', - 'update' => '', + 'read' => 'Allows a user to view the startup variables for a server.', + 'update' => 'Allows a user to modify the startup variables for the server.', ], ], diff --git a/app/Models/Server.php b/app/Models/Server.php index 8f15bfcf1..e6e9bca72 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -38,14 +38,14 @@ use Znck\Eloquent\Traits\BelongsToThrough; * @property \Carbon\Carbon $updated_at * * @property \Pterodactyl\Models\User $user - * @property \Pterodactyl\Models\User[]|\Illuminate\Database\Eloquent\Collection $subusers + * @property \Pterodactyl\Models\Subuser[]|\Illuminate\Database\Eloquent\Collection $subusers * @property \Pterodactyl\Models\Allocation $allocation * @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations * @property \Pterodactyl\Models\Pack|null $pack * @property \Pterodactyl\Models\Node $node * @property \Pterodactyl\Models\Nest $nest * @property \Pterodactyl\Models\Egg $egg - * @property \Pterodactyl\Models\ServerVariable[]|\Illuminate\Database\Eloquent\Collection $variables + * @property \Pterodactyl\Models\EggVariable[]|\Illuminate\Database\Eloquent\Collection $variables * @property \Pterodactyl\Models\Schedule[]|\Illuminate\Database\Eloquent\Collection $schedule * @property \Pterodactyl\Models\Database[]|\Illuminate\Database\Eloquent\Collection $databases * @property \Pterodactyl\Models\Location $location @@ -270,7 +270,9 @@ class Server extends Model */ public function variables() { - return $this->hasMany(ServerVariable::class); + return $this->hasMany(EggVariable::class, 'egg_id', 'egg_id') + ->select(['egg_variables.*', 'server_variables.variable_value as server_value']) + ->leftJoin('server_variables', 'server_variables.variable_id', '=', 'egg_variables.id'); } /** diff --git a/app/Models/User.php b/app/Models/User.php index baff65b6f..39954fbf3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -57,11 +57,6 @@ class User extends Model implements const USER_LEVEL_USER = 0; const USER_LEVEL_ADMIN = 1; - const FILTER_LEVEL_ALL = 0; - const FILTER_LEVEL_OWNER = 1; - const FILTER_LEVEL_ADMIN = 2; - const FILTER_LEVEL_SUBUSER = 3; - /** * The resource name for this model when it is transformed into an * API representation using fractal. diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 8a1d4db21..abd88c04b 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -21,6 +21,7 @@ class SettingsServiceProvider extends ServiceProvider protected $keys = [ 'app:name', 'app:locale', + 'app:analytics', 'recaptcha:enabled', 'recaptcha:secret_key', 'recaptcha:website_key', diff --git a/app/Repositories/Eloquent/BackupRepository.php b/app/Repositories/Eloquent/BackupRepository.php index 4afdd4bd2..adbbd9c93 100644 --- a/app/Repositories/Eloquent/BackupRepository.php +++ b/app/Repositories/Eloquent/BackupRepository.php @@ -27,6 +27,7 @@ class BackupRepository extends EloquentRepository return $this->getBuilder() ->withTrashed() ->where('server_id', $server) + ->where('is_successful', true) ->where('created_at', '>=', Carbon::now()->subMinutes($minutes)->toDateTimeString()) ->get() ->toBase(); diff --git a/app/Repositories/Eloquent/ServerRepository.php b/app/Repositories/Eloquent/ServerRepository.php index a64f68db9..0f7919305 100644 --- a/app/Repositories/Eloquent/ServerRepository.php +++ b/app/Repositories/Eloquent/ServerRepository.php @@ -143,6 +143,10 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt */ public function getVariablesWithValues(int $id, bool $returnAsObject = false) { + $this->getBuilder() + ->with('variables', 'egg.variables') + ->findOrFail($id); + try { $instance = $this->getBuilder()->with('variables', 'egg.variables')->find($id, $this->getColumns()); } catch (ModelNotFoundException $exception) { diff --git a/app/Repositories/Eloquent/SubuserRepository.php b/app/Repositories/Eloquent/SubuserRepository.php index e00d825e7..c0fb930a6 100644 --- a/app/Repositories/Eloquent/SubuserRepository.php +++ b/app/Repositories/Eloquent/SubuserRepository.php @@ -18,30 +18,6 @@ class SubuserRepository extends EloquentRepository implements SubuserRepositoryI return Subuser::class; } - /** - * Returns a subuser model for the given user and server combination. If no record - * exists an exception will be thrown. - * - * @param int $server - * @param string $uuid - * @return \Pterodactyl\Models\Subuser - * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - */ - public function getUserForServer(int $server, string $uuid): Subuser - { - /** @var \Pterodactyl\Models\Subuser $model */ - $model = $this->getBuilder() - ->with('server', 'user') - ->select('subusers.*') - ->join('users', 'users.id', '=', 'subusers.user_id') - ->where('subusers.server_id', $server) - ->where('users.uuid', $uuid) - ->firstOrFail(); - - return $model; - } - /** * Return a subuser with the associated server relationship. * diff --git a/app/Repositories/Wings/DaemonCommandRepository.php b/app/Repositories/Wings/DaemonCommandRepository.php index 644bb024c..38f2fb475 100644 --- a/app/Repositories/Wings/DaemonCommandRepository.php +++ b/app/Repositories/Wings/DaemonCommandRepository.php @@ -5,6 +5,8 @@ namespace Pterodactyl\Repositories\Wings; use Webmozart\Assert\Assert; use Pterodactyl\Models\Server; use Psr\Http\Message\ResponseInterface; +use GuzzleHttp\Exception\TransferException; +use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; class DaemonCommandRepository extends DaemonRepository { @@ -13,16 +15,22 @@ class DaemonCommandRepository extends DaemonRepository * * @param string|string[] $command * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function send($command): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/commands', $this->server->uuid), - [ - 'json' => ['commands' => is_array($command) ? $command : [$command]], - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/commands', $this->server->uuid), + [ + 'json' => ['commands' => is_array($command) ? $command : [$command]], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } } diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index 7a0934b10..553e39d24 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -5,7 +5,9 @@ namespace Pterodactyl\Repositories\Wings; use Webmozart\Assert\Assert; use Pterodactyl\Models\Server; use Psr\Http\Message\ResponseInterface; +use GuzzleHttp\Exception\TransferException; use Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException; +use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; class DaemonFileRepository extends DaemonRepository { @@ -18,17 +20,22 @@ class DaemonFileRepository extends DaemonRepository * * @throws \GuzzleHttp\Exception\TransferException * @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function getContent(string $path, int $notLargerThan = null): string { Assert::isInstanceOf($this->server, Server::class); - $response = $this->getHttpClient()->get( - sprintf('/api/servers/%s/files/contents', $this->server->uuid), - [ - 'query' => ['file' => $path], - ] - ); + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/contents', $this->server->uuid), + [ + 'query' => ['file' => $path], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } $length = (int) $response->getHeader('Content-Length')[0] ?? 0; @@ -47,19 +54,23 @@ class DaemonFileRepository extends DaemonRepository * @param string $content * @return \Psr\Http\Message\ResponseInterface * - * @throws \GuzzleHttp\Exception\TransferException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function putContent(string $path, string $content): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/write', $this->server->uuid), - [ - 'query' => ['file' => $path], - 'body' => $content, - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/write', $this->server->uuid), + [ + 'query' => ['file' => $path], + 'body' => $content, + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -68,18 +79,22 @@ class DaemonFileRepository extends DaemonRepository * @param string $path * @return array * - * @throws \GuzzleHttp\Exception\TransferException + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function getDirectory(string $path): array { Assert::isInstanceOf($this->server, Server::class); - $response = $this->getHttpClient()->get( - sprintf('/api/servers/%s/files/list-directory', $this->server->uuid), - [ - 'query' => ['directory' => $path], - ] - ); + try { + $response = $this->getHttpClient()->get( + sprintf('/api/servers/%s/files/list-directory', $this->server->uuid), + [ + 'query' => ['directory' => $path], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } return json_decode($response->getBody(), true); } @@ -90,20 +105,26 @@ class DaemonFileRepository extends DaemonRepository * @param string $name * @param string $path * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function createDirectory(string $name, string $path): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/create-directory', $this->server->uuid), - [ - 'json' => [ - 'name' => urldecode($name), - 'path' => urldecode($path), - ], - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/create-directory', $this->server->uuid), + [ + 'json' => [ + 'name' => urldecode($name), + 'path' => urldecode($path), + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -112,20 +133,26 @@ class DaemonFileRepository extends DaemonRepository * @param string|null $root * @param array $files * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function renameFiles(?string $root, array $files): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->put( - sprintf('/api/servers/%s/files/rename', $this->server->uuid), - [ - 'json' => [ - 'root' => $root ?? '/', - 'files' => $files, - ], - ] - ); + try { + return $this->getHttpClient()->put( + sprintf('/api/servers/%s/files/rename', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -133,19 +160,25 @@ class DaemonFileRepository extends DaemonRepository * * @param string $location * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function copyFile(string $location): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/copy', $this->server->uuid), - [ - 'json' => [ - 'location' => urldecode($location), - ], - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/copy', $this->server->uuid), + [ + 'json' => [ + 'location' => urldecode($location), + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -154,20 +187,26 @@ class DaemonFileRepository extends DaemonRepository * @param string|null $root * @param array $files * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function deleteFiles(?string $root, array $files): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/delete', $this->server->uuid), - [ - 'json' => [ - 'root' => $root ?? '/', - 'files' => $files, - ], - ] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/delete', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } /** @@ -176,21 +215,58 @@ class DaemonFileRepository extends DaemonRepository * @param string|null $root * @param array $files * @return array + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function compressFiles(?string $root, array $files): array { Assert::isInstanceOf($this->server, Server::class); - $response = $this->getHttpClient()->post( - sprintf('/api/servers/%s/files/compress', $this->server->uuid), - [ - 'json' => [ - 'root' => $root ?? '/', - 'files' => $files, - ], - ] - ); + try { + $response = $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/compress', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'files' => $files, + ], + // Wait for up to 15 minutes for the archive to be completed when calling this endpoint + // since it will likely take quite awhile for large directories. + 'timeout' => 60 * 15, + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } return json_decode($response->getBody(), true); } + + /** + * Decompresses a given archive file. + * + * @param string|null $root + * @param string $file + * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException + */ + public function decompressFile(?string $root, string $file): ResponseInterface + { + Assert::isInstanceOf($this->server, Server::class); + + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/files/decompress', $this->server->uuid), + [ + 'json' => [ + 'root' => $root ?? '/', + 'file' => $file, + ], + ] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } + } } diff --git a/app/Repositories/Wings/DaemonPowerRepository.php b/app/Repositories/Wings/DaemonPowerRepository.php index d7ef42c4f..ccbf169ff 100644 --- a/app/Repositories/Wings/DaemonPowerRepository.php +++ b/app/Repositories/Wings/DaemonPowerRepository.php @@ -5,6 +5,8 @@ namespace Pterodactyl\Repositories\Wings; use Webmozart\Assert\Assert; use Pterodactyl\Models\Server; use Psr\Http\Message\ResponseInterface; +use GuzzleHttp\Exception\TransferException; +use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; class DaemonPowerRepository extends DaemonRepository { @@ -13,14 +15,20 @@ class DaemonPowerRepository extends DaemonRepository * * @param string $action * @return \Psr\Http\Message\ResponseInterface + * + * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException */ public function send(string $action): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); - return $this->getHttpClient()->post( - sprintf('/api/servers/%s/power', $this->server->uuid), - ['json' => ['action' => $action]] - ); + try { + return $this->getHttpClient()->post( + sprintf('/api/servers/%s/power', $this->server->uuid), + ['json' => ['action' => $action]] + ); + } catch (TransferException $exception) { + throw new DaemonConnectionException($exception); + } } } diff --git a/app/Repositories/Wings/DaemonServerRepository.php b/app/Repositories/Wings/DaemonServerRepository.php index b41c75483..abb5dae4e 100644 --- a/app/Repositories/Wings/DaemonServerRepository.php +++ b/app/Repositories/Wings/DaemonServerRepository.php @@ -23,7 +23,7 @@ class DaemonServerRepository extends DaemonRepository sprintf('/api/servers/%s', $this->server->uuid) ); } catch (TransferException $exception) { - throw new DaemonConnectionException($exception); + throw new DaemonConnectionException($exception, false); } return json_decode($response->getBody()->__toString(), true); diff --git a/app/Rules/ResolvesToIPAddress.php b/app/Rules/ResolvesToIPAddress.php new file mode 100644 index 000000000..e1421b52c --- /dev/null +++ b/app/Rules/ResolvesToIPAddress.php @@ -0,0 +1,49 @@ +backup_limit || $server->backups()->count() >= $server->backup_limit) { + if (! $server->backup_limit || $server->backups()->where('is_successful', true)->count() >= $server->backup_limit) { throw new TooManyBackupsException($server->backup_limit); } $previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, 10); if ($previous->count() >= 2) { throw new TooManyRequestsHttpException( - Carbon::now()->diffInSeconds($previous->last()->created_at->addMinutes(10)), + CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addMinutes(10)), 'Only two backups may be generated within a 10 minute span of time.' ); } diff --git a/app/Services/Eggs/EggConfigurationService.php b/app/Services/Eggs/EggConfigurationService.php index 3d98cc33c..6f4eae689 100644 --- a/app/Services/Eggs/EggConfigurationService.php +++ b/app/Services/Eggs/EggConfigurationService.php @@ -51,12 +51,29 @@ class EggConfigurationService ); return [ - 'startup' => json_decode($server->egg->inherit_config_startup), + 'startup' => $this->convertStartupToNewFormat(json_decode($server->egg->inherit_config_startup, true)), 'stop' => $this->convertStopToNewFormat($server->egg->inherit_config_stop), 'configs' => $configs, ]; } + /** + * Convert the "done" variable into an array if it is not currently one. + * + * @param array $startup + * @return array + */ + protected function convertStartupToNewFormat(array $startup) + { + $done = Arr::get($startup, 'done'); + + return [ + 'done' => is_string($done) ? [$done] : $done, + 'user_interaction' => Arr::get($startup, 'userInteraction') ?? Arr::get($startup, 'user_interaction') ?? [], + 'strip_ansi' => Arr::get($startup, 'strip_ansi') ?? false, + ]; + } + /** * Converts a legacy stop string into a new generation stop option for a server. * diff --git a/app/Services/Servers/GetUserPermissionsService.php b/app/Services/Servers/GetUserPermissionsService.php index 98dcf6c34..e0ea20373 100644 --- a/app/Services/Servers/GetUserPermissionsService.php +++ b/app/Services/Servers/GetUserPermissionsService.php @@ -30,7 +30,7 @@ class GetUserPermissionsService } /** @var \Pterodactyl\Models\Subuser|null $subuserPermissions */ - $subuserPermissions = $server->subusers->where('user_id', $user->id)->first(); + $subuserPermissions = $server->subusers()->where('user_id', $user->id)->first(); return $subuserPermissions ? $subuserPermissions->permissions : []; } diff --git a/app/Services/Servers/StartupCommandService.php b/app/Services/Servers/StartupCommandService.php new file mode 100644 index 000000000..5ee170aa0 --- /dev/null +++ b/app/Services/Servers/StartupCommandService.php @@ -0,0 +1,27 @@ +memory, $server->allocation->ip, $server->allocation->port]; + + foreach ($server->variables as $variable) { + $find[] = '{{' . $variable->env_variable . '}}'; + $replace[] = $variable->user_viewable ? ($variable->server_value ?? $variable->default_value) : '[hidden]'; + } + + return str_replace($find, $replace, $server->startup); + } +} diff --git a/app/Services/Servers/StartupCommandViewService.php b/app/Services/Servers/StartupCommandViewService.php deleted file mode 100644 index d3cda3143..000000000 --- a/app/Services/Servers/StartupCommandViewService.php +++ /dev/null @@ -1,56 +0,0 @@ -repository = $repository; - } - - /** - * Generate a startup command for a server and return all of the user-viewable variables - * as well as their assigned values. - * - * @param int $server - * @return \Illuminate\Support\Collection - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function handle(int $server): Collection - { - $response = $this->repository->getVariablesWithValues($server, true); - $server = $this->repository->getPrimaryAllocation($response->server); - - $find = ['{{SERVER_MEMORY}}', '{{SERVER_IP}}', '{{SERVER_PORT}}']; - $replace = [$server->memory, $server->getRelation('allocation')->ip, $server->getRelation('allocation')->port]; - - $variables = $server->getRelation('egg')->getRelation('variables') - ->each(function ($variable) use (&$find, &$replace, $response) { - $find[] = '{{' . $variable->env_variable . '}}'; - $replace[] = $variable->user_viewable ? $response->data[$variable->env_variable] : '[hidden]'; - })->filter(function ($variable) { - return $variable->user_viewable === 1; - }); - - return collect([ - 'startup' => str_replace($find, $replace, $server->startup), - 'variables' => $variables, - 'server_values' => $response->data, - ]); - } -} diff --git a/app/Transformers/Api/Client/BackupTransformer.php b/app/Transformers/Api/Client/BackupTransformer.php index 53966fc77..15d6b357f 100644 --- a/app/Transformers/Api/Client/BackupTransformer.php +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -22,6 +22,7 @@ class BackupTransformer extends BaseClientTransformer { return [ 'uuid' => $backup->uuid, + 'is_successful' => $backup->is_successful, 'name' => $backup->name, 'ignored_files' => $backup->ignored_files, 'sha256_hash' => $backup->sha256_hash, diff --git a/app/Transformers/Api/Client/DatabaseTransformer.php b/app/Transformers/Api/Client/DatabaseTransformer.php index 8d420ea83..ddf02af10 100644 --- a/app/Transformers/Api/Client/DatabaseTransformer.php +++ b/app/Transformers/Api/Client/DatabaseTransformer.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Transformers\Api\Client; use Pterodactyl\Models\Database; use League\Fractal\Resource\Item; +use Pterodactyl\Models\Permission; use Illuminate\Contracts\Encryption\Encrypter; use Pterodactyl\Contracts\Extensions\HashidsInterface; @@ -65,12 +66,16 @@ class DatabaseTransformer extends BaseClientTransformer /** * Include the database password in the request. * - * @param \Pterodactyl\Models\Database $model - * @return \League\Fractal\Resource\Item + * @param \Pterodactyl\Models\Database $database + * @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource */ - public function includePassword(Database $model): Item + public function includePassword(Database $database): Item { - return $this->item($model, function (Database $model) { + if (!$this->getUser()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $database->server)) { + return $this->null(); + } + + return $this->item($database, function (Database $model) { return [ 'password' => $this->encrypter->decrypt($model->password), ]; diff --git a/app/Transformers/Api/Client/EggVariableTransformer.php b/app/Transformers/Api/Client/EggVariableTransformer.php new file mode 100644 index 000000000..62be843f2 --- /dev/null +++ b/app/Transformers/Api/Client/EggVariableTransformer.php @@ -0,0 +1,33 @@ + $variable->name, + 'description' => $variable->description, + 'env_variable' => $variable->env_variable, + 'default_value' => $variable->default_value, + 'server_value' => $variable->server_value, + 'is_editable' => $variable->user_editable, + 'rules' => $variable->rules, + ]; + } +} diff --git a/app/Transformers/Api/Client/ServerTransformer.php b/app/Transformers/Api/Client/ServerTransformer.php index 148fd8990..6d5b86ac5 100644 --- a/app/Transformers/Api/Client/ServerTransformer.php +++ b/app/Transformers/Api/Client/ServerTransformer.php @@ -6,13 +6,17 @@ use Pterodactyl\Models\Egg; use Pterodactyl\Models\Server; use Pterodactyl\Models\Subuser; use Pterodactyl\Models\Allocation; +use Pterodactyl\Models\Permission; +use Illuminate\Container\Container; +use Pterodactyl\Models\EggVariable; +use Pterodactyl\Services\Servers\StartupCommandService; class ServerTransformer extends BaseClientTransformer { /** * @var string[] */ - protected $defaultIncludes = ['allocations']; + protected $defaultIncludes = ['allocations', 'variables']; /** * @var array @@ -36,6 +40,9 @@ class ServerTransformer extends BaseClientTransformer */ public function transform(Server $server): array { + /** @var \Pterodactyl\Services\Servers\StartupCommandService $service */ + $service = Container::getInstance()->make(StartupCommandService::class); + return [ 'server_owner' => $this->getKey()->user_id === $server->owner_id, 'identifier' => $server->uuidShort, @@ -54,6 +61,7 @@ class ServerTransformer extends BaseClientTransformer 'io' => $server->io, 'cpu' => $server->cpu, ], + 'invocation' => $service->handle($server), 'feature_limits' => [ 'databases' => $server->database_limit, 'allocations' => $server->allocation_limit, @@ -68,11 +76,16 @@ class ServerTransformer extends BaseClientTransformer * Returns the allocations associated with this server. * * @param \Pterodactyl\Models\Server $server - * @return \League\Fractal\Resource\Collection + * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException */ public function includeAllocations(Server $server) { + if (! $this->getUser()->can(Permission::ACTION_ALLOCATION_READ, $server)) { + return $this->null(); + } + return $this->collection( $server->allocations, $this->makeTransformer(AllocationTransformer::class), @@ -80,6 +93,25 @@ class ServerTransformer extends BaseClientTransformer ); } + /** + * @param \Pterodactyl\Models\Server $server + * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * + * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException + */ + public function includeVariables(Server $server) + { + if (! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)) { + return $this->null(); + } + + return $this->collection( + $server->variables->where('user_viewable', true), + $this->makeTransformer(EggVariableTransformer::class), + EggVariable::RESOURCE_NAME + ); + } + /** * Returns the egg associated with this server. * @@ -96,11 +128,16 @@ class ServerTransformer extends BaseClientTransformer * Returns the subusers associated with this server. * * @param \Pterodactyl\Models\Server $server - * @return \League\Fractal\Resource\Collection + * @return \League\Fractal\Resource\Collection|\League\Fractal\Resource\NullResource + * * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException */ public function includeSubusers(Server $server) { + if (! $this->getUser()->can(Permission::ACTION_USER_READ, $server)) { + return $this->null(); + } + return $this->collection($server->subusers, $this->makeTransformer(SubuserTransformer::class), Subuser::RESOURCE_NAME); } } diff --git a/app/Transformers/Api/Client/StatsTransformer.php b/app/Transformers/Api/Client/StatsTransformer.php index 0fc1563a0..97989cc3a 100644 --- a/app/Transformers/Api/Client/StatsTransformer.php +++ b/app/Transformers/Api/Client/StatsTransformer.php @@ -27,11 +27,11 @@ class StatsTransformer extends BaseClientTransformer 'current_state' => Arr::get($data, 'state', 'stopped'), 'is_suspended' => Arr::get($data, 'suspended', false), 'resources' => [ - 'memory_bytes' => Arr::get($data, 'resources.memory_bytes', 0), - 'cpu_absolute' => Arr::get($data, 'resources.cpu_absolute', 0), - 'disk_bytes' => Arr::get($data, 'resources.disk_bytes', 0), - 'network_rx_bytes' => Arr::get($data, 'resources.network.rx_bytes', 0), - 'network_tx_bytes' => Arr::get($data, 'resources.network.tx_bytes', 0), + 'memory_bytes' => Arr::get($data, 'memory_bytes', 0), + 'cpu_absolute' => Arr::get($data, 'cpu_absolute', 0), + 'disk_bytes' => Arr::get($data, 'disk_bytes', 0), + 'network_rx_bytes' => Arr::get($data, 'network.rx_bytes', 0), + 'network_tx_bytes' => Arr::get($data, 'network.tx_bytes', 0), ], ]; } diff --git a/config/pterodactyl.php b/config/pterodactyl.php index 70014bc0a..b37790cbc 100644 --- a/config/pterodactyl.php +++ b/config/pterodactyl.php @@ -85,8 +85,8 @@ return [ | Configure the timeout to be used for Guzzle connections here. */ 'guzzle' => [ - 'timeout' => env('GUZZLE_TIMEOUT', 5), - 'connect_timeout' => env('GUZZLE_CONNECT_TIMEOUT', 3), + 'timeout' => env('GUZZLE_TIMEOUT', 30), + 'connect_timeout' => env('GUZZLE_CONNECT_TIMEOUT', 10), ], /* diff --git a/database/migrations/2020_08_20_205533_add_backup_state_column_to_backups.php b/database/migrations/2020_08_20_205533_add_backup_state_column_to_backups.php new file mode 100644 index 000000000..a08568490 --- /dev/null +++ b/database/migrations/2020_08_20_205533_add_backup_state_column_to_backups.php @@ -0,0 +1,32 @@ +boolean('is_successful')->after('uuid')->default(true); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('backups', function (Blueprint $table) { + $table->dropColumn('is_successful'); + }); + } +} diff --git a/database/migrations/2020_08_22_132500_update_bytes_to_unsigned_bigint.php b/database/migrations/2020_08_22_132500_update_bytes_to_unsigned_bigint.php new file mode 100644 index 000000000..802994ebe --- /dev/null +++ b/database/migrations/2020_08_22_132500_update_bytes_to_unsigned_bigint.php @@ -0,0 +1,32 @@ +unsignedBigInteger('bytes')->default(0)->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('backups', function (Blueprint $table) { + $table->integer('bytes')->default(0)->change(); + }); + } +} diff --git a/package.json b/package.json index 99bcf0d37..e939af813 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "@fortawesome/fontawesome-svg-core": "1.2.19", "@fortawesome/free-solid-svg-icons": "^5.9.0", "@fortawesome/react-fontawesome": "0.1.4", - "@types/react-google-recaptcha": "^1.1.1", "axios": "^0.19.2", "ayu-ace": "^2.0.4", "brace": "^0.11.1", @@ -26,11 +25,14 @@ "react-dom": "npm:@hot-loader/react-dom", "react-fast-compare": "^3.2.0", "react-google-recaptcha": "^2.0.1", + "react-helmet": "^6.1.0", + "react-ga": "^3.1.2", "react-hot-loader": "^4.12.21", "react-i18next": "^11.2.1", "react-redux": "^7.1.0", "react-router-dom": "^5.1.2", "react-transition-group": "^4.4.1", + "reaptcha": "^1.7.2", "sockette": "^2.0.6", "styled-components": "^5.1.1", "styled-components-breakpoint": "^3.0.0-preview.20", @@ -61,6 +63,7 @@ "@types/query-string": "^6.3.0", "@types/react": "^16.9.41", "@types/react-dom": "^16.9.8", + "@types/react-helmet": "^6.0.0", "@types/react-redux": "^7.1.1", "@types/react-router": "^5.1.3", "@types/react-router-dom": "^5.1.3", diff --git a/resources/scripts/.eslintrc.yml b/resources/scripts/.eslintrc.yml index 0e22c8f66..b18f90af9 100644 --- a/resources/scripts/.eslintrc.yml +++ b/resources/scripts/.eslintrc.yml @@ -39,6 +39,8 @@ rules: comma-dangle: - warn - always-multiline + spaced-comment: + - warn array-bracket-spacing: - warn - always diff --git a/resources/scripts/TransitionRouter.tsx b/resources/scripts/TransitionRouter.tsx index 227f7dcdc..342e31a7a 100644 --- a/resources/scripts/TransitionRouter.tsx +++ b/resources/scripts/TransitionRouter.tsx @@ -1,9 +1,10 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Route } from 'react-router'; import { SwitchTransition } from 'react-transition-group'; import Fade from '@/components/elements/Fade'; import styled from 'styled-components/macro'; import tw from 'twin.macro'; +import v4 from 'uuid/v4'; const StyledSwitchTransition = styled(SwitchTransition)` ${tw`relative`}; @@ -13,18 +14,22 @@ const StyledSwitchTransition = styled(SwitchTransition)` } `; -const TransitionRouter: React.FC = ({ children }) => ( - ( - - -
- {children} -
-
-
- )} - /> -); +const TransitionRouter: React.FC = ({ children }) => { + const uuid = useRef(v4()).current; + + return ( + ( + + +
+ {children} +
+
+
+ )} + /> + ); +}; export default TransitionRouter; diff --git a/resources/scripts/api/auth/requestPasswordResetEmail.ts b/resources/scripts/api/auth/requestPasswordResetEmail.ts index d70139899..2168160c2 100644 --- a/resources/scripts/api/auth/requestPasswordResetEmail.ts +++ b/resources/scripts/api/auth/requestPasswordResetEmail.ts @@ -1,8 +1,8 @@ import http from '@/api/http'; -export default (email: string): Promise => { +export default (email: string, recaptchaData?: string): Promise => { return new Promise((resolve, reject) => { - http.post('/auth/password', { email }) + http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData }) .then(response => resolve(response.data.status || '')) .catch(reject); }); diff --git a/resources/scripts/api/getServers.ts b/resources/scripts/api/getServers.ts index 8dd8ed22a..63329bfa7 100644 --- a/resources/scripts/api/getServers.ts +++ b/resources/scripts/api/getServers.ts @@ -1,13 +1,19 @@ import { rawDataToServerObject, Server } from '@/api/server/getServer'; import http, { getPaginationSet, PaginatedResult } from '@/api/http'; -export default (query?: string, includeAdmin?: boolean): Promise> => { +interface QueryParams { + query?: string; + page?: number; + onlyAdmin?: boolean; +} + +export default ({ query, page = 1, onlyAdmin = false }: QueryParams): Promise> => { return new Promise((resolve, reject) => { http.get('/api/client', { params: { - include: [ 'allocation' ], - type: includeAdmin ? 'all' : undefined, + type: onlyAdmin ? 'admin' : undefined, 'filter[name]': query, + page, }, }) .then(({ data }) => resolve({ diff --git a/resources/scripts/api/server/backups/createServerBackup.ts b/resources/scripts/api/server/backups/createServerBackup.ts index ade809bb1..a27d5d146 100644 --- a/resources/scripts/api/server/backups/createServerBackup.ts +++ b/resources/scripts/api/server/backups/createServerBackup.ts @@ -1,5 +1,6 @@ -import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups'; import http from '@/api/http'; +import { ServerBackup } from '@/api/server/types'; +import { rawDataToServerBackup } from '@/api/transformers'; export default (uuid: string, name?: string, ignored?: string): Promise => { return new Promise((resolve, reject) => { diff --git a/resources/scripts/api/server/backups/getServerBackups.ts b/resources/scripts/api/server/backups/getServerBackups.ts deleted file mode 100644 index 49f3aa24c..000000000 --- a/resources/scripts/api/server/backups/getServerBackups.ts +++ /dev/null @@ -1,32 +0,0 @@ -import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http'; - -export interface ServerBackup { - uuid: string; - name: string; - ignoredFiles: string; - sha256Hash: string; - bytes: number; - createdAt: Date; - completedAt: Date | null; -} - -export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({ - uuid: attributes.uuid, - name: attributes.name, - ignoredFiles: attributes.ignored_files, - sha256Hash: attributes.sha256_hash, - bytes: attributes.bytes, - createdAt: new Date(attributes.created_at), - completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, -}); - -export default (uuid: string, page?: number | string): Promise> => { - return new Promise((resolve, reject) => { - http.get(`/api/client/servers/${uuid}/backups`, { params: { page } }) - .then(({ data }) => resolve({ - items: (data.data || []).map(rawDataToServerBackup), - pagination: getPaginationSet(data.meta.pagination), - })) - .catch(reject); - }); -}; diff --git a/resources/scripts/api/server/files/compressFiles.ts b/resources/scripts/api/server/files/compressFiles.ts index 0554c7fd9..4204f0884 100644 --- a/resources/scripts/api/server/files/compressFiles.ts +++ b/resources/scripts/api/server/files/compressFiles.ts @@ -4,8 +4,8 @@ import { rawDataToFileObject } from '@/api/transformers'; export default async (uuid: string, directory: string, files: string[]): Promise => { const { data } = await http.post(`/api/client/servers/${uuid}/files/compress`, { root: directory, files }, { - timeout: 300000, - timeoutErrorMessage: 'It looks like this archive is taking a long time to generate. It will appear when completed.', + timeout: 60000, + timeoutErrorMessage: 'It looks like this archive is taking a long time to generate. It will appear once completed.', }); return rawDataToFileObject(data); diff --git a/resources/scripts/api/server/files/decompressFiles.ts b/resources/scripts/api/server/files/decompressFiles.ts new file mode 100644 index 000000000..d674eadb0 --- /dev/null +++ b/resources/scripts/api/server/files/decompressFiles.ts @@ -0,0 +1,8 @@ +import http from '@/api/http'; + +export default async (uuid: string, directory: string, file: string): Promise => { + await http.post(`/api/client/servers/${uuid}/files/decompress`, { root: directory, file }, { + timeout: 300000, + timeoutErrorMessage: 'It looks like this archive is taking a long time to be unarchived. Once completed the unarchived files will appear.', + }); +}; diff --git a/resources/scripts/api/server/files/loadDirectory.ts b/resources/scripts/api/server/files/loadDirectory.ts index 85f290689..77e44bce8 100644 --- a/resources/scripts/api/server/files/loadDirectory.ts +++ b/resources/scripts/api/server/files/loadDirectory.ts @@ -2,7 +2,7 @@ import http from '@/api/http'; import { rawDataToFileObject } from '@/api/transformers'; export interface FileObject { - uuid: string; + key: string; name: string; mode: string; size: number; @@ -12,6 +12,7 @@ export interface FileObject { mimetype: string; createdAt: Date; modifiedAt: Date; + isArchiveType: () => boolean; } export default async (uuid: string, directory?: string): Promise => { diff --git a/resources/scripts/api/server/getServer.ts b/resources/scripts/api/server/getServer.ts index 7072033f1..278b21e17 100644 --- a/resources/scripts/api/server/getServer.ts +++ b/resources/scripts/api/server/getServer.ts @@ -1,5 +1,6 @@ import http, { FractalResponseData, FractalResponseList } from '@/api/http'; -import { rawDataToServerAllocation } from '@/api/transformers'; +import { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers'; +import { ServerEggVariable } from '@/api/server/types'; export interface Allocation { id: number; @@ -19,8 +20,8 @@ export interface Server { ip: string; port: number; }; + invocation: string; description: string; - allocations: Allocation[]; limits: { memory: number; swap: number; @@ -36,6 +37,8 @@ export interface Server { }; isSuspended: boolean; isInstalling: boolean; + variables: ServerEggVariable[]; + allocations: Allocation[]; } export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({ @@ -43,6 +46,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) uuid: data.uuid, name: data.name, node: data.node, + invocation: data.invocation, sftpDetails: { ip: data.sftp_details.ip, port: data.sftp_details.port, @@ -52,6 +56,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData) featureLimits: { ...data.feature_limits }, isSuspended: data.is_suspended, isInstalling: data.is_installing, + variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable), allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation), }); diff --git a/resources/scripts/api/server/types.d.ts b/resources/scripts/api/server/types.d.ts new file mode 100644 index 000000000..e11a39c45 --- /dev/null +++ b/resources/scripts/api/server/types.d.ts @@ -0,0 +1,20 @@ +export interface ServerBackup { + uuid: string; + isSuccessful: boolean; + name: string; + ignoredFiles: string; + sha256Hash: string; + bytes: number; + createdAt: Date; + completedAt: Date | null; +} + +export interface ServerEggVariable { + name: string; + description: string; + envVariable: string; + defaultValue: string; + serverValue: string; + isEditable: boolean; + rules: string[]; +} diff --git a/resources/scripts/api/server/updateStartupVariable.ts b/resources/scripts/api/server/updateStartupVariable.ts new file mode 100644 index 000000000..88231eccc --- /dev/null +++ b/resources/scripts/api/server/updateStartupVariable.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; +import { ServerEggVariable } from '@/api/server/types'; +import { rawDataToServerEggVariable } from '@/api/transformers'; + +export default async (uuid: string, key: string, value: string): Promise => { + const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value }); + + return rawDataToServerEggVariable(data); +}; diff --git a/resources/scripts/api/swr/getServerBackups.ts b/resources/scripts/api/swr/getServerBackups.ts new file mode 100644 index 000000000..d7487fde3 --- /dev/null +++ b/resources/scripts/api/swr/getServerBackups.ts @@ -0,0 +1,18 @@ +import useSWR from 'swr'; +import http, { getPaginationSet, PaginatedResult } from '@/api/http'; +import { ServerBackup } from '@/api/server/types'; +import { rawDataToServerBackup } from '@/api/transformers'; +import useServer from '@/plugins/useServer'; + +export default (page?: number | string) => { + const { uuid } = useServer(); + + return useSWR>([ 'server:backups', uuid, page ], async () => { + const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } }); + + return ({ + items: (data.data || []).map(rawDataToServerBackup), + pagination: getPaginationSet(data.meta.pagination), + }); + }); +}; diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index bd59a7e84..53ee514ed 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -1,7 +1,7 @@ import { Allocation } from '@/api/server/getServer'; import { FractalResponseData } from '@/api/http'; import { FileObject } from '@/api/server/files/loadDirectory'; -import v4 from 'uuid/v4'; +import { ServerBackup, ServerEggVariable } from '@/api/server/types'; export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({ id: data.attributes.id, @@ -13,7 +13,7 @@ export const rawDataToServerAllocation = (data: FractalResponseData): Allocation }); export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ - uuid: v4(), + key: `${data.attributes.is_file ? 'file' : 'dir'}_${data.attributes.name}`, name: data.attributes.name, mode: data.attributes.mode, size: Number(data.attributes.size), @@ -23,4 +23,41 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ mimetype: data.attributes.mimetype, createdAt: new Date(data.attributes.created_at), modifiedAt: new Date(data.attributes.modified_at), + + isArchiveType: function () { + return this.isFile && [ + 'application/vnd.rar', // .rar + 'application/x-rar-compressed', // .rar (2) + 'application/x-tar', // .tar + 'application/x-br', // .tar.br + 'application/x-bzip2', // .tar.bz2, .bz2 + 'application/gzip', // .tar.gz, .gz + 'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct) + 'application/x-sz', // .tar.sz, .sz (not sure if this mime type is correct) + 'application/x-xz', // .tar.xz, .xz + 'application/zstd', // .tar.zst, .zst + 'application/zip', // .zip + ].indexOf(this.mimetype) >= 0; + }, +}); + +export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({ + uuid: attributes.uuid, + isSuccessful: attributes.is_successful, + name: attributes.name, + ignoredFiles: attributes.ignored_files, + sha256Hash: attributes.sha256_hash, + bytes: attributes.bytes, + createdAt: new Date(attributes.created_at), + completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null, +}); + +export const rawDataToServerEggVariable = ({ attributes }: FractalResponseData): ServerEggVariable => ({ + name: attributes.name, + description: attributes.description, + envVariable: attributes.env_variable, + defaultValue: attributes.default_value, + serverValue: attributes.server_value, + isEditable: attributes.is_editable, + rules: attributes.rules.split('|'), }); diff --git a/resources/scripts/assets/css/GlobalStylesheet.ts b/resources/scripts/assets/css/GlobalStylesheet.ts index 5cc44cea6..a38dff74e 100644 --- a/resources/scripts/assets/css/GlobalStylesheet.ts +++ b/resources/scripts/assets/css/GlobalStylesheet.ts @@ -6,19 +6,19 @@ export default createGlobalStyle` ${tw`font-sans bg-neutral-800 text-neutral-200`}; letter-spacing: 0.015em; } - + h1, h2, h3, h4, h5, h6 { ${tw`font-medium tracking-normal font-header`}; } - + p { ${tw`text-neutral-200 leading-snug font-sans`}; } - + form { ${tw`m-0`}; } - + textarea, select, input, button, button:focus, button:focus-visible { ${tw`outline-none`}; } @@ -32,4 +32,41 @@ export default createGlobalStyle` input[type=number] { -moz-appearance: textfield !important; } + + /* Scroll Bar Style */ + ::-webkit-scrollbar { + background: none; + width: 16px; + height: 16px; + } + + ::-webkit-scrollbar-thumb { + border: solid 0 rgb(0 0 0 / 0%); + border-right-width: 4px; + border-left-width: 4px; + -webkit-border-radius: 9px 4px; + -webkit-box-shadow: inset 0 0 0 1px hsl(211, 10%, 53%), inset 0 0 0 4px hsl(209deg 18% 30%); + } + + ::-webkit-scrollbar-track-piece { + margin: 4px 0; + } + + ::-webkit-scrollbar-thumb:horizontal { + border-right-width: 0; + border-left-width: 0; + border-top-width: 4px; + border-bottom-width: 4px; + -webkit-border-radius: 4px 9px; + } + + ::-webkit-scrollbar-thumb:hover { + -webkit-box-shadow: + inset 0 0 0 1px hsl(212, 92%, 43%), + inset 0 0 0 4px hsl(212, 92%, 43%); + } + + ::-webkit-scrollbar-corner { + background: transparent; + } `; diff --git a/resources/scripts/components/App.tsx b/resources/scripts/components/App.tsx index dac7fd102..350387fac 100644 --- a/resources/scripts/components/App.tsx +++ b/resources/scripts/components/App.tsx @@ -1,4 +1,5 @@ -import * as React from 'react'; +import React, { useEffect } from 'react'; +import ReactGA from 'react-ga'; import { hot } from 'react-hot-loader/root'; import { BrowserRouter, Route, Switch } from 'react-router-dom'; import { StoreProvider } from 'easy-peasy'; @@ -48,6 +49,11 @@ const App = () => { store.getActions().settings.setSettings(SiteConfiguration!); } + useEffect(() => { + ReactGA.initialize(SiteConfiguration!.analytics); + ReactGA.pageview(location.pathname); + }, []); + return ( <> diff --git a/resources/scripts/components/auth/ForgotPasswordContainer.tsx b/resources/scripts/components/auth/ForgotPasswordContainer.tsx index dbd4ed469..82bd5e5ff 100644 --- a/resources/scripts/components/auth/ForgotPasswordContainer.tsx +++ b/resources/scripts/components/auth/ForgotPasswordContainer.tsx @@ -1,27 +1,40 @@ import * as React from 'react'; +import { useRef, useState } from 'react'; import { Link } from 'react-router-dom'; import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail'; import { httpErrorToHuman } from '@/api/http'; import LoginFormContainer from '@/components/auth/LoginFormContainer'; -import { Actions, useStoreActions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; +import { useStoreState } from 'easy-peasy'; import Field from '@/components/elements/Field'; import { Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; +import Reaptcha from 'reaptcha'; +import useFlash from '@/plugins/useFlash'; interface Values { email: string; } export default () => { - const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); + const ref = useRef(null); + const [ token, setToken ] = useState(''); + + const { clearFlashes, addFlash } = useFlash(); + const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha); const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers) => { - setSubmitting(true); clearFlashes(); - requestPasswordResetEmail(email) + + // If there is no token in the state yet, request the token and then abort this submit request + // since it will be re-submitted when the recaptcha data is returned by the component. + if (recaptchaEnabled && !token) { + ref.current!.execute().catch(error => console.error(error)); + return; + } + + requestPasswordResetEmail(email, token) .then(response => { resetForm(); addFlash({ type: 'success', title: 'Success', message: response }); @@ -42,7 +55,7 @@ export default () => { .required('A valid email address must be provided to continue.'), })} > - {({ isSubmitting }) => ( + {({ isSubmitting, setSubmitting, submitForm }) => ( { Send Email + {recaptchaEnabled && + { + setToken(response); + submitForm(); + }} + onExpire={() => { + setSubmitting(false); + setToken(''); + }} + /> + }
; - addFlash: ActionCreator; +interface Values { + username: string; + password: string; } -const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handleSubmit }: OwnProps & FormikProps) => { - const ref = useRef(null); - const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha); +const LoginContainer = ({ history }: RouteComponentProps) => { + const ref = useRef(null); + const [ token, setToken ] = useState(''); - const submit = (e: React.FormEvent) => { - e.preventDefault(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha); - if (ref.current && !values.recaptchaData) { - return ref.current.execute(); + const onSubmit = (values: Values, { setSubmitting }: FormikHelpers) => { + clearFlashes(); + + // If there is no token in the state yet, request the token and then abort this submit request + // since it will be re-submitted when the recaptcha data is returned by the component. + if (recaptchaEnabled && !token) { + ref.current!.execute().catch(error => console.error(error)); + return; } - handleSubmit(e); - }; - - return ( - - {ref.current && ref.current.render()} - - -
- -
-
- -
- {recaptchaEnabled && - { - ref.current && ref.current.reset(); - setFieldValue('recaptchaData', token); - submitForm(); - }} - onExpired={() => setFieldValue('recaptchaData', null)} - /> - } -
- - Forgot password? - -
-
-
- ); -}; - -const EnhancedForm = withFormik({ - displayName: 'LoginContainerForm', - - mapPropsToValues: () => ({ - username: '', - password: '', - recaptchaData: null, - }), - - validationSchema: () => object().shape({ - username: string().required('A username or email must be provided.'), - password: string().required('Please enter your account password.'), - }), - - handleSubmit: (values, { props, setFieldValue, setSubmitting }) => { - props.clearFlashes(); - login(values) + login({ ...values, recaptchaData: token }) .then(response => { if (response.complete) { // @ts-ignore @@ -107,26 +41,75 @@ const EnhancedForm = withFormik({ return; } - props.history.replace('/auth/login/checkpoint', { token: response.confirmationToken }); + history.replace('/auth/login/checkpoint', { token: response.confirmationToken }); }) .catch(error => { console.error(error); setSubmitting(false); - setFieldValue('recaptchaData', null); - props.addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) }); + clearAndAddHttpError({ error }); }); - }, -})(LoginContainer); - -export default (props: RouteComponentProps) => { - const { clearFlashes, addFlash } = useStoreActions((actions: Actions) => actions.flashes); + }; return ( - + + {({ isSubmitting, setSubmitting, submitForm }) => ( + + +
+ +
+
+ +
+ {recaptchaEnabled && + { + setToken(response); + submitForm(); + }} + onExpire={() => { + setSubmitting(false); + setToken(''); + }} + /> + } +
+ + Forgot password? + +
+
+ )} +
); }; + +export default LoginContainer; diff --git a/resources/scripts/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx index f3ceb66f0..304fe5630 100644 --- a/resources/scripts/components/dashboard/AccountApiContainer.tsx +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import ContentBox from '@/components/elements/ContentBox'; import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm'; import getApiKeys, { ApiKey } from '@/api/account/getApiKeys'; @@ -7,7 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; import deleteApiKey from '@/api/account/deleteApiKey'; -import { Actions, useStoreActions } from 'easy-peasy'; +import { Actions, useStoreActions, useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; import FlashMessageRender from '@/components/FlashMessageRender'; import { httpErrorToHuman } from '@/api/http'; @@ -21,6 +22,7 @@ export default () => { const [ keys, setKeys ] = useState([]); const [ loading, setLoading ] = useState(true); const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); useEffect(() => { clearFlashes('account'); @@ -49,6 +51,9 @@ export default () => { return ( + + {name} | API +
@@ -56,21 +61,19 @@ export default () => { - {deleteIdentifier && { doDeletion(deleteIdentifier); setDeleteIdentifier(''); }} - onDismissed={() => setDeleteIdentifier('')} + onModalDismissed={() => setDeleteIdentifier('')} > Are you sure you wish to delete this API key? All requests using it will immediately be invalidated and will fail. - } { keys.length === 0 ?

diff --git a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx index e98ddd4a6..d495400b4 100644 --- a/resources/scripts/components/dashboard/AccountOverviewContainer.tsx +++ b/resources/scripts/components/dashboard/AccountOverviewContainer.tsx @@ -1,4 +1,6 @@ import * as React from 'react'; +import { Helmet } from 'react-helmet'; +import { ApplicationStore } from '@/state'; import ContentBox from '@/components/elements/ContentBox'; import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm'; import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm'; @@ -7,6 +9,7 @@ import PageContentBlock from '@/components/elements/PageContentBlock'; import tw from 'twin.macro'; import { breakpoint } from '@/theme'; import styled from 'styled-components/macro'; +import { useStoreState } from 'easy-peasy'; const Container = styled.div` ${tw`flex flex-wrap my-10`}; @@ -25,8 +28,12 @@ const Container = styled.div` `; export default () => { + const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); return ( + + {name} | Account Overview + diff --git a/resources/scripts/components/dashboard/ApiKeyModal.tsx b/resources/scripts/components/dashboard/ApiKeyModal.tsx new file mode 100644 index 000000000..e73274309 --- /dev/null +++ b/resources/scripts/components/dashboard/ApiKeyModal.tsx @@ -0,0 +1,38 @@ +import React, { useContext } from 'react'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import asModal from '@/hoc/asModal'; +import ModalContext from '@/context/ModalContext'; + +interface Props { + apiKey: string; +} + +const ApiKeyModal = ({ apiKey }: Props) => { + const { dismiss } = useContext(ModalContext); + + return ( + <> +

Your API Key

+

+ The API key you have requested is shown below. Please store this in a safe location, it will not be + shown again. +

+
+                {apiKey}
+            
+
+ +
+ + ); +}; + +ApiKeyModal.displayName = 'ApiKeyModal'; + +export default asModal({ + closeOnEscape: false, + closeOnBackground: false, +})(ApiKeyModal); diff --git a/resources/scripts/components/dashboard/DashboardContainer.tsx b/resources/scripts/components/dashboard/DashboardContainer.tsx index 692ac2ed1..1e1e702ca 100644 --- a/resources/scripts/components/dashboard/DashboardContainer.tsx +++ b/resources/scripts/components/dashboard/DashboardContainer.tsx @@ -1,5 +1,7 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import { Server } from '@/api/server/getServer'; +import { ApplicationStore } from '@/state'; import getServers from '@/api/getServers'; import ServerRow from '@/components/dashboard/ServerRow'; import Spinner from '@/components/elements/Spinner'; @@ -11,15 +13,18 @@ import Switch from '@/components/elements/Switch'; import tw from 'twin.macro'; import useSWR from 'swr'; import { PaginatedResult } from '@/api/http'; +import Pagination from '@/components/elements/Pagination'; export default () => { const { clearFlashes, clearAndAddHttpError } = useFlash(); + const [ page, setPage ] = useState(1); const { rootAdmin } = useStoreState(state => state.user.data!); - const [ showAdmin, setShowAdmin ] = usePersistedState('show_all_servers', false); + const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState('show_all_servers', false); + const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); const { data: servers, error } = useSWR>( - [ '/api/client/servers', showAdmin ], - () => getServers(undefined, showAdmin) + [ '/api/client/servers', showOnlyAdmin, page ], + () => getServers({ onlyAdmin: showOnlyAdmin, page }), ); useEffect(() => { @@ -29,29 +34,44 @@ export default () => { return ( + + {name} | Dashboard + {rootAdmin &&

- {showAdmin ? 'Showing all servers' : 'Showing your servers'} + {showOnlyAdmin ? 'Showing other\'s servers' : 'Showing your servers'}

setShowAdmin(s => !s)} + defaultChecked={showOnlyAdmin} + onChange={() => setShowOnlyAdmin(s => !s)} />
} {!servers ? : - servers.items.length > 0 ? - servers.items.map((server, index) => ( - 0 ? tw`mt-2` : undefined}/> - )) - : -

- There are no servers associated with your account. -

+ + {({ items }) => ( + items.length > 0 ? + items.map((server, index) => ( + 0 ? tw`mt-2` : undefined} + /> + )) + : +

+ {showOnlyAdmin ? + 'There are no other servers to display.' + : + 'There are no servers associated with your account.' + } +

+ )} +
}
); diff --git a/resources/scripts/components/dashboard/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index 39d2278f5..d68744c49 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -1,9 +1,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faServer, faEthernet, faMicrochip, faMemory, faHdd } from '@fortawesome/free-solid-svg-icons'; +import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons'; import { Link } from 'react-router-dom'; import { Server } from '@/api/server/getServer'; -import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage'; import { bytesToHuman, megabytesToHuman } from '@/helpers'; import tw from 'twin.macro'; diff --git a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx index 4e8fae1d5..9022ae6c8 100644 --- a/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx +++ b/resources/scripts/components/dashboard/forms/CreateApiKeyForm.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { Field, Form, Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; -import Modal from '@/components/elements/Modal'; import createApiKey from '@/api/account/createApiKey'; import { Actions, useStoreActions } from 'easy-peasy'; import { ApplicationStore } from '@/state'; @@ -12,12 +11,16 @@ import { ApiKey } from '@/api/account/getApiKeys'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import Input, { Textarea } from '@/components/elements/Input'; +import styled from 'styled-components/macro'; +import ApiKeyModal from '@/components/dashboard/ApiKeyModal'; interface Values { description: string; allowedIps: string; } +const CustomTextarea = styled(Textarea)`${tw`h-32`}`; + export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { const [ apiKey, setApiKey ] = useState(''); const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); @@ -41,35 +44,14 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { return ( <> - 0} - onDismissed={() => setApiKey('')} - closeOnEscape={false} - closeOnBackground={false} - > -

Your API Key

-

- The API key you have requested is shown below. Please store this in a safe location, it will not be - shown again. -

-
-                    {apiKey}
-                
-
- -
-
+ onModalDismissed={() => setApiKey('')} + apiKey={apiKey} + /> void }) => { name={'allowedIps'} description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'} > - +
diff --git a/resources/scripts/components/dashboard/search/SearchModal.tsx b/resources/scripts/components/dashboard/search/SearchModal.tsx index 1461b8013..75eff1bfd 100644 --- a/resources/scripts/components/dashboard/search/SearchModal.tsx +++ b/resources/scripts/components/dashboard/search/SearchModal.tsx @@ -57,7 +57,7 @@ export default ({ ...props }: Props) => { setSubmitting(false); clearFlashes('search'); - getServers(term) + getServers({ query: term }) .then(servers => setServers(servers.items.filter((_, index) => index < 5))) .catch(error => { console.error(error); diff --git a/resources/scripts/components/elements/AceEditor.tsx b/resources/scripts/components/elements/AceEditor.tsx index 0b4ebca95..fbea88b8f 100644 --- a/resources/scripts/components/elements/AceEditor.tsx +++ b/resources/scripts/components/elements/AceEditor.tsx @@ -2,8 +2,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import ace, { Editor } from 'brace'; import styled from 'styled-components/macro'; import tw from 'twin.macro'; -import Select from '@/components/elements/Select'; -// @ts-ignore import modes from '@/modes'; // @ts-ignore @@ -21,42 +19,38 @@ const EditorContainer = styled.div` `; Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`)); +const modelist = ace.acequire('ace/ext/modelist'); export interface Props { style?: React.CSSProperties; initialContent?: string; - initialModePath?: string; + mode: string; + filename?: string; + onModeChanged: (mode: string) => void; fetchContent: (callback: () => Promise) => void; onContentSaved: (content: string) => void; } -export default ({ style, initialContent, initialModePath, fetchContent, onContentSaved }: Props) => { - const [ mode, setMode ] = useState('ace/mode/plain_text'); - +export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => { const [ editor, setEditor ] = useState(); const ref = useCallback(node => { - if (node) { - setEditor(ace.edit('editor')); - } + if (node) setEditor(ace.edit('editor')); }, []); useEffect(() => { - editor && editor.session.setMode(mode); + if (modelist && filename) { + onModeChanged(modelist.getModeForPath(filename).mode.replace(/^ace\/mode\//, '')); + } + }, [ filename ]); + + useEffect(() => { + editor && editor.session.setMode(`ace/mode/${mode}`); }, [ editor, mode ]); useEffect(() => { editor && editor.session.setValue(initialContent || ''); }, [ editor, initialContent ]); - useEffect(() => { - if (initialModePath) { - const modelist = ace.acequire('ace/ext/modelist'); - if (modelist) { - setMode(modelist.getModeForPath(initialModePath).mode); - } - } - }, [ initialModePath ]); - useEffect(() => { if (!editor) { fetchContent(() => Promise.reject(new Error('no editor session has been configured'))); @@ -85,20 +79,6 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten return (
-
-
- -
-
); }; diff --git a/resources/scripts/components/elements/ConfirmationModal.tsx b/resources/scripts/components/elements/ConfirmationModal.tsx index 596a23f69..d67c74d1a 100644 --- a/resources/scripts/components/elements/ConfirmationModal.tsx +++ b/resources/scripts/components/elements/ConfirmationModal.tsx @@ -1,7 +1,8 @@ -import React from 'react'; -import Modal, { RequiredModalProps } from '@/components/elements/Modal'; +import React, { useContext } from 'react'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; +import asModal from '@/hoc/asModal'; +import ModalContext from '@/context/ModalContext'; type Props = { title: string; @@ -9,26 +10,29 @@ type Props = { children: string; onConfirmed: () => void; showSpinnerOverlay?: boolean; -} & RequiredModalProps; +}; -const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => ( - onDismissed()} - > -

{title}

-

{children}

-
- - -
-
-); +const ConfirmationModal = ({ title, children, buttonText, onConfirmed }: Props) => { + const { dismiss } = useContext(ModalContext); -export default ConfirmationModal; + return ( + <> +

{title}

+

{children}

+
+ + +
+ + ); +}; + +ConfirmationModal.displayName = 'ConfirmationModal'; + +export default asModal(props => ({ + showSpinnerOverlay: props.showSpinnerOverlay, +}))(ConfirmationModal); diff --git a/resources/scripts/components/elements/Fade.tsx b/resources/scripts/components/elements/Fade.tsx index 62850283e..2b9c3efa8 100644 --- a/resources/scripts/components/elements/Fade.tsx +++ b/resources/scripts/components/elements/Fade.tsx @@ -8,14 +8,14 @@ interface Props extends Omit { } const Container = styled.div<{ timeout: number }>` - .fade-enter, .fade-exit { + .fade-enter, .fade-exit, .fade-appear { will-change: opacity; } - .fade-enter { + .fade-enter, .fade-appear { ${tw`opacity-0`}; - &.fade-enter-active { + &.fade-enter-active, &.fade-appear-active { ${tw`opacity-100 transition-opacity ease-in`}; transition-duration: ${props => props.timeout}ms; } diff --git a/resources/scripts/components/elements/Modal.tsx b/resources/scripts/components/elements/Modal.tsx index f242fbacc..395f7a5a1 100644 --- a/resources/scripts/components/elements/Modal.tsx +++ b/resources/scripts/components/elements/Modal.tsx @@ -13,7 +13,7 @@ export interface RequiredModalProps { top?: boolean; } -interface Props extends RequiredModalProps { +export interface ModalProps extends RequiredModalProps { dismissable?: boolean; closeOnEscape?: boolean; closeOnBackground?: boolean; @@ -40,7 +40,7 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>` } `; -const Modal: React.FC = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => { +const Modal: React.FC = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => { const [ render, setRender ] = useState(visible); const isDismissable = useMemo(() => { @@ -62,7 +62,13 @@ const Modal: React.FC = ({ visible, appear, dismissable, showSpinnerOverl }, [ render ]); return ( - + onDismissed()} + > { if (isDismissable && closeOnBackground) { @@ -80,12 +86,14 @@ const Modal: React.FC = ({ visible, appear, dismissable, showSpinnerOverl
} {showSpinnerOverlay && -
- -
+ +
+ +
+
}
{children} diff --git a/resources/scripts/components/elements/PageContentBlock.tsx b/resources/scripts/components/elements/PageContentBlock.tsx index f32c42ce2..392cffb8d 100644 --- a/resources/scripts/components/elements/PageContentBlock.tsx +++ b/resources/scripts/components/elements/PageContentBlock.tsx @@ -3,31 +3,48 @@ import ContentContainer from '@/components/elements/ContentContainer'; import { CSSTransition } from 'react-transition-group'; import tw from 'twin.macro'; import FlashMessageRender from '@/components/FlashMessageRender'; +import { Helmet } from 'react-helmet'; +import useServer from '@/plugins/useServer'; -const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => ( - - <> - - {showFlashKey && - +interface Props { + title?: string; + className?: string; + showFlashKey?: string; +} + +const PageContentBlock: React.FC = ({ title, showFlashKey, className, children }) => { + const { name } = useServer(); + + return ( + + <> + {!!title && + + {name} | {title} + } - {children} - - -

- © 2015 - 2020  - - Pterodactyl Software - -

-
- -
-); + + {showFlashKey && + + } + {children} + + +

+ © 2015 - 2020  + + Pterodactyl Software + +

+
+ + + ); +}; export default PageContentBlock; diff --git a/resources/scripts/components/elements/Pagination.tsx b/resources/scripts/components/elements/Pagination.tsx new file mode 100644 index 000000000..1ca625fd4 --- /dev/null +++ b/resources/scripts/components/elements/Pagination.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { PaginatedResult } from '@/api/http'; +import tw from 'twin.macro'; +import styled from 'styled-components/macro'; +import Button from '@/components/elements/Button'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faAngleDoubleLeft, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons'; + +interface RenderFuncProps { + items: T[]; + isLastPage: boolean; + isFirstPage: boolean; +} + +interface Props { + data: PaginatedResult; + showGoToLast?: boolean; + showGoToFirst?: boolean; + onPageSelect: (page: number) => void; + children: (props: RenderFuncProps) => React.ReactNode; +} + +const Block = styled(Button)` + ${tw`p-0 w-10 h-10`} + + &:not(:last-of-type) { + ${tw`mr-2`}; + } +`; + +function Pagination ({ data: { items, pagination }, onPageSelect, children }: Props) { + const isFirstPage = pagination.currentPage === 1; + const isLastPage = pagination.currentPage >= pagination.totalPages; + + const pages = []; + + // Start two spaces before the current page. If that puts us before the starting page default + // to the first page as the starting point. + const start = Math.max(pagination.currentPage - 2, 1); + const end = Math.min(pagination.totalPages, pagination.currentPage + 5); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + return ( + <> + {children({ items, isFirstPage, isLastPage })} + {(pages.length > 1) && +
+ {(pages[0] > 1 && !isFirstPage) && + onPageSelect(1)} + > + + + } + { + pages.map(i => ( + onPageSelect(i)} + > + {i} + + )) + } + {(pages[4] < pagination.totalPages && !isLastPage) && + onPageSelect(pagination.totalPages)} + > + + + } +
+ } + + ); +} + +export default Pagination; diff --git a/resources/scripts/components/server/InstallListener.tsx b/resources/scripts/components/server/InstallListener.tsx new file mode 100644 index 000000000..8bc85778a --- /dev/null +++ b/resources/scripts/components/server/InstallListener.tsx @@ -0,0 +1,26 @@ +import useWebsocketEvent from '@/plugins/useWebsocketEvent'; +import { ServerContext } from '@/state/server'; +import useServer from '@/plugins/useServer'; + +const InstallListener = () => { + const server = useServer(); + const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); + const setServer = ServerContext.useStoreActions(actions => actions.server.setServer); + + // Listen for the installation completion event and then fire off a request to fetch the updated + // server information. This allows the server to automatically become available to the user if they + // just sit on the page. + useWebsocketEvent('install completed', () => { + getServer(server.uuid).catch(error => console.error(error)); + }); + + // When we see the install started event immediately update the state to indicate such so that the + // screens automatically update. + useWebsocketEvent('install started', () => { + setServer({ ...server, isInstalling: true }); + }); + + return null; +}; + +export default InstallListener; diff --git a/resources/scripts/components/server/ServerConsole.tsx b/resources/scripts/components/server/ServerConsole.tsx index e90c86035..74ba4d750 100644 --- a/resources/scripts/components/server/ServerConsole.tsx +++ b/resources/scripts/components/server/ServerConsole.tsx @@ -1,4 +1,5 @@ import React, { lazy, useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import { ServerContext } from '@/state/server'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons'; @@ -61,6 +62,9 @@ export default () => { return ( + + {server.name} | Console +

diff --git a/resources/scripts/components/server/StopOrKillButton.tsx b/resources/scripts/components/server/StopOrKillButton.tsx index b9daed85b..fc8490655 100644 --- a/resources/scripts/components/server/StopOrKillButton.tsx +++ b/resources/scripts/components/server/StopOrKillButton.tsx @@ -8,7 +8,7 @@ const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void const status = ServerContext.useStoreState(state => state.status.value); useEffect(() => { - setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state); + setClicked(status === 'stopping'); }, [ status ]); return ( diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index 669f04e84..c7504b164 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -1,50 +1,49 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; +import { Helmet } from 'react-helmet'; import Spinner from '@/components/elements/Spinner'; -import getServerBackups from '@/api/server/backups/getServerBackups'; import useServer from '@/plugins/useServer'; import useFlash from '@/plugins/useFlash'; -import { httpErrorToHuman } from '@/api/http'; import Can from '@/components/elements/Can'; import CreateBackupButton from '@/components/server/backups/CreateBackupButton'; import FlashMessageRender from '@/components/FlashMessageRender'; import BackupRow from '@/components/server/backups/BackupRow'; -import { ServerContext } from '@/state/server'; import PageContentBlock from '@/components/elements/PageContentBlock'; import tw from 'twin.macro'; +import getServerBackups from '@/api/swr/getServerBackups'; export default () => { - const { uuid, featureLimits } = useServer(); - const { addError, clearFlashes } = useFlash(); - const [ loading, setLoading ] = useState(true); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { featureLimits, name: serverName } = useServer(); - const backups = ServerContext.useStoreState(state => state.backups.data); - const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups); + const { data: backups, error, isValidating } = getServerBackups(); useEffect(() => { - clearFlashes('backups'); - getServerBackups(uuid) - .then(data => setBackups(data.items)) - .catch(error => { - console.error(error); - addError({ key: 'backups', message: httpErrorToHuman(error) }); - }) - .then(() => setLoading(false)); - }, []); + if (!error) { + clearFlashes('backups'); - if (backups.length === 0 && loading) { + return; + } + + clearAndAddHttpError({ error, key: 'backups' }); + }, [ error ]); + + if (!backups || (error && isValidating)) { return ; } return ( + + {serverName} | Backups + - {!backups.length ? + {!backups.items.length ?

There are no backups stored for this server.

:
- {backups.map((backup, index) => 0 ? tw`mt-2` : undefined} @@ -52,17 +51,17 @@ export default () => {
} {featureLimits.backups === 0 && -

- Backups cannot be created for this server. -

+

+ Backups cannot be created for this server. +

} - {(featureLimits.backups > 0 && backups.length > 0) && + {(featureLimits.backups > 0 && backups.items.length > 0) &&

- {backups.length} of {featureLimits.backups} backups have been created for this server. + {backups.items.length} of {featureLimits.backups} backups have been created for this server.

} - {featureLimits.backups > 0 && featureLimits.backups !== backups.length && + {featureLimits.backups > 0 && featureLimits.backups !== backups.items.length &&
diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/BackupContextMenu.tsx index 54a45b9d3..9542389cc 100644 --- a/resources/scripts/components/server/backups/BackupContextMenu.tsx +++ b/resources/scripts/components/server/backups/BackupContextMenu.tsx @@ -1,19 +1,18 @@ import React, { useState } from 'react'; -import { ServerBackup } from '@/api/server/backups/getServerBackups'; import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu'; import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl'; -import { httpErrorToHuman } from '@/api/http'; import useFlash from '@/plugins/useFlash'; import ChecksumModal from '@/components/server/backups/ChecksumModal'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import useServer from '@/plugins/useServer'; import deleteBackup from '@/api/server/backups/deleteBackup'; -import { ServerContext } from '@/state/server'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; import Can from '@/components/elements/Can'; import tw from 'twin.macro'; +import getServerBackups from '@/api/swr/getServerBackups'; +import { ServerBackup } from '@/api/server/types'; interface Props { backup: ServerBackup; @@ -24,8 +23,8 @@ export default ({ backup }: Props) => { const [ loading, setLoading ] = useState(false); const [ visible, setVisible ] = useState(false); const [ deleteVisible, setDeleteVisible ] = useState(false); - const { addError, clearFlashes } = useFlash(); - const removeBackup = ServerContext.useStoreActions(actions => actions.backups.removeBackup); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { mutate } = getServerBackups(); const doDownload = () => { setLoading(true); @@ -37,7 +36,7 @@ export default ({ backup }: Props) => { }) .catch(error => { console.error(error); - addError({ key: 'backups', message: httpErrorToHuman(error) }); + clearAndAddHttpError({ key: 'backups', error }); }) .then(() => setLoading(false)); }; @@ -46,10 +45,15 @@ export default ({ backup }: Props) => { setLoading(true); clearFlashes('backups'); deleteBackup(uuid, backup.uuid) - .then(() => removeBackup(backup.uuid)) + .then(() => { + mutate(data => ({ + ...data, + items: data.items.filter(b => b.uuid !== backup.uuid), + }), false); + }) .catch(error => { console.error(error); - addError({ key: 'backups', message: httpErrorToHuman(error) }); + clearAndAddHttpError({ key: 'backups', error }); setLoading(false); setDeleteVisible(false); }); @@ -65,48 +69,55 @@ export default ({ backup }: Props) => { checksum={backup.sha256Hash} /> } - {deleteVisible && doDeletion()} - visible={deleteVisible} - onDismissed={() => setDeleteVisible(false)} + onModalDismissed={() => setDeleteVisible(false)} > Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot be recovered once deleted. - } - ( - - )} - > -
- - doDownload()}> - - Download + {backup.isSuccessful ? + ( + + )} + > +
+ + doDownload()}> + + Download + + + setVisible(true)}> + + Checksum - - setVisible(true)}> - - Checksum - - - setDeleteVisible(true)}> - - Delete - - -
-
+ + setDeleteVisible(true)}> + + Delete + + +
+
+ : + + } ); }; diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx index 2a7b625bc..e6a16a2f1 100644 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { ServerBackup } from '@/api/server/backups/getServerBackups'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; import { format, formatDistanceToNow } from 'date-fns'; @@ -7,10 +6,11 @@ import Spinner from '@/components/elements/Spinner'; import { bytesToHuman } from '@/helpers'; import Can from '@/components/elements/Can'; import useWebsocketEvent from '@/plugins/useWebsocketEvent'; -import { ServerContext } from '@/state/server'; import BackupContextMenu from '@/components/server/backups/BackupContextMenu'; import tw from 'twin.macro'; import GreyRowBox from '@/components/elements/GreyRowBox'; +import getServerBackups from '@/api/swr/getServerBackups'; +import { ServerBackup } from '@/api/server/types'; interface Props { backup: ServerBackup; @@ -18,17 +18,22 @@ interface Props { } export default ({ backup, className }: Props) => { - const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup); + const { mutate } = getServerBackups(); useWebsocketEvent(`backup completed:${backup.uuid}`, data => { try { const parsed = JSON.parse(data); - appendBackup({ - ...backup, - sha256Hash: parsed.sha256_hash || '', - bytes: parsed.file_size || 0, - completedAt: new Date(), - }); + + mutate(data => ({ + ...data, + items: data.items.map(b => b.uuid !== backup.uuid ? b : ({ + ...b, + isSuccessful: parsed.is_successful || true, + sha256Hash: parsed.sha256_hash || '', + bytes: parsed.file_size || 0, + completedAt: new Date(), + })), + }), false); } catch (e) { console.warn(e); } @@ -45,8 +50,13 @@ export default ({ backup, className }: Props) => {

+ {!backup.isSuccessful && + + Failed + + } {backup.name} - {backup.completedAt && + {(backup.completedAt && backup.isSuccessful) && {bytesToHuman(backup.bytes)} }

diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx index 7a04f1041..3fd53403a 100644 --- a/resources/scripts/components/server/backups/CreateBackupButton.tsx +++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx @@ -7,12 +7,11 @@ import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import useFlash from '@/plugins/useFlash'; import useServer from '@/plugins/useServer'; import createServerBackup from '@/api/server/backups/createServerBackup'; -import { httpErrorToHuman } from '@/api/http'; import FlashMessageRender from '@/components/FlashMessageRender'; -import { ServerContext } from '@/state/server'; import Button from '@/components/elements/Button'; import tw from 'twin.macro'; import { Textarea } from '@/components/elements/Input'; +import getServerBackups from '@/api/swr/getServerBackups'; interface Values { name: string; @@ -49,7 +48,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
-
@@ -60,10 +59,9 @@ const ModalContent = ({ ...props }: RequiredModalProps) => { export default () => { const { uuid } = useServer(); - const { addError, clearFlashes } = useFlash(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); const [ visible, setVisible ] = useState(false); - - const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup); + const { mutate } = getServerBackups(); useEffect(() => { clearFlashes('backups:create'); @@ -73,12 +71,11 @@ export default () => { clearFlashes('backups:create'); createServerBackup(uuid, name, ignored) .then(backup => { - appendBackup(backup); + mutate(data => ({ ...data, items: data.items.concat(backup) }), false); setVisible(false); }) .catch(error => { - console.error(error); - addError({ key: 'backups:create', message: httpErrorToHuman(error) }); + clearAndAddHttpError({ key: 'backups:create', error }); setSubmitting(false); }); }; @@ -94,11 +91,7 @@ export default () => { ignored: string(), })} > - setVisible(false)} - /> + setVisible(false)}/> }
diff --git a/resources/scripts/components/server/schedules/ScheduleContainer.tsx b/resources/scripts/components/server/schedules/ScheduleContainer.tsx index 0e4ff6bd2..77e31b590 100644 --- a/resources/scripts/components/server/schedules/ScheduleContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import getServerSchedules from '@/api/server/schedules/getServerSchedules'; import { ServerContext } from '@/state/server'; import Spinner from '@/components/elements/Spinner'; @@ -16,7 +17,7 @@ import GreyRowBox from '@/components/elements/GreyRowBox'; import Button from '@/components/elements/Button'; export default ({ match, history }: RouteComponentProps) => { - const { uuid } = useServer(); + const { uuid, name: serverName } = useServer(); const { clearFlashes, addError } = useFlash(); const [ loading, setLoading ] = useState(true); const [ visible, setVisible ] = useState(false); @@ -37,6 +38,9 @@ export default ({ match, history }: RouteComponentProps) => { return ( + + {serverName} | Schedules + {(!schedules.length && loading) ? diff --git a/resources/scripts/components/server/schedules/ScheduleRow.tsx b/resources/scripts/components/server/schedules/ScheduleRow.tsx index ec23c6f10..514d50ac8 100644 --- a/resources/scripts/components/server/schedules/ScheduleRow.tsx +++ b/resources/scripts/components/server/schedules/ScheduleRow.tsx @@ -14,7 +14,7 @@ export default ({ schedule }: { schedule: Schedule }) => (

{schedule.name}

Last run - at: {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM Do [at] h:mma') : 'never'} + at: {schedule.lastRunAt ? format(schedule.lastRunAt, 'MMM do \'at\' h:mma') : 'never'}

diff --git a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx index fb1136f73..b14a24ea3 100644 --- a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx +++ b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx @@ -69,7 +69,7 @@ export default ({ schedule, task }: Props) => { buttonText={'Delete Task'} onConfirmed={onConfirmDeletion} visible={visible} - onDismissed={() => setVisible(false)} + onModalDismissed={() => setVisible(false)} > Are you sure you want to delete this task? This action cannot be undone. diff --git a/resources/scripts/components/server/schedules/TaskDetailsModal.tsx b/resources/scripts/components/server/schedules/TaskDetailsModal.tsx index 3829d724d..00457a4ec 100644 --- a/resources/scripts/components/server/schedules/TaskDetailsModal.tsx +++ b/resources/scripts/components/server/schedules/TaskDetailsModal.tsx @@ -32,11 +32,16 @@ interface Values { } const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => { - const { values: { action }, setFieldValue, setFieldTouched } = useFormikContext(); + const { values: { action }, initialValues, setFieldValue, setFieldTouched, isSubmitting } = useFormikContext(); useEffect(() => { - setFieldValue('payload', action === 'power' ? 'start' : ''); - setFieldTouched('payload', false); + if (action !== initialValues.action) { + setFieldValue('payload', action === 'power' ? 'start' : ''); + setFieldTouched('payload', false); + } else { + setFieldValue('payload', initialValues.payload); + setFieldTouched('payload', false); + } }, [ action ]); return ( @@ -94,7 +99,7 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => { />
-
diff --git a/resources/scripts/components/server/settings/ReinstallServerBox.tsx b/resources/scripts/components/server/settings/ReinstallServerBox.tsx index eef96c16c..0c1ce8ec7 100644 --- a/resources/scripts/components/server/settings/ReinstallServerBox.tsx +++ b/resources/scripts/components/server/settings/ReinstallServerBox.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { ServerContext } from '@/state/server'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; import ConfirmationModal from '@/components/elements/ConfirmationModal'; @@ -37,15 +37,19 @@ export default () => { }); }; + useEffect(() => { + clearFlashes(); + }, []); + return ( reinstall()} + onConfirmed={reinstall} showSpinnerOverlay={isSubmitting} visible={modalVisible} - onDismissed={() => setModalVisible(false)} + onModalDismissed={() => setModalVisible(false)} > Your server will be stopped and some files may be deleted or modified during this process, are you sure you wish to continue? diff --git a/resources/scripts/components/server/settings/SettingsContainer.tsx b/resources/scripts/components/server/settings/SettingsContainer.tsx index e060fedf8..edaa3503f 100644 --- a/resources/scripts/components/server/settings/SettingsContainer.tsx +++ b/resources/scripts/components/server/settings/SettingsContainer.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Helmet } from 'react-helmet'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; import { ServerContext } from '@/state/server'; import { useStoreState } from 'easy-peasy'; @@ -20,6 +21,9 @@ export default () => { return ( + + {server.name} | Settings +
diff --git a/resources/scripts/components/server/startup/StartupContainer.tsx b/resources/scripts/components/server/startup/StartupContainer.tsx new file mode 100644 index 000000000..481293145 --- /dev/null +++ b/resources/scripts/components/server/startup/StartupContainer.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import PageContentBlock from '@/components/elements/PageContentBlock'; +import TitledGreyBox from '@/components/elements/TitledGreyBox'; +import useServer from '@/plugins/useServer'; +import tw from 'twin.macro'; +import VariableBox from '@/components/server/startup/VariableBox'; + +const StartupContainer = () => { + const { invocation, variables } = useServer(); + + return ( + + +
+

+ {invocation} +

+
+
+
+ {variables.map(variable => )} +
+
+ ); +}; + +export default StartupContainer; diff --git a/resources/scripts/components/server/startup/VariableBox.tsx b/resources/scripts/components/server/startup/VariableBox.tsx new file mode 100644 index 000000000..e9e7b58f0 --- /dev/null +++ b/resources/scripts/components/server/startup/VariableBox.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { ServerEggVariable } from '@/api/server/types'; +import TitledGreyBox from '@/components/elements/TitledGreyBox'; +import { usePermissions } from '@/plugins/usePermissions'; +import InputSpinner from '@/components/elements/InputSpinner'; +import Input from '@/components/elements/Input'; +import tw from 'twin.macro'; +import { debounce } from 'debounce'; +import updateStartupVariable from '@/api/server/updateStartupVariable'; +import useServer from '@/plugins/useServer'; +import { ServerContext } from '@/state/server'; +import useFlash from '@/plugins/useFlash'; +import FlashMessageRender from '@/components/FlashMessageRender'; + +interface Props { + variable: ServerEggVariable; +} + +const VariableBox = ({ variable }: Props) => { + const FLASH_KEY = `server:startup:${variable.envVariable}`; + + const server = useServer(); + const [ loading, setLoading ] = useState(false); + const [ canEdit ] = usePermissions([ 'startup.update' ]); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + + const setServer = ServerContext.useStoreActions(actions => actions.server.setServer); + + const setVariableValue = debounce((value: string) => { + setLoading(true); + clearFlashes(FLASH_KEY); + + updateStartupVariable(server.uuid, variable.envVariable, value) + .then(response => setServer({ + ...server, + variables: server.variables.map(v => v.envVariable === response.envVariable ? response : v), + })) + .catch(error => { + console.error(error); + clearAndAddHttpError({ error, key: FLASH_KEY }); + }) + .then(() => setLoading(false)); + }, 500); + + return ( + + + + setVariableValue(e.currentTarget.value)} + readOnly={!canEdit} + name={variable.envVariable} + defaultValue={variable.serverValue} + placeholder={variable.defaultValue} + /> + +

+ {variable.description} +

+
+ ); +}; + +export default VariableBox; diff --git a/resources/scripts/components/server/users/RemoveSubuserButton.tsx b/resources/scripts/components/server/users/RemoveSubuserButton.tsx index 976c64170..a7fb4ce67 100644 --- a/resources/scripts/components/server/users/RemoveSubuserButton.tsx +++ b/resources/scripts/components/server/users/RemoveSubuserButton.tsx @@ -35,19 +35,17 @@ export default ({ subuser }: { subuser: Subuser }) => { return ( <> - {showConfirmation && doDeletion()} - onDismissed={() => setShowConfirmation(false)} + onModalDismissed={() => setShowConfirmation(false)} > Are you sure you wish to remove this subuser? They will have all access to this server revoked immediately. - }
- + + {subuser.uuid !== uuid && + + } + diff --git a/resources/scripts/components/server/users/UsersContainer.tsx b/resources/scripts/components/server/users/UsersContainer.tsx index 55f60b449..a58d9e904 100644 --- a/resources/scripts/components/server/users/UsersContainer.tsx +++ b/resources/scripts/components/server/users/UsersContainer.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet'; import { ServerContext } from '@/state/server'; import { Actions, useStoreActions, useStoreState } from 'easy-peasy'; import { ApplicationStore } from '@/state'; @@ -17,6 +18,7 @@ export default () => { const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const subusers = ServerContext.useStoreState(state => state.subusers.data); + const servername = ServerContext.useStoreState(state => state.server.data!.name); const setSubusers = ServerContext.useStoreActions(actions => actions.subusers.setSubusers); const permissions = useStoreState((state: ApplicationStore) => state.permissions.data); @@ -49,6 +51,9 @@ export default () => { return ( + + {servername} | Subusers + {!subusers.length ?

diff --git a/resources/scripts/context/ModalContext.ts b/resources/scripts/context/ModalContext.ts new file mode 100644 index 000000000..d2a5d3646 --- /dev/null +++ b/resources/scripts/context/ModalContext.ts @@ -0,0 +1,15 @@ +import React from 'react'; + +export interface ModalContextValues { + dismiss: () => void; + toggleSpinner: (visible?: boolean) => void; +} + +const ModalContext = React.createContext({ + dismiss: () => null, + toggleSpinner: () => null, +}); + +ModalContext.displayName = 'ModalContext'; + +export default ModalContext; diff --git a/resources/scripts/hoc/asModal.tsx b/resources/scripts/hoc/asModal.tsx new file mode 100644 index 000000000..7db437c14 --- /dev/null +++ b/resources/scripts/hoc/asModal.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import Modal, { ModalProps } from '@/components/elements/Modal'; +import ModalContext from '@/context/ModalContext'; +import isEqual from 'react-fast-compare'; + +export interface AsModalProps { + visible: boolean; + onModalDismissed?: () => void; +} + +type ExtendedModalProps = Omit; + +interface State { + render: boolean; + visible: boolean; + modalProps: ExtendedModalProps | undefined; +} + +type ExtendedComponentType = (C: React.ComponentType) => React.ComponentType; + +// eslint-disable-next-line @typescript-eslint/ban-types +function asModal

(modalProps?: ExtendedModalProps | ((props: P) => ExtendedModalProps)): ExtendedComponentType

{ + return function (Component) { + return class extends React.PureComponent

{ + static displayName = `asModal(${Component.displayName})`; + + constructor (props: P & AsModalProps) { + super(props); + + this.state = { + render: props.visible, + visible: props.visible, + modalProps: typeof modalProps === 'function' ? modalProps(this.props) : modalProps, + }; + } + + componentDidUpdate (prevProps: Readonly

) { + const mapped = typeof modalProps === 'function' ? modalProps(this.props) : modalProps; + if (!isEqual(this.state.modalProps, mapped)) { + // noinspection JSPotentiallyInvalidUsageOfThis + this.setState({ modalProps: mapped }); + } + + if (prevProps.visible && !this.props.visible) { + // noinspection JSPotentiallyInvalidUsageOfThis + this.setState({ visible: false }); + } else if (!prevProps.visible && this.props.visible) { + // noinspection JSPotentiallyInvalidUsageOfThis + this.setState({ render: true, visible: true }); + } + } + + dismiss = () => this.setState({ visible: false }); + + toggleSpinner = (value?: boolean) => this.setState(s => ({ + modalProps: { + ...s.modalProps, + showSpinnerOverlay: value || false, + }, + })); + + render () { + return ( + + { + this.state.render ? + this.setState({ render: false }, () => { + if (typeof this.props.onModalDismissed === 'function') { + this.props.onModalDismissed(); + } + })} + {...this.state.modalProps} + > + + + : + null + } + + ); + } + }; + }; +} + +export default asModal; diff --git a/resources/scripts/modes.d.ts b/resources/scripts/modes.d.ts new file mode 100644 index 000000000..92b1ee7fe --- /dev/null +++ b/resources/scripts/modes.d.ts @@ -0,0 +1,3 @@ +declare const modes: Record; + +export default modes; diff --git a/resources/scripts/modes.js b/resources/scripts/modes.js index e5f4bd04f..1e1c7eff1 100644 --- a/resources/scripts/modes.js +++ b/resources/scripts/modes.js @@ -1,3 +1,4 @@ +// This file must be plain Javascript since we're using it within Webpack. module.exports = { assembly_x86: 'Assembly (x86)', c_cpp: 'C++', diff --git a/resources/scripts/plugins/Websocket.ts b/resources/scripts/plugins/Websocket.ts index 0aa13769d..0f8150dcd 100644 --- a/resources/scripts/plugins/Websocket.ts +++ b/resources/scripts/plugins/Websocket.ts @@ -1,13 +1,6 @@ import Sockette from 'sockette'; import { EventEmitter } from 'events'; -export const SOCKET_EVENTS = [ - 'SOCKET_OPEN', - 'SOCKET_RECONNECT', - 'SOCKET_CLOSE', - 'SOCKET_ERROR', -]; - export class Websocket extends EventEmitter { // Timer instance for this socket. private timer: any = null; diff --git a/resources/scripts/plugins/useFileManagerSwr.ts b/resources/scripts/plugins/useFileManagerSwr.ts index 50b69478a..16721e72c 100644 --- a/resources/scripts/plugins/useFileManagerSwr.ts +++ b/resources/scripts/plugins/useFileManagerSwr.ts @@ -2,18 +2,18 @@ import useSWR from 'swr'; import loadDirectory, { FileObject } from '@/api/server/files/loadDirectory'; import { cleanDirectoryPath } from '@/helpers'; import useServer from '@/plugins/useServer'; -import { useLocation } from 'react-router'; +import { ServerContext } from '@/state/server'; export default () => { const { uuid } = useServer(); - const { hash } = useLocation(); + const directory = ServerContext.useStoreState(state => state.files.directory); return useSWR( - `${uuid}:files:${hash}`, - () => loadDirectory(uuid, cleanDirectoryPath(hash)), + `${uuid}:files:${directory}`, + () => loadDirectory(uuid, cleanDirectoryPath(directory)), { - revalidateOnMount: false, + revalidateOnMount: true, refreshInterval: 0, - } + }, ); }; diff --git a/resources/scripts/plugins/useServer.ts b/resources/scripts/plugins/useServer.ts index 40fd93da1..8014ced58 100644 --- a/resources/scripts/plugins/useServer.ts +++ b/resources/scripts/plugins/useServer.ts @@ -1,9 +1,8 @@ -import { DependencyList } from 'react'; import { ServerContext } from '@/state/server'; import { Server } from '@/api/server/getServer'; -const useServer = (dependencies?: DependencyList): Server => { - return ServerContext.useStoreState(state => state.server.data!, [ dependencies ]); +const useServer = (dependencies?: any[] | undefined): Server => { + return ServerContext.useStoreState(state => state.server.data!, dependencies); }; export default useServer; diff --git a/resources/scripts/routers/AuthenticationRouter.tsx b/resources/scripts/routers/AuthenticationRouter.tsx index a7c687eef..57d1422ca 100644 --- a/resources/scripts/routers/AuthenticationRouter.tsx +++ b/resources/scripts/routers/AuthenticationRouter.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import ReactGA from 'react-ga'; import { Route, RouteComponentProps, Switch } from 'react-router-dom'; import LoginContainer from '@/components/auth/LoginContainer'; import ForgotPasswordContainer from '@/components/auth/ForgotPasswordContainer'; @@ -6,17 +7,23 @@ import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer'; import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer'; import NotFound from '@/components/screens/NotFound'; -export default ({ location, history, match }: RouteComponentProps) => ( -

- - - - - - - - history.push('/auth/login')}/> - - -
-); +export default ({ location, history, match }: RouteComponentProps) => { + useEffect(() => { + ReactGA.pageview(location.pathname); + }, [ location.pathname ]); + + return ( +
+ + + + + + + + history.push('/auth/login')} /> + + +
+ ); +}; diff --git a/resources/scripts/routers/DashboardRouter.tsx b/resources/scripts/routers/DashboardRouter.tsx index 79ebbe4a1..7a895a7e4 100644 --- a/resources/scripts/routers/DashboardRouter.tsx +++ b/resources/scripts/routers/DashboardRouter.tsx @@ -1,4 +1,5 @@ -import * as React from 'react'; +import React, { useEffect } from 'react'; +import ReactGA from 'react-ga'; import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom'; import AccountOverviewContainer from '@/components/dashboard/AccountOverviewContainer'; import NavigationBar from '@/components/NavigationBar'; @@ -8,24 +9,30 @@ import NotFound from '@/components/screens/NotFound'; import TransitionRouter from '@/TransitionRouter'; import SubNavigation from '@/components/elements/SubNavigation'; -export default ({ location }: RouteComponentProps) => ( - <> - - {location.pathname.startsWith('/account') && - -
- Settings - API Credentials -
-
- } - - - - - - - - - -); +export default ({ location }: RouteComponentProps) => { + useEffect(() => { + ReactGA.pageview(location.pathname); + }, [ location.pathname ]); + + return ( + <> + + {location.pathname.startsWith('/account') && + +
+ Settings + API Credentials +
+
+ } + + + + + + + + + + ); +}; diff --git a/resources/scripts/routers/ServerRouter.tsx b/resources/scripts/routers/ServerRouter.tsx index 9df270eaa..22e701fa9 100644 --- a/resources/scripts/routers/ServerRouter.tsx +++ b/resources/scripts/routers/ServerRouter.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import ReactGA from 'react-ga'; import { NavLink, Route, RouteComponentProps, Switch } from 'react-router-dom'; import NavigationBar from '@/components/NavigationBar'; import ServerConsole from '@/components/server/ServerConsole'; @@ -25,6 +26,8 @@ import useServer from '@/plugins/useServer'; import ScreenBlock from '@/components/screens/ScreenBlock'; import SubNavigation from '@/components/elements/SubNavigation'; import NetworkContainer from '@/components/server/network/NetworkContainer'; +import InstallListener from '@/components/server/InstallListener'; +import StartupContainer from '@/components/server/startup/StartupContainer'; const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => { const { rootAdmin } = useStoreState(state => state.user.data!); @@ -60,6 +63,10 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) }; }, [ match.params.id ]); + useEffect(() => { + ReactGA.pageview(location.pathname); + }, [ location.pathname ]); + return ( @@ -92,12 +99,17 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) Network + + Startup + Settings
+ + {(installing && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${server.id}`)))) ? ) /> : <> - @@ -130,6 +141,7 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) + diff --git a/resources/scripts/state/flashes.ts b/resources/scripts/state/flashes.ts index 8e4fb258e..fb89a0a8d 100644 --- a/resources/scripts/state/flashes.ts +++ b/resources/scripts/state/flashes.ts @@ -6,7 +6,7 @@ export interface FlashStore { items: FlashMessage[]; addFlash: Action; addError: Action; - clearAndAddHttpError: Action; + clearAndAddHttpError: Action; clearFlashes: Action; } diff --git a/resources/scripts/state/server/backups.ts b/resources/scripts/state/server/backups.ts deleted file mode 100644 index aa24bdf7f..000000000 --- a/resources/scripts/state/server/backups.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ServerBackup } from '@/api/server/backups/getServerBackups'; -import { action, Action } from 'easy-peasy'; - -export interface ServerBackupStore { - data: ServerBackup[]; - setBackups: Action; - appendBackup: Action; - removeBackup: Action; -} - -const backups: ServerBackupStore = { - data: [], - - setBackups: action((state, payload) => { - state.data = payload; - }), - - appendBackup: action((state, payload) => { - if (state.data.find(backup => backup.uuid === payload.uuid)) { - state.data = state.data.map(backup => backup.uuid === payload.uuid ? payload : backup); - } else { - state.data = [ ...state.data, payload ]; - } - }), - - removeBackup: action((state, payload) => { - state.data = [ ...state.data.filter(backup => backup.uuid !== payload) ]; - }), -}; - -export default backups; diff --git a/resources/scripts/state/server/index.ts b/resources/scripts/state/server/index.ts index febf0951a..87023a020 100644 --- a/resources/scripts/state/server/index.ts +++ b/resources/scripts/state/server/index.ts @@ -4,7 +4,6 @@ import socket, { SocketStore } from './socket'; import files, { ServerFileStore } from '@/state/server/files'; import subusers, { ServerSubuserStore } from '@/state/server/subusers'; import { composeWithDevTools } from 'redux-devtools-extension'; -import backups, { ServerBackupStore } from '@/state/server/backups'; import schedules, { ServerScheduleStore } from '@/state/server/schedules'; import databases, { ServerDatabaseStore } from '@/state/server/databases'; @@ -56,7 +55,6 @@ export interface ServerStore { databases: ServerDatabaseStore; files: ServerFileStore; schedules: ServerScheduleStore; - backups: ServerBackupStore; socket: SocketStore; status: ServerStatusStore; clearServerState: Action; @@ -69,7 +67,6 @@ export const ServerContext = createContextStore({ databases, files, subusers, - backups, schedules, clearServerState: action(state => { state.server.data = undefined; @@ -78,7 +75,6 @@ export const ServerContext = createContextStore({ state.subusers.data = []; state.files.directory = '/'; state.files.selectedFiles = []; - state.backups.data = []; state.schedules.data = []; if (state.socket.instance) { diff --git a/resources/scripts/state/settings.ts b/resources/scripts/state/settings.ts index 20dbbdc6e..3eb782d91 100644 --- a/resources/scripts/state/settings.ts +++ b/resources/scripts/state/settings.ts @@ -7,6 +7,7 @@ export interface SiteSettings { enabled: boolean; siteKey: string; }; + analytics: string; } export interface SettingsStore { diff --git a/resources/views/admin/settings/index.blade.php b/resources/views/admin/settings/index.blade.php index 489646dc9..5ccec0dfa 100644 --- a/resources/views/admin/settings/index.blade.php +++ b/resources/views/admin/settings/index.blade.php @@ -31,6 +31,13 @@

This is the name that is used throughout the panel and in emails sent to clients.

+
+ +
+ +

This is your Google Analytics Tracking ID, Ex. UA-123723645-2

+
+
diff --git a/routes/api-client.php b/routes/api-client.php index eb01ed85f..6ca0fb446 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -1,6 +1,7 @@ '/servers/{server}', 'middleware' => [AuthenticateServ Route::post('/copy', 'Servers\FileController@copy'); Route::post('/write', 'Servers\FileController@write'); Route::post('/compress', 'Servers\FileController@compress'); + Route::post('/decompress', 'Servers\FileController@decompress'); Route::post('/delete', 'Servers\FileController@delete'); Route::post('/create-folder', 'Servers\FileController@create'); Route::get('/upload', 'Servers\FileUploadController'); @@ -84,12 +86,12 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::delete('/allocations/{allocation}', 'Servers\NetworkAllocationController@delete'); }); - Route::group(['prefix' => '/users'], function () { + Route::group(['prefix' => '/users', 'middleware' => [SubuserBelongsToServer::class]], function () { Route::get('/', 'Servers\SubuserController@index'); Route::post('/', 'Servers\SubuserController@store'); - Route::get('/{subuser}', 'Servers\SubuserController@view'); - Route::post('/{subuser}', 'Servers\SubuserController@update'); - Route::delete('/{subuser}', 'Servers\SubuserController@delete'); + Route::get('/{user}', 'Servers\SubuserController@view'); + Route::post('/{user}', 'Servers\SubuserController@update'); + Route::delete('/{user}', 'Servers\SubuserController@delete'); }); Route::group(['prefix' => '/backups'], function () { @@ -100,6 +102,10 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ Route::delete('/{backup}', 'Servers\BackupController@delete'); }); + Route::group(['prefix' => '/startup'], function () { + Route::put('/variable', 'Servers\StartupController@update'); + }); + Route::group(['prefix' => '/settings'], function () { Route::post('/rename', 'Servers\SettingsController@rename'); Route::post('/reinstall', 'Servers\SettingsController@reinstall'); diff --git a/routes/auth.php b/routes/auth.php index a6038447b..4bdb72206 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -26,7 +26,7 @@ Route::group(['middleware' => 'guest'], function () { // Password reset routes. This endpoint is hit after going through // the forgot password routes to acquire a token (or after an account // is created). - Route::post('/password/reset', 'ResetPasswordController')->name('auth.reset-password')->middleware('recaptcha'); + Route::post('/password/reset', 'ResetPasswordController')->name('auth.reset-password'); // Catch any other combinations of routes and pass them off to the Vuejs component. Route::fallback('LoginController@index'); diff --git a/tests/Integration/Api/Client/ClientControllerTest.php b/tests/Integration/Api/Client/ClientControllerTest.php index 82aeb564d..b894b14b0 100644 --- a/tests/Integration/Api/Client/ClientControllerTest.php +++ b/tests/Integration/Api/Client/ClientControllerTest.php @@ -38,33 +38,6 @@ class ClientControllerTest extends ClientApiIntegrationTestCase $response->assertJsonPath('meta.pagination.per_page', 50); } - /** - * Tests that all of the servers on the system are returned when making the request as an - * administrator and including the ?filter=all parameter in the URL. - */ - public function testFilterIncludeAllServersWhenAdministrator() - { - /** @var \Pterodactyl\Models\User[] $users */ - $users = factory(User::class)->times(3)->create(); - $users[0]->root_admin = true; - - $servers = [ - $this->createServerModel(['user_id' => $users[0]->id]), - $this->createServerModel(['user_id' => $users[1]->id]), - $this->createServerModel(['user_id' => $users[2]->id]), - ]; - - $response = $this->actingAs($users[0])->getJson('/api/client?type=all'); - - $response->assertOk(); - $response->assertJsonCount(3, 'data'); - - for ($i = 0; $i < 3; $i++) { - $response->assertJsonPath("data.{$i}.attributes.server_owner", $i === 0); - $response->assertJsonPath("data.{$i}.attributes.identifier", $servers[$i]->uuidShort); - } - } - /** * Test that servers where the user is a subuser are returned by default in the API call. */ @@ -143,4 +116,59 @@ class ClientControllerTest extends ClientApiIntegrationTestCase ], ]); } + + /** + * Test that only servers a user can access because they are an administrator are returned. This + * will always exclude any servers they can see because they're the owner or a subuser of the server. + */ + public function testOnlyAdminLevelServersAreReturned() + { + /** @var \Pterodactyl\Models\User[] $users */ + $users = factory(User::class)->times(4)->create(); + $users[0]->update(['root_admin' => true]); + + $servers = [ + $this->createServerModel(['user_id' => $users[0]->id]), + $this->createServerModel(['user_id' => $users[1]->id]), + $this->createServerModel(['user_id' => $users[2]->id]), + $this->createServerModel(['user_id' => $users[3]->id]), + ]; + + Subuser::query()->create([ + 'user_id' => $users[0]->id, + 'server_id' => $servers[1]->id, + 'permissions' => [Permission::ACTION_WEBSOCKET_CONNECT], + ]); + + // Only servers 2 & 3 (0 indexed) should be returned by the API at this point. The user making + // the request is the owner of server 0, and a subuser of server 1 so they should be exluded. + $response = $this->actingAs($users[0])->getJson('/api/client?type=admin'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + + $response->assertJsonPath('data.0.attributes.server_owner', false); + $response->assertJsonPath('data.0.attributes.identifier', $servers[2]->uuidShort); + $response->assertJsonPath('data.1.attributes.server_owner', false); + $response->assertJsonPath('data.1.attributes.identifier', $servers[3]->uuidShort); + } + + /** + * Test that no servers get returned if the user requests all admin level servers by using + * ?type=admin in the request. + */ + public function testNoServersAreReturnedIfAdminFilterIsPassedByRegularUser() + { + /** @var \Pterodactyl\Models\User[] $users */ + $users = factory(User::class)->times(3)->create(); + + $this->createServerModel(['user_id' => $users[0]->id]); + $this->createServerModel(['user_id' => $users[1]->id]); + $this->createServerModel(['user_id' => $users[2]->id]); + + $response = $this->actingAs($users[0])->getJson('/api/client?type=admin'); + + $response->assertOk(); + $response->assertJsonCount(0, 'data'); + } } diff --git a/tests/Integration/Api/Client/Server/CommandControllerTest.php b/tests/Integration/Api/Client/Server/CommandControllerTest.php index 3d7cd090f..de3dacc85 100644 --- a/tests/Integration/Api/Client/Server/CommandControllerTest.php +++ b/tests/Integration/Api/Client/Server/CommandControllerTest.php @@ -9,6 +9,7 @@ use Pterodactyl\Models\Permission; use GuzzleHttp\Exception\BadResponseException; use GuzzleHttp\Psr7\Response as GuzzleResponse; use Pterodactyl\Repositories\Wings\DaemonCommandRepository; +use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException; use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase; class CommandControllerTest extends ClientApiIntegrationTestCase @@ -86,7 +87,9 @@ class CommandControllerTest extends ClientApiIntegrationTestCase [$user, $server] = $this->generateTestAccount(); $this->repository->expects('setServer->send')->andThrows( - new BadResponseException('', new Request('GET', 'test'), new GuzzleResponse(Response::HTTP_BAD_GATEWAY)) + new DaemonConnectionException( + new BadResponseException('', new Request('GET', 'test'), new GuzzleResponse(Response::HTTP_BAD_GATEWAY)) + ) ); $response = $this->actingAs($user)->postJson("/api/client/servers/{$server->uuid}/command", [ diff --git a/tests/Unit/Services/Servers/StartupCommandViewServiceTest.php b/tests/Unit/Services/Servers/StartupCommandViewServiceTest.php index 5bb436122..a16eb3865 100644 --- a/tests/Unit/Services/Servers/StartupCommandViewServiceTest.php +++ b/tests/Unit/Services/Servers/StartupCommandViewServiceTest.php @@ -9,7 +9,7 @@ use Pterodactyl\Models\Server; use Illuminate\Support\Collection; use Pterodactyl\Models\Allocation; use Pterodactyl\Models\EggVariable; -use Pterodactyl\Services\Servers\StartupCommandViewService; +use Pterodactyl\Services\Servers\StartupCommandService; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; class StartupCommandViewServiceTest extends TestCase @@ -76,10 +76,10 @@ class StartupCommandViewServiceTest extends TestCase /** * Return an instance of the service with mocked dependencies. * - * @return \Pterodactyl\Services\Servers\StartupCommandViewService + * @return \Pterodactyl\Services\Servers\StartupCommandService */ - private function getService(): StartupCommandViewService + private function getService(): StartupCommandService { - return new StartupCommandViewService($this->repository); + return new StartupCommandService($this->repository); } } diff --git a/yarn.lock b/yarn.lock index 62c1da6fb..1953de331 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1013,9 +1013,10 @@ dependencies: "@types/react" "*" -"@types/react-google-recaptcha@^1.1.1": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@types/react-google-recaptcha/-/react-google-recaptcha-1.1.1.tgz#7dd2a4dd15d38d8059a2753cd4a7e3485c9bb3ea" +"@types/react-helmet@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@types/react-helmet/-/react-helmet-6.0.0.tgz#5b74e44a12662ffb12d1c97ee702cf4e220958cf" + integrity sha512-NBMPAxgjpaMooXa51cU1BTgrX6T+hQbMiLm77JhBbfOzPQea3RB5rNpPOD5xGWHIVpGXHd59cltEzIq0qglGcQ== dependencies: "@types/react" "*" @@ -5564,11 +5565,16 @@ react-fast-compare@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9" -react-fast-compare@^3.2.0: +react-fast-compare@^3.1.1, react-fast-compare@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== +react-ga@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/react-ga/-/react-ga-3.1.2.tgz#e13f211c51a2e5c401ea69cf094b9501fe3c51ce" + integrity sha512-OJrMqaHEHbodm+XsnjA6ISBEHTwvpFrxco65mctzl/v3CASMSLSyUkFqz9yYrPDKGBUfNQzKCjuMJwctjlWBbw== + react-google-recaptcha@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/react-google-recaptcha/-/react-google-recaptcha-2.0.1.tgz#3276b29659493f7ca2a5b7739f6c239293cdf1d8" @@ -5576,6 +5582,16 @@ react-google-recaptcha@^2.0.1: prop-types "^15.5.0" react-async-script "^1.1.1" +react-helmet@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/react-helmet/-/react-helmet-6.1.0.tgz#a750d5165cb13cf213e44747502652e794468726" + integrity sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw== + dependencies: + object-assign "^4.1.1" + prop-types "^15.7.2" + react-fast-compare "^3.1.1" + react-side-effect "^2.1.0" + react-hot-loader@^4.12.21: version "4.12.21" resolved "https://registry.yarnpkg.com/react-hot-loader/-/react-hot-loader-4.12.21.tgz#332e830801fb33024b5a147d6b13417f491eb975" @@ -5643,6 +5659,11 @@ react-router@5.1.2: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-side-effect@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-side-effect/-/react-side-effect-2.1.0.tgz#1ce4a8b4445168c487ed24dab886421f74d380d3" + integrity sha512-IgmcegOSi5SNX+2Snh1vqmF0Vg/CbkycU9XZbOHJlZ6kMzTmi3yc254oB1WCkgA7OQtIAoLmcSFuHTc/tlcqXg== + react-transition-group@^4.4.1: version "4.4.1" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" @@ -5714,6 +5735,11 @@ readdirp@~3.4.0: dependencies: picomatch "^2.2.1" +reaptcha@^1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/reaptcha/-/reaptcha-1.7.2.tgz#d829f54270c241f46501e92a5a7badeb1fcf372d" + integrity sha512-/RXiPeMd+fPUGByv+kAaQlCXCsSflZ9bKX5Fcwv9IYGS1oyT2nntL/8zn9IaiUFHL66T1jBtOABcb92g2+3w8w== + reduce-css-calc@^2.1.6: version "2.1.7" resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-2.1.7.tgz#1ace2e02c286d78abcd01fd92bfe8097ab0602c2"