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 d788fb7c0..3fab319b8 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,12 @@ in becoming a sponsor?](https://github.com/sponsors/DaneEveritt) > 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! + +#### [XCORE-SERVER.de](https://xcore-server.de) +> XCORE-SERVER.de offers High-End Servers for hosting and gaming since 2012. Fast, excellent and well known for eSports Gaming. ## 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). 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/Contracts/Repository/ServerRepositoryInterface.php b/app/Contracts/Repository/ServerRepositoryInterface.php index 13a931764..f56582858 100644 --- a/app/Contracts/Repository/ServerRepositoryInterface.php +++ b/app/Contracts/Repository/ServerRepositoryInterface.php @@ -65,18 +65,6 @@ interface ServerRepositoryInterface extends RepositoryInterface, SearchableInter */ public function getPrimaryAllocation(Server $server, bool $refresh = false): Server; - /** - * Return all of the server variables possible and default to the variable - * default if there is no value defined for the specific server requested. - * - * @param int $id - * @param bool $returnAsObject - * @return array|object - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function getVariablesWithValues(int $id, bool $returnAsObject = false); - /** * Return enough data to be used for the creation of a server via the daemon. * diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 50ac1a960..f63e5a37f 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -178,16 +178,21 @@ class Handler extends ExceptionHandler return [str_replace('.', '_', $field) => $cleaned]; })->toArray(); - $errors = collect($exception->errors())->map(function ($errors, $field) use ($codes) { + $errors = collect($exception->errors())->map(function ($errors, $field) use ($codes, $exception) { $response = []; foreach ($errors as $key => $error) { - $response[] = [ - 'code' => str_replace(self::PTERODACTYL_RULE_STRING, 'p_', array_get( + $meta = [ + 'source_field' => $field, + 'rule' => str_replace(self::PTERODACTYL_RULE_STRING, 'p_', array_get( $codes, str_replace('.', '_', $field) . '.' . $key )), - 'detail' => $error, - 'source' => ['field' => $field], ]; + + $converted = self::convertToArray($exception)['errors'][0]; + $converted['detail'] = $error; + $converted['meta'] = is_array($converted['meta']) ? array_merge($converted['meta'], $meta) : $meta; + + $response[] = $converted; } return $response; @@ -209,10 +214,19 @@ class Handler extends ExceptionHandler { $error = [ 'code' => class_basename($exception), - 'status' => method_exists($exception, 'getStatusCode') ? strval($exception->getStatusCode()) : '500', + 'status' => method_exists($exception, 'getStatusCode') + ? strval($exception->getStatusCode()) + : ($exception instanceof ValidationException ? '422' : '500'), '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/Http/Controllers/Admin/Servers/ServerViewController.php b/app/Http/Controllers/Admin/Servers/ServerViewController.php index 67531fa5f..5c2440b24 100644 --- a/app/Http/Controllers/Admin/Servers/ServerViewController.php +++ b/app/Http/Controllers/Admin/Servers/ServerViewController.php @@ -9,6 +9,7 @@ use Pterodactyl\Models\Server; use Illuminate\Contracts\View\Factory; use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Http\Controllers\Controller; +use Pterodactyl\Services\Servers\EnvironmentService; use Pterodactyl\Repositories\Eloquent\NestRepository; use Pterodactyl\Repositories\Eloquent\NodeRepository; use Pterodactyl\Repositories\Eloquent\MountRepository; @@ -56,6 +57,11 @@ class ServerViewController extends Controller */ private $nodeRepository; + /** + * @var \Pterodactyl\Services\Servers\EnvironmentService + */ + private $environmentService; + /** * ServerViewController constructor. * @@ -66,6 +72,7 @@ class ServerViewController extends Controller * @param \Pterodactyl\Repositories\Eloquent\NestRepository $nestRepository * @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository * @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository + * @param \Pterodactyl\Services\Servers\EnvironmentService $environmentService */ public function __construct( Factory $view, @@ -74,7 +81,8 @@ class ServerViewController extends Controller MountRepository $mountRepository, NestRepository $nestRepository, NodeRepository $nodeRepository, - ServerRepository $repository + ServerRepository $repository, + EnvironmentService $environmentService ) { $this->view = $view; $this->databaseHostRepository = $databaseHostRepository; @@ -83,6 +91,7 @@ class ServerViewController extends Controller $this->nestRepository = $nestRepository; $this->nodeRepository = $nodeRepository; $this->repository = $repository; + $this->environmentService = $environmentService; } /** @@ -138,12 +147,12 @@ class ServerViewController extends Controller */ public function startup(Request $request, Server $server) { - $parameters = $this->repository->getVariablesWithValues($server->id, true); $nests = $this->nestRepository->getWithEggs(); + $variables = $this->environmentService->handle($server); $this->plainInject([ 'server' => $server, - 'server_variables' => $parameters->data, + 'server_variables' => $variables, 'nests' => $nests->map(function (Nest $item) { return array_merge($item->toArray(), [ 'eggs' => $item->eggs->keyBy('id')->toArray(), diff --git a/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php b/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php index 1d22b7c35..4c8b16a25 100644 --- a/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php @@ -82,7 +82,7 @@ class DownloadBackupController extends ClientApiController throw new BadRequestHttpException; } - return JsonResponse::create([ + return new JsonResponse([ 'object' => 'signed_url', 'attributes' => [ 'url' => $url, diff --git a/app/Http/Controllers/Api/Client/Servers/FileUploadController.php b/app/Http/Controllers/Api/Client/Servers/FileUploadController.php new file mode 100644 index 000000000..e8f5ad080 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/FileUploadController.php @@ -0,0 +1,73 @@ +jwtService = $jwtService; + } + + /** + * Returns a url where files can be uploaded to. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\UploadFileRequest $request + * @param \Pterodactyl\Models\Server $server + * + * @return \Illuminate\Http\JsonResponse + */ + public function __invoke(UploadFileRequest $request, Server $server) + { + return new JsonResponse([ + 'object' => 'signed_url', + 'attributes' => [ + 'url' => $this->getUploadUrl($server, $request->user()), + ], + ]); + } + + /** + * Returns a url where files can be uploaded to. + * + * @param \Pterodactyl\Models\Server $server + * @param \Pterodactyl\Models\User $user + * @return string + */ + protected function getUploadUrl(Server $server, User $user) + { + $token = $this->jwtService + ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) + ->setClaims([ + 'server_uuid' => $server->uuid, + ]) + ->handle($server->node, $user->id . $server->uuid); + + return sprintf( + '%s/upload/file?token=%s', + $server->node->getConnectionAddress(), + $token->__toString() + ); + } +} 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..16975a1be --- /dev/null +++ b/app/Http/Controllers/Api/Client/Servers/StartupController.php @@ -0,0 +1,120 @@ +service = $service; + $this->repository = $repository; + $this->startupCommandService = $startupCommandService; + } + + /** + * Returns the startup information for the server including all of the variables. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Startup\GetStartupRequest $request + * @param \Pterodactyl\Models\Server $server + * @return array + */ + public function index(GetStartupRequest $request, Server $server) + { + $startup = $this->startupCommandService->handle($server, false); + + return $this->fractal->collection($server->variables) + ->transformWith($this->getTransformer(EggVariableTransformer::class)) + ->addMeta([ + 'startup_command' => $startup, + 'raw_startup_command' => $server->startup, + ]) + ->toArray(); + } + + /** + * 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) { + throw new BadRequestHttpException( + "The environment variable you are trying to edit does not exist." + ); + } else if (! $variable->user_editable) { + throw new BadRequestHttpException( + "The environment variable you are trying to edit is read-only." + ); + } + + // 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'); + + $startup = $this->startupCommandService->handle($server, false); + + return $this->fractal->item($variable) + ->transformWith($this->getTransformer(EggVariableTransformer::class)) + ->addMeta([ + 'startup_command' => $startup, + 'raw_startup_command' => $server->startup, + ]) + ->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..3f568882a 100644 --- a/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php +++ b/app/Http/Controllers/Api/Remote/Backups/BackupStatusController.php @@ -3,9 +3,12 @@ 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; +use Pterodactyl\Exceptions\Http\HttpForbiddenException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest; class BackupStatusController extends Controller @@ -32,24 +35,27 @@ class BackupStatusController extends Controller * @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]]); + /** @var \Pterodactyl\Models\Backup $model */ + $model = $this->repository->findFirstWhere([[ 'uuid', '=', $backup ]]); - 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); + if (!is_null($model->completed_at)) { + throw new BadRequestHttpException( + 'Cannot update the status of a backup that is already marked as completed.' + ); } - return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT); + $successful = $request->input('successful') ? true : false; + $model->forceFill([ + 'is_successful' => $successful, + 'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null, + 'bytes' => $successful ? $request->input('size') : 0, + 'completed_at' => CarbonImmutable::now(), + ])->save(); + + 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/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/Servers/Files/UploadFileRequest.php b/app/Http/Requests/Api/Client/Servers/Files/UploadFileRequest.php new file mode 100644 index 000000000..6808a5497 --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Files/UploadFileRequest.php @@ -0,0 +1,17 @@ + '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/Requests/Api/Remote/ReportBackupCompleteRequest.php b/app/Http/Requests/Api/Remote/ReportBackupCompleteRequest.php index edf744dc9..a90a2b2b9 100644 --- a/app/Http/Requests/Api/Remote/ReportBackupCompleteRequest.php +++ b/app/Http/Requests/Api/Remote/ReportBackupCompleteRequest.php @@ -12,8 +12,9 @@ class ReportBackupCompleteRequest extends FormRequest public function rules() { return [ - 'successful' => 'boolean', + 'successful' => 'present|boolean', 'checksum' => 'nullable|string|required_if:successful,true', + 'checksum_type' => 'nullable|string|required_if:successful,true', 'size' => 'nullable|numeric|required_if:successful,true', ]; } diff --git a/app/Models/Backup.php b/app/Models/Backup.php index 14cb4dde9..5a8ab28e3 100644 --- a/app/Models/Backup.php +++ b/app/Models/Backup.php @@ -8,10 +8,11 @@ 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 - * @property string|null $sha256_hash + * @property string|null $checksum * @property int $bytes * @property \Carbon\CarbonImmutable|null $completed_at * @property \Carbon\CarbonImmutable $created_at @@ -44,6 +45,7 @@ class Backup extends Model */ protected $casts = [ 'id' => 'int', + 'is_successful' => 'bool', 'bytes' => 'int', 'ignored_files' => 'array', ]; @@ -59,7 +61,8 @@ class Backup extends Model * @var array */ protected $attributes = [ - 'sha256_hash' => null, + 'is_successful' => true, + 'checksum' => null, 'bytes' => 0, ]; @@ -69,10 +72,11 @@ 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', - 'sha256_hash' => 'nullable|string', + 'checksum' => 'nullable|string', 'bytes' => 'numeric', ]; 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..91ba9621a 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -4,6 +4,7 @@ namespace Pterodactyl\Models; use Illuminate\Notifications\Notifiable; use Pterodactyl\Models\Traits\Searchable; +use Illuminate\Database\Query\JoinClause; use Znck\Eloquent\Traits\BelongsToThrough; /** @@ -38,14 +39,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 +271,17 @@ 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', function (JoinClause $join) { + // Don't forget to join against the server ID as well since the way we're using this relationship + // would actually return all of the variables and their values for _all_ servers using that egg,\ + // rather than only the server for this model. + // + // @see https://github.com/pterodactyl/panel/issues/2250 + $join->on('server_variables.variable_id', 'egg_variables.id') + ->where('server_variables.server_id', $this->id); + }); } /** 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..f749d0d18 100644 --- a/app/Repositories/Eloquent/ServerRepository.php +++ b/app/Repositories/Eloquent/ServerRepository.php @@ -131,41 +131,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt return $server; } - /** - * Return all of the server variables possible and default to the variable - * default if there is no value defined for the specific server requested. - * - * @param int $id - * @param bool $returnAsObject - * @return array|object - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException - */ - public function getVariablesWithValues(int $id, bool $returnAsObject = false) - { - try { - $instance = $this->getBuilder()->with('variables', 'egg.variables')->find($id, $this->getColumns()); - } catch (ModelNotFoundException $exception) { - throw new RecordNotFoundException; - } - - $data = []; - $instance->getRelation('egg')->getRelation('variables')->each(function ($item) use (&$data, $instance) { - $display = $instance->getRelation('variables')->where('variable_id', $item->id)->pluck('variable_value')->first(); - - $data[$item->env_variable] = $display ?? $item->default_value; - }); - - if ($returnAsObject) { - return (object) [ - 'data' => $data, - 'server' => $instance, - ]; - } - - return $data; - } - /** * Return enough data to be used for the creation of a server via the daemon. * 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/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/Servers/EnvironmentService.php b/app/Services/Servers/EnvironmentService.php index 68ae68dc4..8aab214d1 100644 --- a/app/Services/Servers/EnvironmentService.php +++ b/app/Services/Servers/EnvironmentService.php @@ -3,6 +3,7 @@ namespace Pterodactyl\Services\Servers; use Pterodactyl\Models\Server; +use Pterodactyl\Models\EggVariable; use Illuminate\Contracts\Config\Repository as ConfigRepository; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; @@ -63,35 +64,33 @@ class EnvironmentService * * @param \Pterodactyl\Models\Server $server * @return array - * - * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException */ public function handle(Server $server): array { - $variables = $this->repository->getVariablesWithValues($server->id); + $variables = $server->variables->toBase()->mapWithKeys(function (EggVariable $variable) { + return [$variable->env_variable => $variable->server_value ?? $variable->default_value]; + }); // Process environment variables defined in this file. This is done first // in order to allow run-time and config defined variables to take // priority over built-in values. foreach ($this->getEnvironmentMappings() as $key => $object) { - $variables[$key] = object_get($server, $object); + $variables->put($key, object_get($server, $object)); } // Process variables set in the configuration file. foreach ($this->config->get('pterodactyl.environment_variables', []) as $key => $object) { - if (is_callable($object)) { - $variables[$key] = call_user_func($object, $server); - } else { - $variables[$key] = object_get($server, $object); - } + $variables->put( + $key, is_callable($object) ? call_user_func($object, $server) : object_get($server, $object) + ); } // Process dynamically included environment variables. foreach ($this->additional as $key => $closure) { - $variables[$key] = call_user_func($closure, $server); + $variables->put($key, call_user_func($closure, $server)); } - return $variables; + return $variables->toArray(); } /** 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/ServerCreationService.php b/app/Services/Servers/ServerCreationService.php index 09638547b..6d70d23e4 100644 --- a/app/Services/Servers/ServerCreationService.php +++ b/app/Services/Servers/ServerCreationService.php @@ -242,16 +242,16 @@ class ServerCreationService 'io' => Arr::get($data, 'io'), 'cpu' => Arr::get($data, 'cpu'), 'threads' => Arr::get($data, 'threads'), - 'oom_disabled' => Arr::get($data, 'oom_disabled', true), + 'oom_disabled' => Arr::get($data, 'oom_disabled') ?? true, 'allocation_id' => Arr::get($data, 'allocation_id'), 'nest_id' => Arr::get($data, 'nest_id'), 'egg_id' => Arr::get($data, 'egg_id'), 'pack_id' => empty($data['pack_id']) ? null : $data['pack_id'], 'startup' => Arr::get($data, 'startup'), 'image' => Arr::get($data, 'image'), - 'database_limit' => Arr::get($data, 'database_limit', 0), - 'allocation_limit' => Arr::get($data, 'allocation_limit', 0), - 'backup_limit' => Arr::get($data, 'backup_limit', 0), + 'database_limit' => Arr::get($data, 'database_limit') ?? 0, + 'allocation_limit' => Arr::get($data, 'allocation_limit') ?? 0, + 'backup_limit' => Arr::get($data, 'backup_limit') ?? 0, ]); return $model; diff --git a/app/Services/Servers/StartupCommandService.php b/app/Services/Servers/StartupCommandService.php new file mode 100644 index 000000000..bf31763cc --- /dev/null +++ b/app/Services/Servers/StartupCommandService.php @@ -0,0 +1,28 @@ +memory, $server->allocation->ip, $server->allocation->port]; + + foreach ($server->variables as $variable) { + $find[] = '{{' . $variable->env_variable . '}}'; + $replace[] = ($variable->user_viewable && !$hideAllValues) ? ($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..d5acd41fe 100644 --- a/app/Transformers/Api/Client/BackupTransformer.php +++ b/app/Transformers/Api/Client/BackupTransformer.php @@ -22,9 +22,10 @@ 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, + 'checksum' => $backup->checksum, 'bytes' => $backup->bytes, 'created_at' => $backup->created_at->toIso8601String(), 'completed_at' => $backup->completed_at ? $backup->completed_at->toIso8601String() : null, 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..d40bfd3f6 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, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $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/config/pterodactyl.php b/config/pterodactyl.php index b37790cbc..671a64fd3 100644 --- a/config/pterodactyl.php +++ b/config/pterodactyl.php @@ -177,7 +177,7 @@ return [ | This array includes the MIME filetypes that can be edited via the web. */ 'files' => [ - 'max_edit_size' => env('PTERODACTYL_FILES_MAX_EDIT_SIZE', 1024 * 512), + 'max_edit_size' => env('PTERODACTYL_FILES_MAX_EDIT_SIZE', 1024 * 1024 * 4), 'editable' => [ 'application/json', 'application/javascript', 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/database/migrations/2020_08_23_175331_modify_checksums_column_for_backups.php b/database/migrations/2020_08_23_175331_modify_checksums_column_for_backups.php new file mode 100644 index 000000000..8a9013cde --- /dev/null +++ b/database/migrations/2020_08_23_175331_modify_checksums_column_for_backups.php @@ -0,0 +1,41 @@ +renameColumn('sha256_hash', 'checksum'); + }); + + Schema::table('backups', function (Blueprint $table) { + DB::update('UPDATE backups SET checksum = CONCAT(\'sha256:\', checksum)'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('backups', function (Blueprint $table) { + $table->renameColumn('checksum', 'sha256_hash'); + }); + + Schema::table('backups', function (Blueprint $table) { + DB::update('UPDATE backups SET sha256_hash = SUBSTRING(sha256_hash, 8)'); + }); + } +} 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/http.ts b/resources/scripts/api/http.ts index 9ac1b64f8..a642bb16e 100644 --- a/resources/scripts/api/http.ts +++ b/resources/scripts/api/http.ts @@ -66,6 +66,11 @@ export function httpErrorToHuman (error: any): string { if (data.errors && data.errors[0] && data.errors[0].detail) { return data.errors[0].detail; } + + // Errors from wings directory, mostly just for file uploads. + if (data.error && typeof data.error === 'string') { + return data.error; + } } return error.message; 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/getFileUploadUrl.ts b/resources/scripts/api/server/files/getFileUploadUrl.ts new file mode 100644 index 000000000..690e8587c --- /dev/null +++ b/resources/scripts/api/server/files/getFileUploadUrl.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (uuid: string): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/client/servers/${uuid}/files/upload`) + .then(({ data }) => resolve(data.attributes.url)) + .catch(reject); + }); +}; 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..b37fae402 --- /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; + checksum: 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..74498caa8 --- /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<[ ServerEggVariable, string ]> => { + const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value }); + + return [ rawDataToServerEggVariable(data), data.meta.startup_command ]; +}; diff --git a/resources/scripts/api/swr/getServerBackups.ts b/resources/scripts/api/swr/getServerBackups.ts new file mode 100644 index 000000000..0c38cd278 --- /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 { ServerContext } from '@/state/server'; + +export default (page?: number | string) => { + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + + 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/swr/getServerStartup.ts b/resources/scripts/api/swr/getServerStartup.ts new file mode 100644 index 000000000..fff0263f9 --- /dev/null +++ b/resources/scripts/api/swr/getServerStartup.ts @@ -0,0 +1,18 @@ +import useSWR from 'swr'; +import http, { FractalResponseList } from '@/api/http'; +import { rawDataToServerEggVariable } from '@/api/transformers'; +import { ServerEggVariable } from '@/api/server/types'; + +interface Response { + invocation: string; + variables: ServerEggVariable[]; +} + +export default (uuid: string, initialData?: Response) => useSWR([ uuid, '/startup' ], async (): Promise => { + console.log('firing getServerStartup'); + const { data } = await http.get(`/api/client/servers/${uuid}/startup`); + + const variables = ((data as FractalResponseList).data || []).map(rawDataToServerEggVariable); + + return { invocation: data.meta.startup_command, variables }; +}, { initialData, errorRetryCount: 3 }); diff --git a/resources/scripts/api/transformers.ts b/resources/scripts/api/transformers.ts index 6ac0ba1dd..595f2b9c8 100644 --- a/resources/scripts/api/transformers.ts +++ b/resources/scripts/api/transformers.ts @@ -1,6 +1,7 @@ import { Allocation } from '@/api/server/getServer'; import { FractalResponseData } from '@/api/http'; import { FileObject } from '@/api/server/files/loadDirectory'; +import { ServerBackup, ServerEggVariable } from '@/api/server/types'; export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({ id: data.attributes.id, @@ -39,3 +40,24 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ ].indexOf(this.mimetype) >= 0; }, }); + +export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({ + uuid: attributes.uuid, + isSuccessful: attributes.is_successful, + name: attributes.name, + ignoredFiles: attributes.ignored_files, + checksum: attributes.checksum, + 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/components/NavigationBar.tsx b/resources/scripts/components/NavigationBar.tsx index 206e035c3..4f122a82d 100644 --- a/resources/scripts/components/NavigationBar.tsx +++ b/resources/scripts/components/NavigationBar.tsx @@ -43,8 +43,8 @@ const RightNavigation = styled.div` `; export default () => { - const user = useStoreState((state: ApplicationStore) => state.user.data!); const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); + const rootAdmin = useStoreState((state: ApplicationStore) => state.user.data!.rootAdmin); return ( @@ -62,7 +62,7 @@ export default () => { - {user.rootAdmin && + {rootAdmin && diff --git a/resources/scripts/components/dashboard/AccountApiContainer.tsx b/resources/scripts/components/dashboard/AccountApiContainer.tsx index c80a51a20..304fe5630 100644 --- a/resources/scripts/components/dashboard/AccountApiContainer.tsx +++ b/resources/scripts/components/dashboard/AccountApiContainer.tsx @@ -61,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/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/ServerRow.tsx b/resources/scripts/components/dashboard/ServerRow.tsx index d68744c49..a504f1951 100644 --- a/resources/scripts/components/dashboard/ServerRow.tsx +++ b/resources/scripts/components/dashboard/ServerRow.tsx @@ -59,7 +59,17 @@ export default ({ server, className }: { server: Server; className?: string }) =
-

{server.name}

+
+
+

{server.name}

+
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/elements/AceEditor.tsx b/resources/scripts/components/elements/AceEditor.tsx index 0b4ebca95..47fba4edb 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; + onContentSaved: () => 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'))); @@ -76,7 +70,7 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten editor.commands.addCommand({ name: 'Save', bindKey: { win: 'Ctrl-s', mac: 'Command-s' }, - exec: (editor: Editor) => onContentSaved(editor.session.getValue()), + exec: () => onContentSaved(), }); fetchContent(() => Promise.resolve(editor.session.getValue())); @@ -85,20 +79,6 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten return (
-
-
- -
-
); }; diff --git a/resources/scripts/components/elements/Can.tsx b/resources/scripts/components/elements/Can.tsx index 4ea140d3e..dd9d4845f 100644 --- a/resources/scripts/components/elements/Can.tsx +++ b/resources/scripts/components/elements/Can.tsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { memo } from 'react'; import { usePermissions } from '@/plugins/usePermissions'; +import isEqual from 'react-fast-compare'; interface Props { action: string | string[]; @@ -23,4 +24,4 @@ const Can = ({ action, matchAny = false, renderOnError, children }: Props) => { ); }; -export default Can; +export default memo(Can, isEqual); 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..b62918050 100644 --- a/resources/scripts/components/elements/Modal.tsx +++ b/resources/scripts/components/elements/Modal.tsx @@ -13,14 +13,14 @@ export interface RequiredModalProps { top?: boolean; } -interface Props extends RequiredModalProps { +export interface ModalProps extends RequiredModalProps { dismissable?: boolean; closeOnEscape?: boolean; closeOnBackground?: boolean; showSpinnerOverlay?: boolean; } -const ModalMask = styled.div` +export const ModalMask = styled.div` ${tw`fixed z-50 overflow-auto flex w-full inset-0`}; background: rgba(0, 0, 0, 0.70); `; @@ -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..bcbead377 100644 --- a/resources/scripts/components/elements/PageContentBlock.tsx +++ b/resources/scripts/components/elements/PageContentBlock.tsx @@ -3,31 +3,45 @@ 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'; -const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => ( - - <> - - {showFlashKey && - +export interface PageContentBlockProps { + title?: string; + className?: string; + showFlashKey?: string; +} + +const PageContentBlock: React.FC = ({ title, showFlashKey, className, children }) => { + return ( + + <> + {title && + + {title} + } - {children} - - -

- © 2015 - 2020  - - Pterodactyl Software - -

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

+ © 2015 - 2020  + + Pterodactyl Software + +

+
+ + + ); +}; export default PageContentBlock; diff --git a/resources/scripts/components/elements/ServerContentBlock.tsx b/resources/scripts/components/elements/ServerContentBlock.tsx new file mode 100644 index 000000000..0457d34a5 --- /dev/null +++ b/resources/scripts/components/elements/ServerContentBlock.tsx @@ -0,0 +1,19 @@ +import PageContentBlock, { PageContentBlockProps } from '@/components/elements/PageContentBlock'; +import React from 'react'; +import { ServerContext } from '@/state/server'; + +interface Props extends PageContentBlockProps { + title: string; +} + +const ServerContentBlock: React.FC = ({ title, children, ...props }) => { + const name = ServerContext.useStoreState(state => state.server.data!.name); + + return ( + + {children} + + ); +}; + +export default ServerContentBlock; diff --git a/resources/scripts/components/elements/Spinner.tsx b/resources/scripts/components/elements/Spinner.tsx index 127ab6524..d2f7ffb24 100644 --- a/resources/scripts/components/elements/Spinner.tsx +++ b/resources/scripts/components/elements/Spinner.tsx @@ -45,4 +45,10 @@ const Spinner = ({ centered, ...props }: Props) => ( ); Spinner.DisplayName = 'Spinner'; +Spinner.Size = { + SMALL: 'small' as SpinnerSize, + BASE: 'base' as SpinnerSize, + LARGE: 'large' as SpinnerSize, +}; + export default Spinner; diff --git a/resources/scripts/components/screens/ScreenBlock.tsx b/resources/scripts/components/screens/ScreenBlock.tsx index 55e1e70a0..38cad46cb 100644 --- a/resources/scripts/components/screens/ScreenBlock.tsx +++ b/resources/scripts/components/screens/ScreenBlock.tsx @@ -52,8 +52,8 @@ export default ({ title, image, message, onBack, onRetry }: Props) => (
} - -

{title}

+ +

{title}

{message}

diff --git a/resources/scripts/components/server/InstallListener.tsx b/resources/scripts/components/server/InstallListener.tsx index 8bc85778a..782b9da3f 100644 --- a/resources/scripts/components/server/InstallListener.tsx +++ b/resources/scripts/components/server/InstallListener.tsx @@ -1,23 +1,22 @@ import useWebsocketEvent from '@/plugins/useWebsocketEvent'; import { ServerContext } from '@/state/server'; -import useServer from '@/plugins/useServer'; const InstallListener = () => { - const server = useServer(); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); - const setServer = ServerContext.useStoreActions(actions => actions.server.setServer); + const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState); // 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)); + getServer(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 }); + setServerFromState(s => ({ ...s, isInstalling: true })); }); return null; diff --git a/resources/scripts/components/server/ServerConsole.tsx b/resources/scripts/components/server/ServerConsole.tsx index 74ba4d750..253cb05e2 100644 --- a/resources/scripts/components/server/ServerConsole.tsx +++ b/resources/scripts/components/server/ServerConsole.tsx @@ -1,5 +1,4 @@ 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'; @@ -7,11 +6,11 @@ import { bytesToHuman, megabytesToHuman } from '@/helpers'; import SuspenseSpinner from '@/components/elements/SuspenseSpinner'; import TitledGreyBox from '@/components/elements/TitledGreyBox'; import Can from '@/components/elements/Can'; -import PageContentBlock from '@/components/elements/PageContentBlock'; import ContentContainer from '@/components/elements/ContentContainer'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import StopOrKillButton from '@/components/server/StopOrKillButton'; +import ServerContentBlock from '@/components/elements/ServerContentBlock'; export type PowerAction = 'start' | 'stop' | 'restart' | 'kill'; @@ -23,10 +22,13 @@ export default () => { const [ cpu, setCpu ] = useState(0); const [ disk, setDisk ] = useState(0); - const server = ServerContext.useStoreState(state => state.server.data!); + const name = ServerContext.useStoreState(state => state.server.data!.name); + const limits = ServerContext.useStoreState(state => state.server.data!.limits); + const isInstalling = ServerContext.useStoreState(state => state.server.data!.isInstalling); const status = ServerContext.useStoreState(state => state.status.value); - const { connected, instance } = ServerContext.useStoreState(state => state.socket); + const connected = ServerContext.useStoreState(state => state.socket.connected); + const instance = ServerContext.useStoreState(state => state.socket.instance); const statsListener = (data: string) => { let stats: any = {}; @@ -57,16 +59,13 @@ export default () => { }; }, [ instance, connected ]); - const disklimit = server.limits.disk ? megabytesToHuman(server.limits.disk) : 'Unlimited'; - const memorylimit = server.limits.memory ? megabytesToHuman(server.limits.memory) : 'Unlimited'; + const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited'; + const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited'; return ( - - - {server.name} | Console - +
- +

{ / {disklimit}

- {!server.isInstalling ? + {!isInstalling ?
@@ -143,6 +142,6 @@ export default () => {
- + ); }; diff --git a/resources/scripts/components/server/StopOrKillButton.tsx b/resources/scripts/components/server/StopOrKillButton.tsx index fc8490655..ee9d40d2d 100644 --- a/resources/scripts/components/server/StopOrKillButton.tsx +++ b/resources/scripts/components/server/StopOrKillButton.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useState } from 'react'; +import React, { memo, useEffect, useState } from 'react'; import { ServerContext } from '@/state/server'; import { PowerAction } from '@/components/server/ServerConsole'; import Button from '@/components/elements/Button'; +import isEqual from 'react-fast-compare'; const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => { const [ clicked, setClicked ] = useState(false); @@ -27,4 +28,4 @@ const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void ); }; -export default StopOrKillButton; +export default memo(StopOrKillButton, isEqual); diff --git a/resources/scripts/components/server/backups/BackupContainer.tsx b/resources/scripts/components/server/backups/BackupContainer.tsx index bcead7abb..b3bfdc945 100644 --- a/resources/scripts/components/server/backups/BackupContainer.tsx +++ b/resources/scripts/components/server/backups/BackupContainer.tsx @@ -1,77 +1,68 @@ -import React, { useEffect, useState } from 'react'; -import { Helmet } from 'react-helmet'; +import React, { useEffect } from 'react'; 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'; +import { ServerContext } from '@/state/server'; +import ServerContentBlock from '@/components/elements/ServerContentBlock'; export default () => { - const { uuid, featureLimits, name: serverName } = useServer(); - const { addError, clearFlashes } = useFlash(); - const [ loading, setLoading ] = useState(true); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { data: backups, error, isValidating } = getServerBackups(); - const backups = ServerContext.useStoreState(state => state.backups.data); - const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups); + const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups); 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} />)}
} - {featureLimits.backups === 0 && -

- Backups cannot be created for this server. -

+ {backupLimit === 0 && +

+ Backups cannot be created for this server. +

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

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

} - {featureLimits.backups > 0 && featureLimits.backups !== backups.length && + {backupLimit > 0 && backupLimit !== backups.items.length &&
}
-
+ ); }; diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/BackupContextMenu.tsx index 54a45b9d3..7c3ba07ac 100644 --- a/resources/scripts/components/server/backups/BackupContextMenu.tsx +++ b/resources/scripts/components/server/backups/BackupContextMenu.tsx @@ -1,31 +1,30 @@ 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'; +import { ServerContext } from '@/state/server'; interface Props { backup: ServerBackup; } export default ({ backup }: Props) => { - const { uuid } = useServer(); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); 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); }); @@ -62,51 +66,58 @@ export default ({ backup }: Props) => { appear visible={visible} onDismissed={() => setVisible(false)} - checksum={backup.sha256Hash} + checksum={backup.checksum} /> } - {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..d4d68d58e 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, + checksum: parsed.checksum || '', + 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/ChecksumModal.tsx b/resources/scripts/components/server/backups/ChecksumModal.tsx index 91b275904..f37774f59 100644 --- a/resources/scripts/components/server/backups/ChecksumModal.tsx +++ b/resources/scripts/components/server/backups/ChecksumModal.tsx @@ -6,7 +6,7 @@ const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum:

Verify file checksum

- The SHA256 checksum of this file is: + The checksum of this file is:

             {checksum}
diff --git a/resources/scripts/components/server/backups/CreateBackupButton.tsx b/resources/scripts/components/server/backups/CreateBackupButton.tsx
index 3d7834fa9..e505fd251 100644
--- a/resources/scripts/components/server/backups/CreateBackupButton.tsx
+++ b/resources/scripts/components/server/backups/CreateBackupButton.tsx
@@ -5,14 +5,13 @@ import { object, string } from 'yup';
 import Field from '@/components/elements/Field';
 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';
+import { ServerContext } from '@/state/server';
 
 interface Values {
     name: string;
@@ -59,11 +58,10 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
 };
 
 export default () => {
-    const { uuid } = useServer();
-    const { addError, clearFlashes } = useFlash();
+    const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
+    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);
             });
     };
diff --git a/resources/scripts/components/server/databases/CreateDatabaseButton.tsx b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx
index 2b035ce2c..0fff36d25 100644
--- a/resources/scripts/components/server/databases/CreateDatabaseButton.tsx
+++ b/resources/scripts/components/server/databases/CreateDatabaseButton.tsx
@@ -8,7 +8,6 @@ import { ServerContext } from '@/state/server';
 import { httpErrorToHuman } from '@/api/http';
 import FlashMessageRender from '@/components/FlashMessageRender';
 import useFlash from '@/plugins/useFlash';
-import useServer from '@/plugins/useServer';
 import Button from '@/components/elements/Button';
 import tw from 'twin.macro';
 
@@ -29,7 +28,7 @@ const schema = object().shape({
 });
 
 export default () => {
-    const { uuid } = useServer();
+    const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
     const { addError, clearFlashes } = useFlash();
     const [ visible, setVisible ] = useState(false);
 
diff --git a/resources/scripts/components/server/databases/DatabaseRow.tsx b/resources/scripts/components/server/databases/DatabaseRow.tsx
index 4cad11611..e0e3b038a 100644
--- a/resources/scripts/components/server/databases/DatabaseRow.tsx
+++ b/resources/scripts/components/server/databases/DatabaseRow.tsx
@@ -1,6 +1,6 @@
 import React, { useState } from 'react';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faDatabase, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons';
+import { faDatabase, faEye, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
 import Modal from '@/components/elements/Modal';
 import { Form, Formik, FormikHelpers } from 'formik';
 import Field from '@/components/elements/Field';
@@ -12,7 +12,6 @@ import { httpErrorToHuman } from '@/api/http';
 import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
 import Can from '@/components/elements/Can';
 import { ServerDatabase } from '@/api/server/getServerDatabases';
-import useServer from '@/plugins/useServer';
 import useFlash from '@/plugins/useFlash';
 import tw from 'twin.macro';
 import Button from '@/components/elements/Button';
@@ -26,7 +25,7 @@ interface Props {
 }
 
 export default ({ database, className }: Props) => {
-    const { uuid } = useServer();
+    const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
     const { addError, clearFlashes } = useFlash();
     const [ visible, setVisible ] = useState(false);
     const [ connectionVisible, setConnectionVisible ] = useState(false);
diff --git a/resources/scripts/components/server/databases/DatabasesContainer.tsx b/resources/scripts/components/server/databases/DatabasesContainer.tsx
index 922f0a364..8ee4b3129 100644
--- a/resources/scripts/components/server/databases/DatabasesContainer.tsx
+++ b/resources/scripts/components/server/databases/DatabasesContainer.tsx
@@ -1,5 +1,4 @@
 import React, { useEffect, useState } from 'react';
-import { Helmet } from 'react-helmet';
 import getServerDatabases from '@/api/server/getServerDatabases';
 import { ServerContext } from '@/state/server';
 import { httpErrorToHuman } from '@/api/http';
@@ -9,17 +8,19 @@ import Spinner from '@/components/elements/Spinner';
 import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
 import Can from '@/components/elements/Can';
 import useFlash from '@/plugins/useFlash';
-import useServer from '@/plugins/useServer';
-import PageContentBlock from '@/components/elements/PageContentBlock';
 import tw from 'twin.macro';
 import Fade from '@/components/elements/Fade';
+import ServerContentBlock from '@/components/elements/ServerContentBlock';
+import { useDeepMemoize } from '@/plugins/useDeepMemoize';
 
 export default () => {
-    const { uuid, featureLimits, name: serverName } = useServer();
+    const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
+    const databaseLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.databases);
+
     const { addError, clearFlashes } = useFlash();
     const [ loading, setLoading ] = useState(true);
 
-    const databases = ServerContext.useStoreState(state => state.databases.data);
+    const databases = useDeepMemoize(ServerContext.useStoreState(state => state.databases.data));
     const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases);
 
     useEffect(() => {
@@ -36,10 +37,7 @@ export default () => {
     }, []);
 
     return (
-        
-            
-                 {serverName} | Databases 
-            
+        
             
             {(!databases.length && loading) ?
                 
@@ -56,7 +54,7 @@ export default () => {
                             ))
                             :
                             

- {featureLimits.databases > 0 ? + {databaseLimit > 0 ? 'It looks like you have no databases.' : 'Databases cannot be created for this server.' @@ -64,13 +62,13 @@ export default () => {

} - {(featureLimits.databases > 0 && databases.length > 0) && + {(databaseLimit > 0 && databases.length > 0) &&

- {databases.length} of {featureLimits.databases} databases have been allocated to this + {databases.length} of {databaseLimit} databases have been allocated to this server.

} - {featureLimits.databases > 0 && featureLimits.databases !== databases.length && + {databaseLimit > 0 && databaseLimit !== databases.length &&
@@ -79,6 +77,6 @@ export default () => { } -
+ ); }; diff --git a/resources/scripts/components/server/files/FileDropdownMenu.tsx b/resources/scripts/components/server/files/FileDropdownMenu.tsx index e64dd3d84..66b2fbb32 100644 --- a/resources/scripts/components/server/files/FileDropdownMenu.tsx +++ b/resources/scripts/components/server/files/FileDropdownMenu.tsx @@ -19,7 +19,6 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import copyFile from '@/api/server/files/copyFile'; import Can from '@/components/elements/Can'; import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl'; -import useServer from '@/plugins/useServer'; import useFlash from '@/plugins/useFlash'; import tw from 'twin.macro'; import { FileObject } from '@/api/server/files/loadDirectory'; @@ -56,7 +55,7 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => { const [ showSpinner, setShowSpinner ] = useState(false); const [ modal, setModal ] = useState(null); - const { uuid } = useServer(); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const { mutate } = useFileManagerSwr(); const { clearAndAddHttpError, clearFlashes } = useFlash(); const directory = ServerContext.useStoreState(state => state.files.directory); diff --git a/resources/scripts/components/server/files/FileEditContainer.tsx b/resources/scripts/components/server/files/FileEditContainer.tsx index fdde9bb1b..4b3d73a80 100644 --- a/resources/scripts/components/server/files/FileEditContainer.tsx +++ b/resources/scripts/components/server/files/FileEditContainer.tsx @@ -1,8 +1,5 @@ import React, { lazy, useEffect, useState } from 'react'; -import { ServerContext } from '@/state/server'; import getFileContents from '@/api/server/files/getFileContents'; -import { Actions, useStoreActions } from 'easy-peasy'; -import { ApplicationStore } from '@/state'; import { httpErrorToHuman } from '@/api/http'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import saveFileContents from '@/api/server/files/saveFileContents'; @@ -15,6 +12,10 @@ import PageContentBlock from '@/components/elements/PageContentBlock'; import ServerError from '@/components/screens/ServerError'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; +import Select from '@/components/elements/Select'; +import modes from '@/modes'; +import useFlash from '@/plugins/useFlash'; +import { ServerContext } from '@/state/server'; const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor')); @@ -24,28 +25,30 @@ export default () => { const [ loading, setLoading ] = useState(action === 'edit'); const [ content, setContent ] = useState(''); const [ modalVisible, setModalVisible ] = useState(false); + const [ mode, setMode ] = useState('plain_text'); const history = useHistory(); const { hash } = useLocation(); - const { id, uuid } = ServerContext.useStoreState(state => state.server.data!); - const { addError, clearFlashes } = useStoreActions((actions: Actions) => actions.flashes); + const id = ServerContext.useStoreState(state => state.server.data!.id); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const { addError, clearFlashes } = useFlash(); let fetchFileContent: null | (() => Promise) = null; - if (action !== 'new') { - useEffect(() => { - setLoading(true); - setError(''); - getFileContents(uuid, hash.replace(/^#/, '')) - .then(setContent) - .catch(error => { - console.error(error); - setError(httpErrorToHuman(error)); - }) - .then(() => setLoading(false)); - }, [ uuid, hash ]); - } + useEffect(() => { + if (action === 'new') return; + + setLoading(true); + setError(''); + getFileContents(uuid, hash.replace(/^#/, '')) + .then(setContent) + .catch(error => { + console.error(error); + setError(httpErrorToHuman(error)); + }) + .then(() => setLoading(false)); + }, [ action, uuid, hash ]); const save = (name?: string) => { if (!fetchFileContent) { @@ -75,10 +78,7 @@ export default () => { if (error) { return ( - history.goBack()} - /> + history.goBack()}/> ); } @@ -109,15 +109,24 @@ export default () => {
{ fetchFileContent = value; }} - onContentSaved={() => save()} + onContentSaved={save} />
+
+ +
{action === 'edit' ? + + ); +}; diff --git a/resources/scripts/components/server/network/NetworkContainer.tsx b/resources/scripts/components/server/network/NetworkContainer.tsx index a330685b1..14ae10597 100644 --- a/resources/scripts/components/server/network/NetworkContainer.tsx +++ b/resources/scripts/components/server/network/NetworkContainer.tsx @@ -1,14 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { Helmet } from 'react-helmet'; import tw from 'twin.macro'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; import styled from 'styled-components/macro'; -import PageContentBlock from '@/components/elements/PageContentBlock'; import GreyRowBox from '@/components/elements/GreyRowBox'; import Button from '@/components/elements/Button'; import Can from '@/components/elements/Can'; -import useServer from '@/plugins/useServer'; import useSWR from 'swr'; import getServerAllocations from '@/api/server/network/getServerAllocations'; import { Allocation } from '@/api/server/getServer'; @@ -19,12 +16,17 @@ import { Textarea } from '@/components/elements/Input'; import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes'; import { debounce } from 'debounce'; import InputSpinner from '@/components/elements/InputSpinner'; +import ServerContentBlock from '@/components/elements/ServerContentBlock'; +import { ServerContext } from '@/state/server'; +import { useDeepMemoize } from '@/plugins/useDeepMemoize'; const Code = styled.code`${tw`font-mono py-1 px-2 bg-neutral-900 rounded text-sm block`}`; const Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`; const NetworkContainer = () => { - const { uuid, allocations, name: serverName } = useServer(); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const allocations = useDeepMemoize(ServerContext.useStoreState(state => state.server.data!.allocations)); + const { clearFlashes, clearAndAddHttpError } = useFlash(); const [ loading, setLoading ] = useState(false); const { data, error, mutate } = useSWR(uuid, key => getServerAllocations(key), { initialData: allocations }); @@ -61,10 +63,7 @@ const NetworkContainer = () => { }, [ error ]); return ( - - - {serverName} | Network - + {!data ? : @@ -112,7 +111,7 @@ const NetworkContainer = () => { )) } - + ); }; diff --git a/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx index 26d86652d..198060388 100644 --- a/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx +++ b/resources/scripts/components/server/schedules/DeleteScheduleButton.tsx @@ -39,12 +39,12 @@ export default ({ scheduleId, onDeleted }: Props) => { return ( <> setVisible(false)} + showSpinnerOverlay={isLoading} + onModalDismissed={() => setVisible(false)} > Are you sure you want to delete this schedule? All tasks will be removed and any running processes will be terminated. diff --git a/resources/scripts/components/server/schedules/EditScheduleModal.tsx b/resources/scripts/components/server/schedules/EditScheduleModal.tsx index 1cf9eea3a..5d42888f3 100644 --- a/resources/scripts/components/server/schedules/EditScheduleModal.tsx +++ b/resources/scripts/components/server/schedules/EditScheduleModal.tsx @@ -8,7 +8,6 @@ import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedul import { ServerContext } from '@/state/server'; import { httpErrorToHuman } from '@/api/http'; import FlashMessageRender from '@/components/FlashMessageRender'; -import useServer from '@/plugins/useServer'; import useFlash from '@/plugins/useFlash'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; @@ -75,7 +74,7 @@ const EditScheduleModal = ({ schedule, ...props }: Omit { - const { uuid } = useServer(); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const { addError, clearFlashes } = useFlash(); const [ modalVisible, setModalVisible ] = useState(visible); diff --git a/resources/scripts/components/server/schedules/ScheduleContainer.tsx b/resources/scripts/components/server/schedules/ScheduleContainer.tsx index 77e31b590..b9c1f0340 100644 --- a/resources/scripts/components/server/schedules/ScheduleContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleContainer.tsx @@ -1,5 +1,4 @@ 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'; @@ -9,15 +8,14 @@ import ScheduleRow from '@/components/server/schedules/ScheduleRow'; import { httpErrorToHuman } from '@/api/http'; import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; import Can from '@/components/elements/Can'; -import useServer from '@/plugins/useServer'; import useFlash from '@/plugins/useFlash'; -import PageContentBlock from '@/components/elements/PageContentBlock'; import tw from 'twin.macro'; import GreyRowBox from '@/components/elements/GreyRowBox'; import Button from '@/components/elements/Button'; +import ServerContentBlock from '@/components/elements/ServerContentBlock'; export default ({ match, history }: RouteComponentProps) => { - const { uuid, name: serverName } = useServer(); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const { clearFlashes, addError } = useFlash(); const [ loading, setLoading ] = useState(true); const [ visible, setVisible ] = useState(false); @@ -37,10 +35,7 @@ export default ({ match, history }: RouteComponentProps) => { }, []); return ( - - - {serverName} | Schedules - + {(!schedules.length && loading) ? @@ -77,6 +72,6 @@ export default ({ match, history }: RouteComponentProps) => { } - + ); }; diff --git a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx index 6fcaca784..9573d307b 100644 --- a/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx +++ b/resources/scripts/components/server/schedules/ScheduleEditContainer.tsx @@ -11,7 +11,6 @@ import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; import NewTaskButton from '@/components/server/schedules/NewTaskButton'; import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton'; import Can from '@/components/elements/Can'; -import useServer from '@/plugins/useServer'; import useFlash from '@/plugins/useFlash'; import { ServerContext } from '@/state/server'; import PageContentBlock from '@/components/elements/PageContentBlock'; @@ -28,7 +27,9 @@ interface State { } export default ({ match, history, location: { state } }: RouteComponentProps, State>) => { - const { id, uuid } = useServer(); + const id = ServerContext.useStoreState(state => state.server.data!.id); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const { clearFlashes, addError } = useFlash(); const [ isLoading, setIsLoading ] = useState(true); const [ showEditModal, setShowEditModal ] = useState(false); diff --git a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx index fb1136f73..5f101415e 100644 --- a/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx +++ b/resources/scripts/components/server/schedules/ScheduleTaskRow.tsx @@ -7,7 +7,6 @@ import { httpErrorToHuman } from '@/api/http'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal'; import Can from '@/components/elements/Can'; -import useServer from '@/plugins/useServer'; import useFlash from '@/plugins/useFlash'; import { ServerContext } from '@/state/server'; import tw from 'twin.macro'; @@ -32,7 +31,7 @@ const getActionDetails = (action: string): [ string, any ] => { }; export default ({ schedule, task }: Props) => { - const { uuid } = useServer(); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const { clearFlashes, addError } = useFlash(); const [ visible, setVisible ] = useState(false); const [ isLoading, setIsLoading ] = useState(false); @@ -69,7 +68,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 00457a4ec..1ef64c138 100644 --- a/resources/scripts/components/server/schedules/TaskDetailsModal.tsx +++ b/resources/scripts/components/server/schedules/TaskDetailsModal.tsx @@ -9,7 +9,6 @@ import Field from '@/components/elements/Field'; import FlashMessageRender from '@/components/FlashMessageRender'; import { number, object, string } from 'yup'; import useFlash from '@/plugins/useFlash'; -import useServer from '@/plugins/useServer'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import tw from 'twin.macro'; import Label from '@/components/elements/Label'; @@ -108,7 +107,7 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => { }; export default ({ task, schedule, onDismissed }: Props) => { - const { uuid } = useServer(); + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const { clearFlashes, addError } = useFlash(); const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); diff --git a/resources/scripts/components/server/settings/ReinstallServerBox.tsx b/resources/scripts/components/server/settings/ReinstallServerBox.tsx index 1b7b44de7..0c1ce8ec7 100644 --- a/resources/scripts/components/server/settings/ReinstallServerBox.tsx +++ b/resources/scripts/components/server/settings/ReinstallServerBox.tsx @@ -46,10 +46,10 @@ export default () => { 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 edaa3503f..f8934d075 100644 --- a/resources/scripts/components/server/settings/SettingsContainer.tsx +++ b/resources/scripts/components/server/settings/SettingsContainer.tsx @@ -1,29 +1,25 @@ 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'; -import { ApplicationStore } from '@/state'; -import { UserData } from '@/state/user'; import RenameServerBox from '@/components/server/settings/RenameServerBox'; import FlashMessageRender from '@/components/FlashMessageRender'; import Can from '@/components/elements/Can'; import ReinstallServerBox from '@/components/server/settings/ReinstallServerBox'; -import PageContentBlock from '@/components/elements/PageContentBlock'; import tw from 'twin.macro'; import Input from '@/components/elements/Input'; import Label from '@/components/elements/Label'; import { LinkButton } from '@/components/elements/Button'; +import ServerContentBlock from '@/components/elements/ServerContentBlock'; export default () => { - const user = useStoreState(state => state.user.data!); - const server = ServerContext.useStoreState(state => state.server.data!); + const username = useStoreState(state => state.user.data!.username); + const id = ServerContext.useStoreState(state => state.server.data!.id); + const sftpIp = ServerContext.useStoreState(state => state.server.data!.sftpDetails.ip); + const sftpPort = ServerContext.useStoreState(state => state.server.data!.sftpDetails.port); return ( - - - {server.name} | Settings - +
@@ -33,7 +29,7 @@ export default () => {
@@ -41,7 +37,7 @@ export default () => {
@@ -56,7 +52,7 @@ export default () => {
Launch SFTP @@ -76,6 +72,6 @@ export default () => {
- + ); }; diff --git a/resources/scripts/components/server/startup/StartupContainer.tsx b/resources/scripts/components/server/startup/StartupContainer.tsx new file mode 100644 index 000000000..bf3b7651c --- /dev/null +++ b/resources/scripts/components/server/startup/StartupContainer.tsx @@ -0,0 +1,65 @@ +import React, { useEffect } from 'react'; +import TitledGreyBox from '@/components/elements/TitledGreyBox'; +import tw from 'twin.macro'; +import VariableBox from '@/components/server/startup/VariableBox'; +import ServerContentBlock from '@/components/elements/ServerContentBlock'; +import getServerStartup from '@/api/swr/getServerStartup'; +import Spinner from '@/components/elements/Spinner'; +import ServerError from '@/components/screens/ServerError'; +import { httpErrorToHuman } from '@/api/http'; +import { ServerContext } from '@/state/server'; +import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect'; + +const StartupContainer = () => { + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const invocation = ServerContext.useStoreState(state => state.server.data!.invocation); + const variables = ServerContext.useStoreState(state => state.server.data!.variables); + + const { data, error, isValidating, mutate } = getServerStartup(uuid, { invocation, variables }); + + const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState); + + useEffect(() => { + // Since we're passing in initial data this will not trigger on mount automatically. We + // want to always fetch fresh information from the API however when we're loading the startup + // information. + mutate(); + }, []); + + useDeepCompareEffect(() => { + if (!data) return; + + setServerFromState(s => ({ + ...s, + invocation: data.invocation, + variables: data.variables, + })); + }, [ data ]); + + return ( + !data ? + (!error || (error && isValidating)) ? + + : + mutate()} + /> + : + + +
+

+ {data.invocation} +

+
+
+
+ {data.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..f238614ec --- /dev/null +++ b/resources/scripts/components/server/startup/VariableBox.tsx @@ -0,0 +1,77 @@ +import React, { memo, 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 useFlash from '@/plugins/useFlash'; +import FlashMessageRender from '@/components/FlashMessageRender'; +import getServerStartup from '@/api/swr/getServerStartup'; +import isEqual from 'react-fast-compare'; +import { ServerContext } from '@/state/server'; + +interface Props { + variable: ServerEggVariable; +} + +const VariableBox = ({ variable }: Props) => { + const FLASH_KEY = `server:startup:${variable.envVariable}`; + + const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); + const [ loading, setLoading ] = useState(false); + const [ canEdit ] = usePermissions([ 'startup.update' ]); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const { mutate } = getServerStartup(uuid); + + const setVariableValue = debounce((value: string) => { + setLoading(true); + clearFlashes(FLASH_KEY); + + updateStartupVariable(uuid, variable.envVariable, value) + .then(([ response, invocation ]) => mutate(data => ({ + invocation, + variables: data.variables.map(v => v.envVariable === response.envVariable ? response : v), + }), false)) + .catch(error => { + console.error(error); + clearAndAddHttpError({ error, key: FLASH_KEY }); + }) + .then(() => setLoading(false)); + }, 500); + + return ( + + {!variable.isEditable && + Read Only + } + {variable.name} +

+ } + > + + + { + if (canEdit && variable.isEditable) { + setVariableValue(e.currentTarget.value); + } + }} + readOnly={!canEdit || !variable.isEditable} + name={variable.envVariable} + defaultValue={variable.serverValue} + placeholder={variable.defaultValue} + /> + +

+ {variable.description} +

+
+ ); +}; + +export default memo(VariableBox, isEqual); diff --git a/resources/scripts/components/server/users/EditSubuserModal.tsx b/resources/scripts/components/server/users/EditSubuserModal.tsx index 02776932e..f3e0f81c7 100644 --- a/resources/scripts/components/server/users/EditSubuserModal.tsx +++ b/resources/scripts/components/server/users/EditSubuserModal.tsx @@ -15,7 +15,7 @@ import { httpErrorToHuman } from '@/api/http'; import FlashMessageRender from '@/components/FlashMessageRender'; import Can from '@/components/elements/Can'; import { usePermissions } from '@/plugins/usePermissions'; -import { useDeepMemo } from '@/plugins/useDeepMemo'; +import { useDeepCompareMemo } from '@/plugins/useDeepCompareMemo'; import tw from 'twin.macro'; import Button from '@/components/elements/Button'; import Label from '@/components/elements/Label'; @@ -63,7 +63,7 @@ const EditSubuserModal = forwardRef(({ subuser, ...pr const loggedInPermissions = ServerContext.useStoreState(state => state.server.permissions); // The permissions that can be modified by this user. - const editablePermissions = useDeepMemo(() => { + const editablePermissions = useDeepCompareMemo(() => { const cleaned = Object.keys(permissions) .map(key => Object.keys(permissions[key].keys).map(pkey => `${key}.${pkey}`)); 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. - }