Merge pull request #2 from pterodactyl/develop

Update From Upstream
This commit is contained in:
Caleb 2020-08-29 08:57:06 -04:00 committed by GitHub
commit f54d4f965c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
130 changed files with 1906 additions and 815 deletions

View file

@ -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 \ && cat docker/www.conf > /usr/local/etc/php-fpm.d/www.conf \
&& rm /usr/local/etc/php-fpm.d/www.conf.default \ && rm /usr/local/etc/php-fpm.d/www.conf.default \
&& cat docker/supervisord.conf > /etc/supervisord.conf \ && 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 \ && sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \
&& mkdir -p /var/run/php /var/run/nginx && mkdir -p /var/run/php /var/run/nginx

View file

@ -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 > 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. > 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 & 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). 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).

View file

@ -0,0 +1,51 @@
<?php
namespace Pterodactyl\Console\Commands\Maintenance;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
use Illuminate\Console\Command;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
class PruneOrphanedBackupsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'p:maintenance:prune-backups {--since-minutes=30}';
/**
* @var string
*/
protected $description = 'Marks all backups that have not completed in the last "n" minutes as being failed.';
/**
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
*/
public function handle(BackupRepository $repository)
{
$since = $this->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(),
]);
}
}

View file

@ -22,7 +22,16 @@ class Kernel extends ConsoleKernel
*/ */
protected function schedule(Schedule $schedule) 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(); $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(); $schedule->command('p:maintenance:clean-service-backups')->daily();
} }
} }

View file

@ -65,18 +65,6 @@ interface ServerRepositoryInterface extends RepositoryInterface, SearchableInter
*/ */
public function getPrimaryAllocation(Server $server, bool $refresh = false): Server; 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. * Return enough data to be used for the creation of a server via the daemon.
* *

View file

@ -178,16 +178,21 @@ class Handler extends ExceptionHandler
return [str_replace('.', '_', $field) => $cleaned]; return [str_replace('.', '_', $field) => $cleaned];
})->toArray(); })->toArray();
$errors = collect($exception->errors())->map(function ($errors, $field) use ($codes) { $errors = collect($exception->errors())->map(function ($errors, $field) use ($codes, $exception) {
$response = []; $response = [];
foreach ($errors as $key => $error) { foreach ($errors as $key => $error) {
$response[] = [ $meta = [
'code' => str_replace(self::PTERODACTYL_RULE_STRING, 'p_', array_get( 'source_field' => $field,
'rule' => str_replace(self::PTERODACTYL_RULE_STRING, 'p_', array_get(
$codes, str_replace('.', '_', $field) . '.' . $key $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; return $response;
@ -209,10 +214,19 @@ class Handler extends ExceptionHandler
{ {
$error = [ $error = [
'code' => class_basename($exception), '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.', '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')) { if (config('app.debug')) {
$error = array_merge($error, [ $error = array_merge($error, [
'detail' => $exception->getMessage(), 'detail' => $exception->getMessage(),

View file

@ -9,6 +9,7 @@ use Pterodactyl\Models\Server;
use Illuminate\Contracts\View\Factory; use Illuminate\Contracts\View\Factory;
use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Services\Servers\EnvironmentService;
use Pterodactyl\Repositories\Eloquent\NestRepository; use Pterodactyl\Repositories\Eloquent\NestRepository;
use Pterodactyl\Repositories\Eloquent\NodeRepository; use Pterodactyl\Repositories\Eloquent\NodeRepository;
use Pterodactyl\Repositories\Eloquent\MountRepository; use Pterodactyl\Repositories\Eloquent\MountRepository;
@ -56,6 +57,11 @@ class ServerViewController extends Controller
*/ */
private $nodeRepository; private $nodeRepository;
/**
* @var \Pterodactyl\Services\Servers\EnvironmentService
*/
private $environmentService;
/** /**
* ServerViewController constructor. * ServerViewController constructor.
* *
@ -66,6 +72,7 @@ class ServerViewController extends Controller
* @param \Pterodactyl\Repositories\Eloquent\NestRepository $nestRepository * @param \Pterodactyl\Repositories\Eloquent\NestRepository $nestRepository
* @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository * @param \Pterodactyl\Repositories\Eloquent\NodeRepository $nodeRepository
* @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository * @param \Pterodactyl\Repositories\Eloquent\ServerRepository $repository
* @param \Pterodactyl\Services\Servers\EnvironmentService $environmentService
*/ */
public function __construct( public function __construct(
Factory $view, Factory $view,
@ -74,7 +81,8 @@ class ServerViewController extends Controller
MountRepository $mountRepository, MountRepository $mountRepository,
NestRepository $nestRepository, NestRepository $nestRepository,
NodeRepository $nodeRepository, NodeRepository $nodeRepository,
ServerRepository $repository ServerRepository $repository,
EnvironmentService $environmentService
) { ) {
$this->view = $view; $this->view = $view;
$this->databaseHostRepository = $databaseHostRepository; $this->databaseHostRepository = $databaseHostRepository;
@ -83,6 +91,7 @@ class ServerViewController extends Controller
$this->nestRepository = $nestRepository; $this->nestRepository = $nestRepository;
$this->nodeRepository = $nodeRepository; $this->nodeRepository = $nodeRepository;
$this->repository = $repository; $this->repository = $repository;
$this->environmentService = $environmentService;
} }
/** /**
@ -138,12 +147,12 @@ class ServerViewController extends Controller
*/ */
public function startup(Request $request, Server $server) public function startup(Request $request, Server $server)
{ {
$parameters = $this->repository->getVariablesWithValues($server->id, true);
$nests = $this->nestRepository->getWithEggs(); $nests = $this->nestRepository->getWithEggs();
$variables = $this->environmentService->handle($server);
$this->plainInject([ $this->plainInject([
'server' => $server, 'server' => $server,
'server_variables' => $parameters->data, 'server_variables' => $variables,
'nests' => $nests->map(function (Nest $item) { 'nests' => $nests->map(function (Nest $item) {
return array_merge($item->toArray(), [ return array_merge($item->toArray(), [
'eggs' => $item->eggs->keyBy('id')->toArray(), 'eggs' => $item->eggs->keyBy('id')->toArray(),

View file

@ -82,7 +82,7 @@ class DownloadBackupController extends ClientApiController
throw new BadRequestHttpException; throw new BadRequestHttpException;
} }
return JsonResponse::create([ return new JsonResponse([
'object' => 'signed_url', 'object' => 'signed_url',
'attributes' => [ 'attributes' => [
'url' => $url, 'url' => $url,

View file

@ -0,0 +1,73 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\UploadFileRequest;
class FileUploadController extends ClientApiController
{
/**
* @var \Pterodactyl\Services\Nodes\NodeJWTService
*/
private $jwtService;
/**
* FileUploadController constructor.
*
* @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService
*/
public function __construct(
NodeJWTService $jwtService
) {
parent::__construct();
$this->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()
);
}
}

View file

@ -0,0 +1,120 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Services\Servers\StartupCommandService;
use Pterodactyl\Services\Servers\VariableValidatorService;
use Pterodactyl\Repositories\Eloquent\ServerVariableRepository;
use Pterodactyl\Transformers\Api\Client\EggVariableTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\GetStartupRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupVariableRequest;
class StartupController extends ClientApiController
{
/**
* @var \Pterodactyl\Services\Servers\VariableValidatorService
*/
private $service;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerVariableRepository
*/
private $repository;
/**
* @var \Pterodactyl\Services\Servers\StartupCommandService
*/
private $startupCommandService;
/**
* StartupController constructor.
*
* @param \Pterodactyl\Services\Servers\VariableValidatorService $service
* @param \Pterodactyl\Services\Servers\StartupCommandService $startupCommandService
* @param \Pterodactyl\Repositories\Eloquent\ServerVariableRepository $repository
*/
public function __construct(VariableValidatorService $service, StartupCommandService $startupCommandService, ServerVariableRepository $repository)
{
parent::__construct();
$this->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();
}
}

View file

@ -3,7 +3,9 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers; namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Models\Subuser;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Permission; use Pterodactyl\Models\Permission;
use Pterodactyl\Repositories\Eloquent\SubuserRepository; use Pterodactyl\Repositories\Eloquent\SubuserRepository;
@ -57,6 +59,21 @@ class SubuserController extends ClientApiController
->toArray(); ->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. * 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. * Update a given subuser in the system for the server.
* *
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array * @return array
* *
* @throws \Pterodactyl\Exceptions\Model\DataValidationException * @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @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, [ $this->repository->update($subuser->id, [
'permissions' => $this->getDefaultPermissions($request), 'permissions' => $this->getDefaultPermissions($request),
]); ]);
@ -106,14 +124,16 @@ class SubuserController extends ClientApiController
* Removes a subusers from a server's assignment. * Removes a subusers from a server's assignment.
* *
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request
* @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse * @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);
} }
/** /**

View file

@ -3,9 +3,12 @@
namespace Pterodactyl\Http\Controllers\Api\Remote\Backups; namespace Pterodactyl\Http\Controllers\Api\Remote\Backups;
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Repositories\Eloquent\BackupRepository; use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest; use Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest;
class BackupStatusController extends Controller class BackupStatusController extends Controller
@ -32,24 +35,27 @@ class BackupStatusController extends Controller
* @param string $backup * @param string $backup
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
* *
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function __invoke(ReportBackupCompleteRequest $request, string $backup) public function __invoke(ReportBackupCompleteRequest $request, string $backup)
{ {
/** @var \Pterodactyl\Models\Backup $backup */ /** @var \Pterodactyl\Models\Backup $model */
$backup = $this->repository->findFirstWhere([['uuid', '=', $backup]]); $model = $this->repository->findFirstWhere([[ 'uuid', '=', $backup ]]);
if ($request->input('successful')) { if (!is_null($model->completed_at)) {
$this->repository->update($backup->id, [ throw new BadRequestHttpException(
'sha256_hash' => $request->input('checksum'), 'Cannot update the status of a backup that is already marked as completed.'
'bytes' => $request->input('size'), );
'completed_at' => Carbon::now(),
], true, true);
} else {
$this->repository->delete($backup->id);
} }
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);
} }
} }

View file

@ -0,0 +1,36 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
use Closure;
use Illuminate\Http\Request;
class SubuserBelongsToServer
{
/**
* Ensure that the user being accessed in the request is a user that is currently assigned
* as a subuser for this server instance. We'll let the requests themselves handle wether or
* not the user making the request can actually modify or delete the subuser record.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
/** @var \Pterodactyl\Models\Server $server */
$server = $request->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);
}
}

View file

@ -3,6 +3,7 @@
namespace Pterodactyl\Http\Middleware\Api\Client; namespace Pterodactyl\Http\Middleware\Api\Client;
use Closure; use Closure;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Backup; use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Database; use Pterodactyl\Models\Database;
use Illuminate\Container\Container; use Illuminate\Container\Container;
@ -52,6 +53,10 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings
return Backup::query()->where('uuid', $value)->firstOrFail(); 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); return parent::handle($request, $next);
} }
} }

View file

@ -29,10 +29,6 @@ class DatabaseHostFormRequest extends AdminFormRequest
$this->merge(['node_id' => null]); $this->merge(['node_id' => null]);
} }
$this->merge([
'host' => gethostbyname($this->input('host')),
]);
return parent::getValidatorInstance(); return parent::getValidatorInstance();
} }
} }

View file

@ -17,4 +17,14 @@ class StoreApiKeyRequest extends ClientApiRequest
'allowed_ips.*' => 'ip', 'allowed_ips.*' => 'ip',
]; ];
} }
/**
* @return array|string[]
*/
public function messages()
{
return [
'allowed_ips.*' => 'All of the IP addresses entered must be valid IPv4 addresses.',
];
}
} }

View file

@ -0,0 +1,17 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Files;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class UploadFileRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_FILE_CREATE;
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Startup;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class GetStartupRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_STARTUP_READ;
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Startup;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class UpdateStartupVariableRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_STARTUP_UPDATE;
}
/**
* The actual validation of the variable's value will happen inside the controller.
*
* @return array|string[]
*/
public function rules(): array
{
return [
'key' => 'required|string',
'value' => 'present|string',
];
}
}

View file

@ -3,12 +3,10 @@
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers; namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Pterodactyl\Models\Server; use Pterodactyl\Models\User;
use Pterodactyl\Exceptions\Http\HttpForbiddenException; use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest; use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException; use Pterodactyl\Services\Servers\GetUserPermissionsService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
abstract class SubuserRequest extends ClientApiRequest abstract class SubuserRequest extends ClientApiRequest
{ {
@ -30,10 +28,10 @@ abstract class SubuserRequest extends ClientApiRequest
return false; return false;
} }
// If there is a subuser present in the URL, validate that it is not the same as the $user = $this->route()->parameter('user');
// current request user. You're not allowed to modify yourself. // Don't allow a user to edit themselves on the server.
if ($this->route()->hasParameter('subuser')) { if ($user instanceof User) {
if ($this->endpointSubuser()->user_id === $this->user()->id) { if ($user->uuid === $this->user()->uuid) {
return false; return false;
} }
} }
@ -71,68 +69,14 @@ abstract class SubuserRequest extends ClientApiRequest
// Otherwise, get the current subuser's permission set, and ensure that the // 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 // permissions they are trying to assign are not _more_ than the ones they
// already have. // 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( throw new HttpForbiddenException(
'Cannot assign permissions to a subuser that your account does not actively possess.' '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']
);
}
} }

View file

@ -12,8 +12,9 @@ class ReportBackupCompleteRequest extends FormRequest
public function rules() public function rules()
{ {
return [ return [
'successful' => 'boolean', 'successful' => 'present|boolean',
'checksum' => 'nullable|string|required_if:successful,true', 'checksum' => 'nullable|string|required_if:successful,true',
'checksum_type' => 'nullable|string|required_if:successful,true',
'size' => 'nullable|numeric|required_if:successful,true', 'size' => 'nullable|numeric|required_if:successful,true',
]; ];
} }

View file

@ -8,10 +8,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $id * @property int $id
* @property int $server_id * @property int $server_id
* @property int $uuid * @property int $uuid
* @property bool $is_successful
* @property string $name * @property string $name
* @property string[] $ignored_files * @property string[] $ignored_files
* @property string $disk * @property string $disk
* @property string|null $sha256_hash * @property string|null $checksum
* @property int $bytes * @property int $bytes
* @property \Carbon\CarbonImmutable|null $completed_at * @property \Carbon\CarbonImmutable|null $completed_at
* @property \Carbon\CarbonImmutable $created_at * @property \Carbon\CarbonImmutable $created_at
@ -44,6 +45,7 @@ class Backup extends Model
*/ */
protected $casts = [ protected $casts = [
'id' => 'int', 'id' => 'int',
'is_successful' => 'bool',
'bytes' => 'int', 'bytes' => 'int',
'ignored_files' => 'array', 'ignored_files' => 'array',
]; ];
@ -59,7 +61,8 @@ class Backup extends Model
* @var array * @var array
*/ */
protected $attributes = [ protected $attributes = [
'sha256_hash' => null, 'is_successful' => true,
'checksum' => null,
'bytes' => 0, 'bytes' => 0,
]; ];
@ -69,10 +72,11 @@ class Backup extends Model
public static $validationRules = [ public static $validationRules = [
'server_id' => 'bail|required|numeric|exists:servers,id', 'server_id' => 'bail|required|numeric|exists:servers,id',
'uuid' => 'required|uuid', 'uuid' => 'required|uuid',
'is_successful' => 'boolean',
'name' => 'required|string', 'name' => 'required|string',
'ignored_files' => 'array', 'ignored_files' => 'array',
'disk' => 'required|string', 'disk' => 'required|string',
'sha256_hash' => 'nullable|string', 'checksum' => 'nullable|string',
'bytes' => 'numeric', 'bytes' => 'numeric',
]; ];

View file

@ -2,6 +2,8 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
use Pterodactyl\Rules\ResolvesToIPAddress;
class DatabaseHost extends Model class DatabaseHost extends Model
{ {
/** /**
@ -51,13 +53,25 @@ class DatabaseHost extends Model
*/ */
public static $validationRules = [ public static $validationRules = [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'host' => 'required|unique:database_hosts,host', 'host' => 'required|string',
'port' => 'required|numeric|between:1,65535', 'port' => 'required|numeric|between:1,65535',
'username' => 'required|string|max:32', 'username' => 'required|string|max:32',
'password' => 'nullable|string', 'password' => 'nullable|string',
'node_id' => 'sometimes|nullable|integer|exists:nodes,id', '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. * Gets the node associated with a database host.
* *

View file

@ -2,6 +2,27 @@
namespace Pterodactyl\Models; 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 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'; 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. * The table associated with the model.
* *
@ -38,8 +64,8 @@ class EggVariable extends Model
*/ */
protected $casts = [ protected $casts = [
'egg_id' => 'integer', 'egg_id' => 'integer',
'user_viewable' => 'integer', 'user_viewable' => 'bool',
'user_editable' => 'integer', 'user_editable' => 'bool',
]; ];
/** /**
@ -65,12 +91,19 @@ class EggVariable extends Model
]; ];
/** /**
* @param $value
* @return bool * @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);
} }
/** /**

View file

@ -55,6 +55,9 @@ class Permission extends Model
const ACTION_FILE_ARCHIVE = 'file.archive'; const ACTION_FILE_ARCHIVE = 'file.archive';
const ACTION_FILE_SFTP = 'file.sftp'; 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_RENAME = 'settings.rename';
const ACTION_SETTINGS_REINSTALL = 'settings.reinstall'; const ACTION_SETTINGS_REINSTALL = 'settings.reinstall';
@ -169,8 +172,8 @@ class Permission extends Model
'startup' => [ 'startup' => [
'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.', 'description' => 'Permissions that control a user\'s ability to view this server\'s startup parameters.',
'keys' => [ 'keys' => [
'read' => '', 'read' => 'Allows a user to view the startup variables for a server.',
'update' => '', 'update' => 'Allows a user to modify the startup variables for the server.',
], ],
], ],

View file

@ -4,6 +4,7 @@ namespace Pterodactyl\Models;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Pterodactyl\Models\Traits\Searchable; use Pterodactyl\Models\Traits\Searchable;
use Illuminate\Database\Query\JoinClause;
use Znck\Eloquent\Traits\BelongsToThrough; use Znck\Eloquent\Traits\BelongsToThrough;
/** /**
@ -38,14 +39,14 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property \Carbon\Carbon $updated_at * @property \Carbon\Carbon $updated_at
* *
* @property \Pterodactyl\Models\User $user * @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 $allocation
* @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations * @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
* @property \Pterodactyl\Models\Pack|null $pack * @property \Pterodactyl\Models\Pack|null $pack
* @property \Pterodactyl\Models\Node $node * @property \Pterodactyl\Models\Node $node
* @property \Pterodactyl\Models\Nest $nest * @property \Pterodactyl\Models\Nest $nest
* @property \Pterodactyl\Models\Egg $egg * @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\Schedule[]|\Illuminate\Database\Eloquent\Collection $schedule
* @property \Pterodactyl\Models\Database[]|\Illuminate\Database\Eloquent\Collection $databases * @property \Pterodactyl\Models\Database[]|\Illuminate\Database\Eloquent\Collection $databases
* @property \Pterodactyl\Models\Location $location * @property \Pterodactyl\Models\Location $location
@ -270,7 +271,17 @@ class Server extends Model
*/ */
public function variables() 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);
});
} }
/** /**

View file

@ -27,6 +27,7 @@ class BackupRepository extends EloquentRepository
return $this->getBuilder() return $this->getBuilder()
->withTrashed() ->withTrashed()
->where('server_id', $server) ->where('server_id', $server)
->where('is_successful', true)
->where('created_at', '>=', Carbon::now()->subMinutes($minutes)->toDateTimeString()) ->where('created_at', '>=', Carbon::now()->subMinutes($minutes)->toDateTimeString())
->get() ->get()
->toBase(); ->toBase();

View file

@ -131,41 +131,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt
return $server; 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. * Return enough data to be used for the creation of a server via the daemon.
* *

View file

@ -18,30 +18,6 @@ class SubuserRepository extends EloquentRepository implements SubuserRepositoryI
return Subuser::class; 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. * Return a subuser with the associated server relationship.
* *

View file

@ -0,0 +1,49 @@
<?php
namespace Pterodactyl\Rules;
use Illuminate\Contracts\Validation\Rule;
class ResolvesToIPAddress implements Rule
{
/**
* Validate that a given string can correctly resolve to a valid IPv4 address.
*
* @param string $attribute
* @param mixed $value
* @return bool
*/
public function passes($attribute, $value): bool
{
// inet_pton returns false if the value passed through is not a valid IP address, so we'll just
// use that a nice ugly PHP hack to determine if we should pass this off to the gethostbyname
// call below.
$isIP = inet_pton($attribute) !== false;
// If the value received is not an IP address try to look it up using the gethostbyname() call.
// If that returns the same value that we passed in then it means it did not resolve to anything
// and we should fail this validation call.
return $isIP || gethostbyname($value) !== $value;
}
/**
* Return a validation message for use when this rule fails.
*
* @return string
*/
public function message(): string
{
return 'The :attribute must be a valid IPv4 address or hostname that resolves to a valid IPv4 address.';
}
/**
* Convert the rule to a validation string. This is necessary to avoid
* issues with Eloquence which tries to use this rule as a string.
*
* @return string
*/
public function __toString()
{
return 'p_resolves_to_ip_address';
}
}

View file

@ -2,7 +2,6 @@
namespace Pterodactyl\Services\Backups; namespace Pterodactyl\Services\Backups;
use Carbon\Carbon;
use Ramsey\Uuid\Uuid; use Ramsey\Uuid\Uuid;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
@ -101,14 +100,14 @@ class InitiateBackupService
public function handle(Server $server, string $name = null): Backup public function handle(Server $server, string $name = null): Backup
{ {
// Do not allow the user to continue if this server is already at its limit. // Do not allow the user to continue if this server is already at its limit.
if (! $server->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); throw new TooManyBackupsException($server->backup_limit);
} }
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, 10); $previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, 10);
if ($previous->count() >= 2) { if ($previous->count() >= 2) {
throw new TooManyRequestsHttpException( 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.' 'Only two backups may be generated within a 10 minute span of time.'
); );
} }

View file

@ -3,6 +3,7 @@
namespace Pterodactyl\Services\Servers; namespace Pterodactyl\Services\Servers;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Models\EggVariable;
use Illuminate\Contracts\Config\Repository as ConfigRepository; use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
@ -63,35 +64,33 @@ class EnvironmentService
* *
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return array * @return array
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function handle(Server $server): array 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 // Process environment variables defined in this file. This is done first
// in order to allow run-time and config defined variables to take // in order to allow run-time and config defined variables to take
// priority over built-in values. // priority over built-in values.
foreach ($this->getEnvironmentMappings() as $key => $object) { 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. // Process variables set in the configuration file.
foreach ($this->config->get('pterodactyl.environment_variables', []) as $key => $object) { foreach ($this->config->get('pterodactyl.environment_variables', []) as $key => $object) {
if (is_callable($object)) { $variables->put(
$variables[$key] = call_user_func($object, $server); $key, is_callable($object) ? call_user_func($object, $server) : object_get($server, $object)
} else { );
$variables[$key] = object_get($server, $object);
}
} }
// Process dynamically included environment variables. // Process dynamically included environment variables.
foreach ($this->additional as $key => $closure) { 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();
} }
/** /**

View file

@ -30,7 +30,7 @@ class GetUserPermissionsService
} }
/** @var \Pterodactyl\Models\Subuser|null $subuserPermissions */ /** @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 : []; return $subuserPermissions ? $subuserPermissions->permissions : [];
} }

View file

@ -242,16 +242,16 @@ class ServerCreationService
'io' => Arr::get($data, 'io'), 'io' => Arr::get($data, 'io'),
'cpu' => Arr::get($data, 'cpu'), 'cpu' => Arr::get($data, 'cpu'),
'threads' => Arr::get($data, 'threads'), 'threads' => Arr::get($data, 'threads'),
'oom_disabled' => Arr::get($data, 'oom_disabled', true), 'oom_disabled' => Arr::get($data, 'oom_disabled') ?? true,
'allocation_id' => Arr::get($data, 'allocation_id'), 'allocation_id' => Arr::get($data, 'allocation_id'),
'nest_id' => Arr::get($data, 'nest_id'), 'nest_id' => Arr::get($data, 'nest_id'),
'egg_id' => Arr::get($data, 'egg_id'), 'egg_id' => Arr::get($data, 'egg_id'),
'pack_id' => empty($data['pack_id']) ? null : $data['pack_id'], 'pack_id' => empty($data['pack_id']) ? null : $data['pack_id'],
'startup' => Arr::get($data, 'startup'), 'startup' => Arr::get($data, 'startup'),
'image' => Arr::get($data, 'image'), 'image' => Arr::get($data, 'image'),
'database_limit' => Arr::get($data, 'database_limit', 0), 'database_limit' => Arr::get($data, 'database_limit') ?? 0,
'allocation_limit' => Arr::get($data, 'allocation_limit', 0), 'allocation_limit' => Arr::get($data, 'allocation_limit') ?? 0,
'backup_limit' => Arr::get($data, 'backup_limit', 0), 'backup_limit' => Arr::get($data, 'backup_limit') ?? 0,
]); ]);
return $model; return $model;

View file

@ -0,0 +1,28 @@
<?php
namespace Pterodactyl\Services\Servers;
use Pterodactyl\Models\Server;
class StartupCommandService
{
/**
* Generates a startup command for a given server instance.
*
* @param \Pterodactyl\Models\Server $server
* @param bool $hideAllValues
* @return string
*/
public function handle(Server $server, bool $hideAllValues = false): string
{
$find = ['{{SERVER_MEMORY}}', '{{SERVER_IP}}', '{{SERVER_PORT}}'];
$replace = [$server->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);
}
}

View file

@ -1,56 +0,0 @@
<?php
namespace Pterodactyl\Services\Servers;
use Illuminate\Support\Collection;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
class StartupCommandViewService
{
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* StartupCommandViewService constructor.
*
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
*/
public function __construct(ServerRepositoryInterface $repository)
{
$this->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,
]);
}
}

View file

@ -22,9 +22,10 @@ class BackupTransformer extends BaseClientTransformer
{ {
return [ return [
'uuid' => $backup->uuid, 'uuid' => $backup->uuid,
'is_successful' => $backup->is_successful,
'name' => $backup->name, 'name' => $backup->name,
'ignored_files' => $backup->ignored_files, 'ignored_files' => $backup->ignored_files,
'sha256_hash' => $backup->sha256_hash, 'checksum' => $backup->checksum,
'bytes' => $backup->bytes, 'bytes' => $backup->bytes,
'created_at' => $backup->created_at->toIso8601String(), 'created_at' => $backup->created_at->toIso8601String(),
'completed_at' => $backup->completed_at ? $backup->completed_at->toIso8601String() : null, 'completed_at' => $backup->completed_at ? $backup->completed_at->toIso8601String() : null,

View file

@ -4,6 +4,7 @@ namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\Database; use Pterodactyl\Models\Database;
use League\Fractal\Resource\Item; use League\Fractal\Resource\Item;
use Pterodactyl\Models\Permission;
use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Contracts\Extensions\HashidsInterface; use Pterodactyl\Contracts\Extensions\HashidsInterface;
@ -65,12 +66,16 @@ class DatabaseTransformer extends BaseClientTransformer
/** /**
* Include the database password in the request. * Include the database password in the request.
* *
* @param \Pterodactyl\Models\Database $model * @param \Pterodactyl\Models\Database $database
* @return \League\Fractal\Resource\Item * @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 [ return [
'password' => $this->encrypter->decrypt($model->password), 'password' => $this->encrypter->decrypt($model->password),
]; ];

View file

@ -0,0 +1,33 @@
<?php
namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\EggVariable;
class EggVariableTransformer extends BaseClientTransformer
{
/**
* @return string
*/
public function getResourceName(): string
{
return EggVariable::RESOURCE_NAME;
}
/**
* @param \Pterodactyl\Models\EggVariable $variable
* @return array
*/
public function transform(EggVariable $variable)
{
return [
'name' => $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,
];
}
}

View file

@ -6,13 +6,17 @@ use Pterodactyl\Models\Egg;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Models\Subuser; use Pterodactyl\Models\Subuser;
use Pterodactyl\Models\Allocation; 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 class ServerTransformer extends BaseClientTransformer
{ {
/** /**
* @var string[] * @var string[]
*/ */
protected $defaultIncludes = ['allocations']; protected $defaultIncludes = ['allocations', 'variables'];
/** /**
* @var array * @var array
@ -36,6 +40,9 @@ class ServerTransformer extends BaseClientTransformer
*/ */
public function transform(Server $server): array public function transform(Server $server): array
{ {
/** @var \Pterodactyl\Services\Servers\StartupCommandService $service */
$service = Container::getInstance()->make(StartupCommandService::class);
return [ return [
'server_owner' => $this->getKey()->user_id === $server->owner_id, 'server_owner' => $this->getKey()->user_id === $server->owner_id,
'identifier' => $server->uuidShort, 'identifier' => $server->uuidShort,
@ -54,6 +61,7 @@ class ServerTransformer extends BaseClientTransformer
'io' => $server->io, 'io' => $server->io,
'cpu' => $server->cpu, 'cpu' => $server->cpu,
], ],
'invocation' => $service->handle($server, ! $this->getUser()->can(Permission::ACTION_STARTUP_READ, $server)),
'feature_limits' => [ 'feature_limits' => [
'databases' => $server->database_limit, 'databases' => $server->database_limit,
'allocations' => $server->allocation_limit, 'allocations' => $server->allocation_limit,
@ -68,11 +76,16 @@ class ServerTransformer extends BaseClientTransformer
* Returns the allocations associated with this server. * Returns the allocations associated with this server.
* *
* @param \Pterodactyl\Models\Server $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 * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/ */
public function includeAllocations(Server $server) public function includeAllocations(Server $server)
{ {
if (! $this->getUser()->can(Permission::ACTION_ALLOCATION_READ, $server)) {
return $this->null();
}
return $this->collection( return $this->collection(
$server->allocations, $server->allocations,
$this->makeTransformer(AllocationTransformer::class), $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. * Returns the egg associated with this server.
* *
@ -96,11 +128,16 @@ class ServerTransformer extends BaseClientTransformer
* Returns the subusers associated with this server. * Returns the subusers associated with this server.
* *
* @param \Pterodactyl\Models\Server $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 * @throws \Pterodactyl\Exceptions\Transformer\InvalidTransformerLevelException
*/ */
public function includeSubusers(Server $server) 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); return $this->collection($server->subusers, $this->makeTransformer(SubuserTransformer::class), Subuser::RESOURCE_NAME);
} }
} }

View file

@ -177,7 +177,7 @@ return [
| This array includes the MIME filetypes that can be edited via the web. | This array includes the MIME filetypes that can be edited via the web.
*/ */
'files' => [ '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' => [ 'editable' => [
'application/json', 'application/json',
'application/javascript', 'application/javascript',

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddBackupStateColumnToBackups extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('backups', function (Blueprint $table) {
$table->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');
});
}
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class UpdateBytesToUnsignedBigint extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('backups', function (Blueprint $table) {
$table->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();
});
}
}

View file

@ -0,0 +1,41 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ModifyChecksumsColumnForBackups extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('backups', function (Blueprint $table) {
$table->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)');
});
}
}

View file

@ -39,6 +39,8 @@ rules:
comma-dangle: comma-dangle:
- warn - warn
- always-multiline - always-multiline
spaced-comment:
- warn
array-bracket-spacing: array-bracket-spacing:
- warn - warn
- always - always

View file

@ -1,9 +1,10 @@
import React from 'react'; import React, { useRef } from 'react';
import { Route } from 'react-router'; import { Route } from 'react-router';
import { SwitchTransition } from 'react-transition-group'; import { SwitchTransition } from 'react-transition-group';
import Fade from '@/components/elements/Fade'; import Fade from '@/components/elements/Fade';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import tw from 'twin.macro'; import tw from 'twin.macro';
import v4 from 'uuid/v4';
const StyledSwitchTransition = styled(SwitchTransition)` const StyledSwitchTransition = styled(SwitchTransition)`
${tw`relative`}; ${tw`relative`};
@ -13,18 +14,22 @@ const StyledSwitchTransition = styled(SwitchTransition)`
} }
`; `;
const TransitionRouter: React.FC = ({ children }) => ( const TransitionRouter: React.FC = ({ children }) => {
<Route const uuid = useRef(v4()).current;
render={({ location }) => (
<StyledSwitchTransition> return (
<Fade timeout={150} key={location.key} in appear unmountOnExit> <Route
<section> render={({ location }) => (
{children} <StyledSwitchTransition>
</section> <Fade timeout={150} key={location.key || uuid} in appear unmountOnExit>
</Fade> <section>
</StyledSwitchTransition> {children}
)} </section>
/> </Fade>
); </StyledSwitchTransition>
)}
/>
);
};
export default TransitionRouter; export default TransitionRouter;

View file

@ -66,6 +66,11 @@ export function httpErrorToHuman (error: any): string {
if (data.errors && data.errors[0] && data.errors[0].detail) { if (data.errors && data.errors[0] && data.errors[0].detail) {
return 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; return error.message;

View file

@ -1,5 +1,6 @@
import { rawDataToServerBackup, ServerBackup } from '@/api/server/backups/getServerBackups';
import http from '@/api/http'; 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<ServerBackup> => { export default (uuid: string, name?: string, ignored?: string): Promise<ServerBackup> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View file

@ -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<PaginatedResult<ServerBackup>> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/backups`, { params: { page } })
.then(({ data }) => resolve({
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
}))
.catch(reject);
});
};

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (uuid: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/files/upload`)
.then(({ data }) => resolve(data.attributes.url))
.catch(reject);
});
};

View file

@ -1,5 +1,6 @@
import http, { FractalResponseData, FractalResponseList } from '@/api/http'; 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 { export interface Allocation {
id: number; id: number;
@ -19,8 +20,8 @@ export interface Server {
ip: string; ip: string;
port: number; port: number;
}; };
invocation: string;
description: string; description: string;
allocations: Allocation[];
limits: { limits: {
memory: number; memory: number;
swap: number; swap: number;
@ -36,6 +37,8 @@ export interface Server {
}; };
isSuspended: boolean; isSuspended: boolean;
isInstalling: boolean; isInstalling: boolean;
variables: ServerEggVariable[];
allocations: Allocation[];
} }
export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData): Server => ({
@ -43,6 +46,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
uuid: data.uuid, uuid: data.uuid,
name: data.name, name: data.name,
node: data.node, node: data.node,
invocation: data.invocation,
sftpDetails: { sftpDetails: {
ip: data.sftp_details.ip, ip: data.sftp_details.ip,
port: data.sftp_details.port, port: data.sftp_details.port,
@ -52,6 +56,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
featureLimits: { ...data.feature_limits }, featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended, isSuspended: data.is_suspended,
isInstalling: data.is_installing, isInstalling: data.is_installing,
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable),
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation), allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),
}); });

20
resources/scripts/api/server/types.d.ts vendored Normal file
View file

@ -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[];
}

View file

@ -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 ];
};

View file

@ -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<PaginatedResult<ServerBackup>>([ '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),
});
});
};

View file

@ -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<Response> => {
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 });

View file

@ -1,6 +1,7 @@
import { Allocation } from '@/api/server/getServer'; import { Allocation } from '@/api/server/getServer';
import { FractalResponseData } from '@/api/http'; import { FractalResponseData } from '@/api/http';
import { FileObject } from '@/api/server/files/loadDirectory'; import { FileObject } from '@/api/server/files/loadDirectory';
import { ServerBackup, ServerEggVariable } from '@/api/server/types';
export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({ export const rawDataToServerAllocation = (data: FractalResponseData): Allocation => ({
id: data.attributes.id, id: data.attributes.id,
@ -39,3 +40,24 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
].indexOf(this.mimetype) >= 0; ].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('|'),
});

View file

@ -43,8 +43,8 @@ const RightNavigation = styled.div`
`; `;
export default () => { export default () => {
const user = useStoreState((state: ApplicationStore) => state.user.data!);
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name); const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
const rootAdmin = useStoreState((state: ApplicationStore) => state.user.data!.rootAdmin);
return ( return (
<Navigation> <Navigation>
@ -62,7 +62,7 @@ export default () => {
<NavLink to={'/account'}> <NavLink to={'/account'}>
<FontAwesomeIcon icon={faUserCircle}/> <FontAwesomeIcon icon={faUserCircle}/>
</NavLink> </NavLink>
{user.rootAdmin && {rootAdmin &&
<a href={'/admin'} target={'_blank'} rel={'noreferrer'}> <a href={'/admin'} target={'_blank'} rel={'noreferrer'}>
<FontAwesomeIcon icon={faCogs}/> <FontAwesomeIcon icon={faCogs}/>
</a> </a>

View file

@ -61,21 +61,19 @@ export default () => {
</ContentBox> </ContentBox>
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}> <ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
<SpinnerOverlay visible={loading}/> <SpinnerOverlay visible={loading}/>
{deleteIdentifier &&
<ConfirmationModal <ConfirmationModal
visible visible={!!deleteIdentifier}
title={'Confirm key deletion'} title={'Confirm key deletion'}
buttonText={'Yes, delete key'} buttonText={'Yes, delete key'}
onConfirmed={() => { onConfirmed={() => {
doDeletion(deleteIdentifier); doDeletion(deleteIdentifier);
setDeleteIdentifier(''); setDeleteIdentifier('');
}} }}
onDismissed={() => setDeleteIdentifier('')} onModalDismissed={() => setDeleteIdentifier('')}
> >
Are you sure you wish to delete this API key? All requests using it will immediately be Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail. invalidated and will fail.
</ConfirmationModal> </ConfirmationModal>
}
{ {
keys.length === 0 ? keys.length === 0 ?
<p css={tw`text-center text-sm`}> <p css={tw`text-center text-sm`}>

View file

@ -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 (
<>
<h3 css={tw`mb-6`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
</pre>
<div css={tw`flex justify-end mt-6`}>
<Button type={'button'} onClick={() => dismiss()}>
Close
</Button>
</div>
</>
);
};
ApiKeyModal.displayName = 'ApiKeyModal';
export default asModal<Props>({
closeOnEscape: false,
closeOnBackground: false,
})(ApiKeyModal);

View file

@ -59,7 +59,17 @@ export default ({ server, className }: { server: Server; className?: string }) =
<FontAwesomeIcon icon={faServer}/> <FontAwesomeIcon icon={faServer}/>
</div> </div>
<div css={tw`flex-1 ml-4`}> <div css={tw`flex-1 ml-4`}>
<p css={tw`text-lg`}>{server.name}</p> <div css={tw`flex items-center`}>
<div
css={[
tw`w-3 h-3 rounded-full mr-2`,
(!stats?.status || stats?.status === 'offline')
? tw`bg-red-500`
: (stats?.status === 'running' ? tw`bg-green-500` : tw`bg-yellow-500`),
]}
/>
<p css={tw`text-lg`}>{server.name}</p>
</div>
</div> </div>
<div css={tw`w-1/4 overflow-hidden`}> <div css={tw`w-1/4 overflow-hidden`}>
<div css={tw`flex ml-4`}> <div css={tw`flex ml-4`}>

View file

@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { Field, Form, Formik, FormikHelpers } from 'formik'; import { Field, Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup'; import { object, string } from 'yup';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import Modal from '@/components/elements/Modal';
import createApiKey from '@/api/account/createApiKey'; import createApiKey from '@/api/account/createApiKey';
import { Actions, useStoreActions } from 'easy-peasy'; import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
@ -12,12 +11,16 @@ import { ApiKey } from '@/api/account/getApiKeys';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Input, { Textarea } from '@/components/elements/Input'; import Input, { Textarea } from '@/components/elements/Input';
import styled from 'styled-components/macro';
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
interface Values { interface Values {
description: string; description: string;
allowedIps: string; allowedIps: string;
} }
const CustomTextarea = styled(Textarea)`${tw`h-32`}`;
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => { export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
const [ apiKey, setApiKey ] = useState(''); const [ apiKey, setApiKey ] = useState('');
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -41,35 +44,14 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
return ( return (
<> <>
<Modal <ApiKeyModal
visible={apiKey.length > 0} visible={apiKey.length > 0}
onDismissed={() => setApiKey('')} onModalDismissed={() => setApiKey('')}
closeOnEscape={false} apiKey={apiKey}
closeOnBackground={false} />
>
<h3 css={tw`mb-6`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
</pre>
<div css={tw`flex justify-end mt-6`}>
<Button
type={'button'}
onClick={() => setApiKey('')}
>
Close
</Button>
</div>
</Modal>
<Formik <Formik
onSubmit={submit} onSubmit={submit}
initialValues={{ initialValues={{ description: '', allowedIps: '' }}
description: '',
allowedIps: '',
}}
validationSchema={object().shape({ validationSchema={object().shape({
allowedIps: string(), allowedIps: string(),
description: string().required().min(4), description: string().required().min(4),
@ -91,7 +73,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
name={'allowedIps'} name={'allowedIps'}
description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'} description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'}
> >
<Field as={Textarea} name={'allowedIps'} css={tw`h-32`}/> <Field name={'allowedIps'} as={CustomTextarea}/>
</FormikFieldWrapper> </FormikFieldWrapper>
<div css={tw`flex justify-end mt-6`}> <div css={tw`flex justify-end mt-6`}>
<Button>Create</Button> <Button>Create</Button>

View file

@ -2,8 +2,6 @@ import React, { useCallback, useEffect, useState } from 'react';
import ace, { Editor } from 'brace'; import ace, { Editor } from 'brace';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Select from '@/components/elements/Select';
// @ts-ignore
import modes from '@/modes'; import modes from '@/modes';
// @ts-ignore // @ts-ignore
@ -21,42 +19,38 @@ const EditorContainer = styled.div`
`; `;
Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`)); Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`));
const modelist = ace.acequire('ace/ext/modelist');
export interface Props { export interface Props {
style?: React.CSSProperties; style?: React.CSSProperties;
initialContent?: string; initialContent?: string;
initialModePath?: string; mode: string;
filename?: string;
onModeChanged: (mode: string) => void;
fetchContent: (callback: () => Promise<string>) => void; fetchContent: (callback: () => Promise<string>) => void;
onContentSaved: (content: string) => void; onContentSaved: () => void;
} }
export default ({ style, initialContent, initialModePath, fetchContent, onContentSaved }: Props) => { export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => {
const [ mode, setMode ] = useState('ace/mode/plain_text');
const [ editor, setEditor ] = useState<Editor>(); const [ editor, setEditor ] = useState<Editor>();
const ref = useCallback(node => { const ref = useCallback(node => {
if (node) { if (node) setEditor(ace.edit('editor'));
setEditor(ace.edit('editor'));
}
}, []); }, []);
useEffect(() => { 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 ]); }, [ editor, mode ]);
useEffect(() => { useEffect(() => {
editor && editor.session.setValue(initialContent || ''); editor && editor.session.setValue(initialContent || '');
}, [ editor, initialContent ]); }, [ editor, initialContent ]);
useEffect(() => {
if (initialModePath) {
const modelist = ace.acequire('ace/ext/modelist');
if (modelist) {
setMode(modelist.getModeForPath(initialModePath).mode);
}
}
}, [ initialModePath ]);
useEffect(() => { useEffect(() => {
if (!editor) { if (!editor) {
fetchContent(() => Promise.reject(new Error('no editor session has been configured'))); 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({ editor.commands.addCommand({
name: 'Save', name: 'Save',
bindKey: { win: 'Ctrl-s', mac: 'Command-s' }, bindKey: { win: 'Ctrl-s', mac: 'Command-s' },
exec: (editor: Editor) => onContentSaved(editor.session.getValue()), exec: () => onContentSaved(),
}); });
fetchContent(() => Promise.resolve(editor.session.getValue())); fetchContent(() => Promise.resolve(editor.session.getValue()));
@ -85,20 +79,6 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
return ( return (
<EditorContainer style={style}> <EditorContainer style={style}>
<div id={'editor'} ref={ref}/> <div id={'editor'} ref={ref}/>
<div css={tw`absolute right-0 bottom-0 z-50`}>
<div css={tw`m-3 rounded bg-neutral-900 border border-black`}>
<Select
value={mode.split('/').pop()}
onChange={e => setMode(`ace/mode/${e.currentTarget.value}`)}
>
{
Object.keys(modes).map(key => (
<option key={key} value={key}>{(modes as { [k: string]: string })[key]}</option>
))
}
</Select>
</div>
</div>
</EditorContainer> </EditorContainer>
); );
}; };

View file

@ -1,5 +1,6 @@
import React from 'react'; import React, { memo } from 'react';
import { usePermissions } from '@/plugins/usePermissions'; import { usePermissions } from '@/plugins/usePermissions';
import isEqual from 'react-fast-compare';
interface Props { interface Props {
action: string | string[]; action: string | string[];
@ -23,4 +24,4 @@ const Can = ({ action, matchAny = false, renderOnError, children }: Props) => {
); );
}; };
export default Can; export default memo(Can, isEqual);

View file

@ -1,7 +1,8 @@
import React from 'react'; import React, { useContext } from 'react';
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
type Props = { type Props = {
title: string; title: string;
@ -9,26 +10,29 @@ type Props = {
children: string; children: string;
onConfirmed: () => void; onConfirmed: () => void;
showSpinnerOverlay?: boolean; showSpinnerOverlay?: boolean;
} & RequiredModalProps; };
const ConfirmationModal = ({ title, appear, children, visible, buttonText, onConfirmed, showSpinnerOverlay, onDismissed }: Props) => ( const ConfirmationModal = ({ title, children, buttonText, onConfirmed }: Props) => {
<Modal const { dismiss } = useContext(ModalContext);
appear={appear || true}
visible={visible}
showSpinnerOverlay={showSpinnerOverlay}
onDismissed={() => onDismissed()}
>
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p>
<div css={tw`flex items-center justify-end mt-8`}>
<Button isSecondary onClick={() => onDismissed()}>
Cancel
</Button>
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
{buttonText}
</Button>
</div>
</Modal>
);
export default ConfirmationModal; return (
<>
<h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p>
<div css={tw`flex items-center justify-end mt-8`}>
<Button isSecondary onClick={() => dismiss()}>
Cancel
</Button>
<Button color={'red'} css={tw`ml-4`} onClick={() => onConfirmed()}>
{buttonText}
</Button>
</div>
</>
);
};
ConfirmationModal.displayName = 'ConfirmationModal';
export default asModal<Props>(props => ({
showSpinnerOverlay: props.showSpinnerOverlay,
}))(ConfirmationModal);

View file

@ -8,14 +8,14 @@ interface Props extends Omit<CSSTransitionProps, 'timeout' | 'classNames'> {
} }
const Container = styled.div<{ timeout: number }>` const Container = styled.div<{ timeout: number }>`
.fade-enter, .fade-exit { .fade-enter, .fade-exit, .fade-appear {
will-change: opacity; will-change: opacity;
} }
.fade-enter { .fade-enter, .fade-appear {
${tw`opacity-0`}; ${tw`opacity-0`};
&.fade-enter-active { &.fade-enter-active, &.fade-appear-active {
${tw`opacity-100 transition-opacity ease-in`}; ${tw`opacity-100 transition-opacity ease-in`};
transition-duration: ${props => props.timeout}ms; transition-duration: ${props => props.timeout}ms;
} }

View file

@ -13,14 +13,14 @@ export interface RequiredModalProps {
top?: boolean; top?: boolean;
} }
interface Props extends RequiredModalProps { export interface ModalProps extends RequiredModalProps {
dismissable?: boolean; dismissable?: boolean;
closeOnEscape?: boolean; closeOnEscape?: boolean;
closeOnBackground?: boolean; closeOnBackground?: boolean;
showSpinnerOverlay?: boolean; showSpinnerOverlay?: boolean;
} }
const ModalMask = styled.div` export const ModalMask = styled.div`
${tw`fixed z-50 overflow-auto flex w-full inset-0`}; ${tw`fixed z-50 overflow-auto flex w-full inset-0`};
background: rgba(0, 0, 0, 0.70); background: rgba(0, 0, 0, 0.70);
`; `;
@ -40,7 +40,7 @@ const ModalContainer = styled.div<{ alignTop?: boolean }>`
} }
`; `;
const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => { const Modal: React.FC<ModalProps> = ({ visible, appear, dismissable, showSpinnerOverlay, top = true, closeOnBackground = true, closeOnEscape = true, onDismissed, children }) => {
const [ render, setRender ] = useState(visible); const [ render, setRender ] = useState(visible);
const isDismissable = useMemo(() => { const isDismissable = useMemo(() => {
@ -62,7 +62,13 @@ const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverl
}, [ render ]); }, [ render ]);
return ( return (
<Fade timeout={150} appear={appear} in={render} unmountOnExit onExited={onDismissed}> <Fade
in={render}
timeout={150}
appear={appear || true}
unmountOnExit
onExited={() => onDismissed()}
>
<ModalMask <ModalMask
onClick={e => { onClick={e => {
if (isDismissable && closeOnBackground) { if (isDismissable && closeOnBackground) {
@ -80,12 +86,14 @@ const Modal: React.FC<Props> = ({ visible, appear, dismissable, showSpinnerOverl
</div> </div>
} }
{showSpinnerOverlay && {showSpinnerOverlay &&
<div <Fade timeout={150} appear in>
css={tw`absolute w-full h-full rounded flex items-center justify-center`} <div
style={{ background: 'hsla(211, 10%, 53%, 0.25)' }} css={tw`absolute w-full h-full rounded flex items-center justify-center`}
> style={{ background: 'hsla(211, 10%, 53%, 0.25)' }}
<Spinner/> >
</div> <Spinner/>
</div>
</Fade>
} }
<div css={tw`bg-neutral-800 p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}> <div css={tw`bg-neutral-800 p-6 rounded shadow-md overflow-y-scroll transition-all duration-150`}>
{children} {children}

View file

@ -3,31 +3,45 @@ import ContentContainer from '@/components/elements/ContentContainer';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import tw from 'twin.macro'; import tw from 'twin.macro';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { Helmet } from 'react-helmet';
const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => ( export interface PageContentBlockProps {
<CSSTransition timeout={150} classNames={'fade'} appear in> title?: string;
<> className?: string;
<ContentContainer css={tw`my-10`} className={className}> showFlashKey?: string;
{showFlashKey && }
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
const PageContentBlock: React.FC<PageContentBlockProps> = ({ title, showFlashKey, className, children }) => {
return (
<CSSTransition timeout={150} classNames={'fade'} appear in>
<>
{title &&
<Helmet>
<title>{title}</title>
</Helmet>
} }
{children} <ContentContainer css={tw`my-10`} className={className}>
</ContentContainer> {showFlashKey &&
<ContentContainer css={tw`mb-4`}> <FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
<p css={tw`text-center text-neutral-500 text-xs`}> }
&copy; 2015 - 2020&nbsp; {children}
<a </ContentContainer>
rel={'noopener nofollow noreferrer'} <ContentContainer css={tw`mb-4`}>
href={'https://pterodactyl.io'} <p css={tw`text-center text-neutral-500 text-xs`}>
target={'_blank'} &copy; 2015 - 2020&nbsp;
css={tw`no-underline text-neutral-500 hover:text-neutral-300`} <a
> rel={'noopener nofollow noreferrer'}
Pterodactyl Software href={'https://pterodactyl.io'}
</a> target={'_blank'}
</p> css={tw`no-underline text-neutral-500 hover:text-neutral-300`}
</ContentContainer> >
</> Pterodactyl Software
</CSSTransition> </a>
); </p>
</ContentContainer>
</>
</CSSTransition>
);
};
export default PageContentBlock; export default PageContentBlock;

View file

@ -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<Props> = ({ title, children, ...props }) => {
const name = ServerContext.useStoreState(state => state.server.data!.name);
return (
<PageContentBlock title={`${name} | ${title}`} {...props}>
{children}
</PageContentBlock>
);
};
export default ServerContentBlock;

View file

@ -45,4 +45,10 @@ const Spinner = ({ centered, ...props }: Props) => (
); );
Spinner.DisplayName = 'Spinner'; Spinner.DisplayName = 'Spinner';
Spinner.Size = {
SMALL: 'small' as SpinnerSize,
BASE: 'base' as SpinnerSize,
LARGE: 'large' as SpinnerSize,
};
export default Spinner; export default Spinner;

View file

@ -52,8 +52,8 @@ export default ({ title, image, message, onBack, onRetry }: Props) => (
</ActionButton> </ActionButton>
</div> </div>
} }
<img src={image} css={tw`w-2/3 h-auto select-none`}/> <img src={image} css={tw`w-2/3 h-auto select-none mx-auto`}/>
<h2 css={tw`mt-6 text-neutral-900 font-bold`}>{title}</h2> <h2 css={tw`mt-10 text-neutral-900 font-bold text-4xl`}>{title}</h2>
<p css={tw`text-sm text-neutral-700 mt-2`}> <p css={tw`text-sm text-neutral-700 mt-2`}>
{message} {message}
</p> </p>

View file

@ -1,23 +1,22 @@
import useWebsocketEvent from '@/plugins/useWebsocketEvent'; import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import useServer from '@/plugins/useServer';
const InstallListener = () => { const InstallListener = () => {
const server = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); 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 // 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 // server information. This allows the server to automatically become available to the user if they
// just sit on the page. // just sit on the page.
useWebsocketEvent('install completed', () => { 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 // When we see the install started event immediately update the state to indicate such so that the
// screens automatically update. // screens automatically update.
useWebsocketEvent('install started', () => { useWebsocketEvent('install started', () => {
setServer({ ...server, isInstalling: true }); setServerFromState(s => ({ ...s, isInstalling: true }));
}); });
return null; return null;

View file

@ -1,5 +1,4 @@
import React, { lazy, useEffect, useState } from 'react'; import React, { lazy, useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons'; 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 SuspenseSpinner from '@/components/elements/SuspenseSpinner';
import TitledGreyBox from '@/components/elements/TitledGreyBox'; import TitledGreyBox from '@/components/elements/TitledGreyBox';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ContentContainer from '@/components/elements/ContentContainer'; import ContentContainer from '@/components/elements/ContentContainer';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import StopOrKillButton from '@/components/server/StopOrKillButton'; import StopOrKillButton from '@/components/server/StopOrKillButton';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
export type PowerAction = 'start' | 'stop' | 'restart' | 'kill'; export type PowerAction = 'start' | 'stop' | 'restart' | 'kill';
@ -23,10 +22,13 @@ export default () => {
const [ cpu, setCpu ] = useState(0); const [ cpu, setCpu ] = useState(0);
const [ disk, setDisk ] = 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 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) => { const statsListener = (data: string) => {
let stats: any = {}; let stats: any = {};
@ -57,16 +59,13 @@ export default () => {
}; };
}, [ instance, connected ]); }, [ instance, connected ]);
const disklimit = server.limits.disk ? megabytesToHuman(server.limits.disk) : 'Unlimited'; const disklimit = limits.disk ? megabytesToHuman(limits.disk) : 'Unlimited';
const memorylimit = server.limits.memory ? megabytesToHuman(server.limits.memory) : 'Unlimited'; const memorylimit = limits.memory ? megabytesToHuman(limits.memory) : 'Unlimited';
return ( return (
<PageContentBlock css={tw`flex`}> <ServerContentBlock title={'Console'} css={tw`flex`}>
<Helmet>
<title> {server.name} | Console </title>
</Helmet>
<div css={tw`w-1/4`}> <div css={tw`w-1/4`}>
<TitledGreyBox title={server.name} icon={faServer}> <TitledGreyBox title={name} icon={faServer}>
<p css={tw`text-xs uppercase`}> <p css={tw`text-xs uppercase`}>
<FontAwesomeIcon <FontAwesomeIcon
icon={faCircle} icon={faCircle}
@ -90,7 +89,7 @@ export default () => {
<span css={tw`text-neutral-500`}> / {disklimit}</span> <span css={tw`text-neutral-500`}> / {disklimit}</span>
</p> </p>
</TitledGreyBox> </TitledGreyBox>
{!server.isInstalling ? {!isInstalling ?
<Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny> <Can action={[ 'control.start', 'control.stop', 'control.restart' ]} matchAny>
<div css={tw`shadow-md bg-neutral-700 rounded p-3 flex text-xs mt-4 justify-center`}> <div css={tw`shadow-md bg-neutral-700 rounded p-3 flex text-xs mt-4 justify-center`}>
<Can action={'control.start'}> <Can action={'control.start'}>
@ -143,6 +142,6 @@ export default () => {
<ChunkedStatGraphs/> <ChunkedStatGraphs/>
</SuspenseSpinner> </SuspenseSpinner>
</div> </div>
</PageContentBlock> </ServerContentBlock>
); );
}; };

View file

@ -1,7 +1,8 @@
import React, { useEffect, useState } from 'react'; import React, { memo, useEffect, useState } from 'react';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { PowerAction } from '@/components/server/ServerConsole'; import { PowerAction } from '@/components/server/ServerConsole';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import isEqual from 'react-fast-compare';
const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => { const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void }) => {
const [ clicked, setClicked ] = useState(false); const [ clicked, setClicked ] = useState(false);
@ -27,4 +28,4 @@ const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void
); );
}; };
export default StopOrKillButton; export default memo(StopOrKillButton, isEqual);

View file

@ -1,77 +1,68 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import getServerBackups from '@/api/server/backups/getServerBackups';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import CreateBackupButton from '@/components/server/backups/CreateBackupButton'; import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import BackupRow from '@/components/server/backups/BackupRow'; import BackupRow from '@/components/server/backups/BackupRow';
import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerContext } from '@/state/server';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
export default () => { export default () => {
const { uuid, featureLimits, name: serverName } = useServer(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const { addError, clearFlashes } = useFlash(); const { data: backups, error, isValidating } = getServerBackups();
const [ loading, setLoading ] = useState(true);
const backups = ServerContext.useStoreState(state => state.backups.data); const backupLimit = ServerContext.useStoreState(state => state.server.data!.featureLimits.backups);
const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups);
useEffect(() => { useEffect(() => {
clearFlashes('backups'); if (!error) {
getServerBackups(uuid) clearFlashes('backups');
.then(data => setBackups(data.items))
.catch(error => {
console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) });
})
.then(() => setLoading(false));
}, []);
if (backups.length === 0 && loading) { return;
}
clearAndAddHttpError({ error, key: 'backups' });
}, [ error ]);
if (!backups || (error && isValidating)) {
return <Spinner size={'large'} centered/>; return <Spinner size={'large'} centered/>;
} }
return ( return (
<PageContentBlock> <ServerContentBlock title={'Backups'}>
<Helmet>
<title> {serverName} | Backups</title>
</Helmet>
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/> <FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
{!backups.length ? {!backups.items.length ?
<p css={tw`text-center text-sm text-neutral-400`}> <p css={tw`text-center text-sm text-neutral-400`}>
There are no backups stored for this server. There are no backups stored for this server.
</p> </p>
: :
<div> <div>
{backups.map((backup, index) => <BackupRow {backups.items.map((backup, index) => <BackupRow
key={backup.uuid} key={backup.uuid}
backup={backup} backup={backup}
css={index > 0 ? tw`mt-2` : undefined} css={index > 0 ? tw`mt-2` : undefined}
/>)} />)}
</div> </div>
} }
{featureLimits.backups === 0 && {backupLimit === 0 &&
<p css={tw`text-center text-sm text-neutral-400`}> <p css={tw`text-center text-sm text-neutral-400`}>
Backups cannot be created for this server. Backups cannot be created for this server.
</p> </p>
} }
<Can action={'backup.create'}> <Can action={'backup.create'}>
{(featureLimits.backups > 0 && backups.length > 0) && {(backupLimit > 0 && backups.items.length > 0) &&
<p css={tw`text-center text-xs text-neutral-400 mt-2`}> <p css={tw`text-center text-xs text-neutral-400 mt-2`}>
{backups.length} of {featureLimits.backups} backups have been created for this server. {backups.items.length} of {backupLimit} backups have been created for this server.
</p> </p>
} }
{featureLimits.backups > 0 && featureLimits.backups !== backups.length && {backupLimit > 0 && backupLimit !== backups.items.length &&
<div css={tw`mt-6 flex justify-end`}> <div css={tw`mt-6 flex justify-end`}>
<CreateBackupButton/> <CreateBackupButton/>
</div> </div>
} }
</Can> </Can>
</PageContentBlock> </ServerContentBlock>
); );
}; };

View file

@ -1,31 +1,30 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu'; import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl'; import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import { httpErrorToHuman } from '@/api/http';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import ChecksumModal from '@/components/server/backups/ChecksumModal'; import ChecksumModal from '@/components/server/backups/ChecksumModal';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useServer from '@/plugins/useServer';
import deleteBackup from '@/api/server/backups/deleteBackup'; import deleteBackup from '@/api/server/backups/deleteBackup';
import { ServerContext } from '@/state/server';
import ConfirmationModal from '@/components/elements/ConfirmationModal'; import ConfirmationModal from '@/components/elements/ConfirmationModal';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import tw from 'twin.macro'; import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types';
import { ServerContext } from '@/state/server';
interface Props { interface Props {
backup: ServerBackup; backup: ServerBackup;
} }
export default ({ backup }: Props) => { export default ({ backup }: Props) => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const [ loading, setLoading ] = useState(false); const [ loading, setLoading ] = useState(false);
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const [ deleteVisible, setDeleteVisible ] = useState(false); const [ deleteVisible, setDeleteVisible ] = useState(false);
const { addError, clearFlashes } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const removeBackup = ServerContext.useStoreActions(actions => actions.backups.removeBackup); const { mutate } = getServerBackups();
const doDownload = () => { const doDownload = () => {
setLoading(true); setLoading(true);
@ -37,7 +36,7 @@ export default ({ backup }: Props) => {
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) }); clearAndAddHttpError({ key: 'backups', error });
}) })
.then(() => setLoading(false)); .then(() => setLoading(false));
}; };
@ -46,10 +45,15 @@ export default ({ backup }: Props) => {
setLoading(true); setLoading(true);
clearFlashes('backups'); clearFlashes('backups');
deleteBackup(uuid, backup.uuid) deleteBackup(uuid, backup.uuid)
.then(() => removeBackup(backup.uuid)) .then(() => {
mutate(data => ({
...data,
items: data.items.filter(b => b.uuid !== backup.uuid),
}), false);
})
.catch(error => { .catch(error => {
console.error(error); console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) }); clearAndAddHttpError({ key: 'backups', error });
setLoading(false); setLoading(false);
setDeleteVisible(false); setDeleteVisible(false);
}); });
@ -62,51 +66,58 @@ export default ({ backup }: Props) => {
appear appear
visible={visible} visible={visible}
onDismissed={() => setVisible(false)} onDismissed={() => setVisible(false)}
checksum={backup.sha256Hash} checksum={backup.checksum}
/> />
} }
{deleteVisible &&
<ConfirmationModal <ConfirmationModal
visible={deleteVisible}
title={'Delete this backup?'} title={'Delete this backup?'}
buttonText={'Yes, delete backup'} buttonText={'Yes, delete backup'}
onConfirmed={() => doDeletion()} onConfirmed={() => doDeletion()}
visible={deleteVisible} onModalDismissed={() => setDeleteVisible(false)}
onDismissed={() => setDeleteVisible(false)}
> >
Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot
be recovered once deleted. be recovered once deleted.
</ConfirmationModal> </ConfirmationModal>
}
<SpinnerOverlay visible={loading} fixed/> <SpinnerOverlay visible={loading} fixed/>
<DropdownMenu {backup.isSuccessful ?
renderToggle={onClick => ( <DropdownMenu
<button renderToggle={onClick => (
onClick={onClick} <button
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`} onClick={onClick}
> css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
<FontAwesomeIcon icon={faEllipsisH}/> >
</button> <FontAwesomeIcon icon={faEllipsisH}/>
)} </button>
> )}
<div css={tw`text-sm`}> >
<Can action={'backup.download'}> <div css={tw`text-sm`}>
<DropdownButtonRow onClick={() => doDownload()}> <Can action={'backup.download'}>
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/> <DropdownButtonRow onClick={() => doDownload()}>
<span css={tw`ml-2`}>Download</span> <FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Download</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setVisible(true)}>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow> </DropdownButtonRow>
</Can> <Can action={'backup.delete'}>
<DropdownButtonRow onClick={() => setVisible(true)}> <DropdownButtonRow danger onClick={() => setDeleteVisible(true)}>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/> <FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span> <span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow> </DropdownButtonRow>
<Can action={'backup.delete'}> </Can>
<DropdownButtonRow danger onClick={() => setDeleteVisible(true)}> </div>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/> </DropdownMenu>
<span css={tw`ml-2`}>Delete</span> :
</DropdownButtonRow> <button
</Can> onClick={() => setDeleteVisible(true)}
</div> css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
</DropdownMenu> >
<FontAwesomeIcon icon={faTrashAlt}/>
</button>
}
</> </>
); );
}; };

View file

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { ServerBackup } from '@/api/server/backups/getServerBackups';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons'; import { faArchive, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
import { format, formatDistanceToNow } from 'date-fns'; import { format, formatDistanceToNow } from 'date-fns';
@ -7,10 +6,11 @@ import Spinner from '@/components/elements/Spinner';
import { bytesToHuman } from '@/helpers'; import { bytesToHuman } from '@/helpers';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import useWebsocketEvent from '@/plugins/useWebsocketEvent'; import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import BackupContextMenu from '@/components/server/backups/BackupContextMenu'; import BackupContextMenu from '@/components/server/backups/BackupContextMenu';
import tw from 'twin.macro'; import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox'; import GreyRowBox from '@/components/elements/GreyRowBox';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types';
interface Props { interface Props {
backup: ServerBackup; backup: ServerBackup;
@ -18,17 +18,22 @@ interface Props {
} }
export default ({ backup, className }: Props) => { export default ({ backup, className }: Props) => {
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup); const { mutate } = getServerBackups();
useWebsocketEvent(`backup completed:${backup.uuid}`, data => { useWebsocketEvent(`backup completed:${backup.uuid}`, data => {
try { try {
const parsed = JSON.parse(data); const parsed = JSON.parse(data);
appendBackup({
...backup, mutate(data => ({
sha256Hash: parsed.sha256_hash || '', ...data,
bytes: parsed.file_size || 0, items: data.items.map(b => b.uuid !== backup.uuid ? b : ({
completedAt: new Date(), ...b,
}); isSuccessful: parsed.is_successful || true,
checksum: parsed.checksum || '',
bytes: parsed.file_size || 0,
completedAt: new Date(),
})),
}), false);
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
} }
@ -45,8 +50,13 @@ export default ({ backup, className }: Props) => {
</div> </div>
<div css={tw`flex-1`}> <div css={tw`flex-1`}>
<p css={tw`text-sm mb-1`}> <p css={tw`text-sm mb-1`}>
{!backup.isSuccessful &&
<span css={tw`bg-red-500 py-px px-2 rounded-full text-white text-xs uppercase border border-red-600 mr-2`}>
Failed
</span>
}
{backup.name} {backup.name}
{backup.completedAt && {(backup.completedAt && backup.isSuccessful) &&
<span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span> <span css={tw`ml-3 text-neutral-300 text-xs font-thin`}>{bytesToHuman(backup.bytes)}</span>
} }
</p> </p>

View file

@ -6,7 +6,7 @@ const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum:
<Modal {...props}> <Modal {...props}>
<h3 css={tw`mb-6`}>Verify file checksum</h3> <h3 css={tw`mb-6`}>Verify file checksum</h3>
<p css={tw`text-sm`}> <p css={tw`text-sm`}>
The SHA256 checksum of this file is: The checksum of this file is:
</p> </p>
<pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}> <pre css={tw`mt-2 text-sm p-2 bg-neutral-900 rounded`}>
<code css={tw`block font-mono`}>{checksum}</code> <code css={tw`block font-mono`}>{checksum}</code>

View file

@ -5,14 +5,13 @@ import { object, string } from 'yup';
import Field from '@/components/elements/Field'; import Field from '@/components/elements/Field';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import createServerBackup from '@/api/server/backups/createServerBackup'; import createServerBackup from '@/api/server/backups/createServerBackup';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { ServerContext } from '@/state/server';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { Textarea } from '@/components/elements/Input'; import { Textarea } from '@/components/elements/Input';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerContext } from '@/state/server';
interface Values { interface Values {
name: string; name: string;
@ -59,11 +58,10 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
}; };
export default () => { export default () => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { addError, clearFlashes } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const { mutate } = getServerBackups();
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup);
useEffect(() => { useEffect(() => {
clearFlashes('backups:create'); clearFlashes('backups:create');
@ -73,12 +71,11 @@ export default () => {
clearFlashes('backups:create'); clearFlashes('backups:create');
createServerBackup(uuid, name, ignored) createServerBackup(uuid, name, ignored)
.then(backup => { .then(backup => {
appendBackup(backup); mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
setVisible(false); setVisible(false);
}) })
.catch(error => { .catch(error => {
console.error(error); clearAndAddHttpError({ key: 'backups:create', error });
addError({ key: 'backups:create', message: httpErrorToHuman(error) });
setSubmitting(false); setSubmitting(false);
}); });
}; };

View file

@ -8,7 +8,6 @@ import { ServerContext } from '@/state/server';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import tw from 'twin.macro'; import tw from 'twin.macro';
@ -29,7 +28,7 @@ const schema = object().shape({
}); });
export default () => { export default () => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { addError, clearFlashes } = useFlash(); const { addError, clearFlashes } = useFlash();
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);

View file

@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 Modal from '@/components/elements/Modal';
import { Form, Formik, FormikHelpers } from 'formik'; import { Form, Formik, FormikHelpers } from 'formik';
import Field from '@/components/elements/Field'; import Field from '@/components/elements/Field';
@ -12,7 +12,6 @@ import { httpErrorToHuman } from '@/api/http';
import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton'; import RotatePasswordButton from '@/components/server/databases/RotatePasswordButton';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import { ServerDatabase } from '@/api/server/getServerDatabases'; import { ServerDatabase } from '@/api/server/getServerDatabases';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
@ -26,7 +25,7 @@ interface Props {
} }
export default ({ database, className }: Props) => { export default ({ database, className }: Props) => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { addError, clearFlashes } = useFlash(); const { addError, clearFlashes } = useFlash();
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const [ connectionVisible, setConnectionVisible ] = useState(false); const [ connectionVisible, setConnectionVisible ] = useState(false);

View file

@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import getServerDatabases from '@/api/server/getServerDatabases'; import getServerDatabases from '@/api/server/getServerDatabases';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
@ -9,17 +8,19 @@ import Spinner from '@/components/elements/Spinner';
import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton'; import CreateDatabaseButton from '@/components/server/databases/CreateDatabaseButton';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Fade from '@/components/elements/Fade'; import Fade from '@/components/elements/Fade';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
import { useDeepMemoize } from '@/plugins/useDeepMemoize';
export default () => { 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 { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true); 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); const setDatabases = ServerContext.useStoreActions(state => state.databases.setDatabases);
useEffect(() => { useEffect(() => {
@ -36,10 +37,7 @@ export default () => {
}, []); }, []);
return ( return (
<PageContentBlock> <ServerContentBlock title={'Databases'}>
<Helmet>
<title> {serverName} | Databases </title>
</Helmet>
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/> <FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
{(!databases.length && loading) ? {(!databases.length && loading) ?
<Spinner size={'large'} centered/> <Spinner size={'large'} centered/>
@ -56,7 +54,7 @@ export default () => {
)) ))
: :
<p css={tw`text-center text-sm text-neutral-400`}> <p css={tw`text-center text-sm text-neutral-400`}>
{featureLimits.databases > 0 ? {databaseLimit > 0 ?
'It looks like you have no databases.' 'It looks like you have no databases.'
: :
'Databases cannot be created for this server.' 'Databases cannot be created for this server.'
@ -64,13 +62,13 @@ export default () => {
</p> </p>
} }
<Can action={'database.create'}> <Can action={'database.create'}>
{(featureLimits.databases > 0 && databases.length > 0) && {(databaseLimit > 0 && databases.length > 0) &&
<p css={tw`text-center text-xs text-neutral-400 mt-2`}> <p css={tw`text-center text-xs text-neutral-400 mt-2`}>
{databases.length} of {featureLimits.databases} databases have been allocated to this {databases.length} of {databaseLimit} databases have been allocated to this
server. server.
</p> </p>
} }
{featureLimits.databases > 0 && featureLimits.databases !== databases.length && {databaseLimit > 0 && databaseLimit !== databases.length &&
<div css={tw`mt-6 flex justify-end`}> <div css={tw`mt-6 flex justify-end`}>
<CreateDatabaseButton/> <CreateDatabaseButton/>
</div> </div>
@ -79,6 +77,6 @@ export default () => {
</> </>
</Fade> </Fade>
} }
</PageContentBlock> </ServerContentBlock>
); );
}; };

View file

@ -19,7 +19,6 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import copyFile from '@/api/server/files/copyFile'; import copyFile from '@/api/server/files/copyFile';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl'; import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { FileObject } from '@/api/server/files/loadDirectory'; import { FileObject } from '@/api/server/files/loadDirectory';
@ -56,7 +55,7 @@ const FileDropdownMenu = ({ file }: { file: FileObject }) => {
const [ showSpinner, setShowSpinner ] = useState(false); const [ showSpinner, setShowSpinner ] = useState(false);
const [ modal, setModal ] = useState<ModalType | null>(null); const [ modal, setModal ] = useState<ModalType | null>(null);
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { mutate } = useFileManagerSwr(); const { mutate } = useFileManagerSwr();
const { clearAndAddHttpError, clearFlashes } = useFlash(); const { clearAndAddHttpError, clearFlashes } = useFlash();
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);

View file

@ -1,8 +1,5 @@
import React, { lazy, useEffect, useState } from 'react'; import React, { lazy, useEffect, useState } from 'react';
import { ServerContext } from '@/state/server';
import getFileContents from '@/api/server/files/getFileContents'; import getFileContents from '@/api/server/files/getFileContents';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import saveFileContents from '@/api/server/files/saveFileContents'; import saveFileContents from '@/api/server/files/saveFileContents';
@ -15,6 +12,10 @@ import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError'; import ServerError from '@/components/screens/ServerError';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; 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')); const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor'));
@ -24,28 +25,30 @@ export default () => {
const [ loading, setLoading ] = useState(action === 'edit'); const [ loading, setLoading ] = useState(action === 'edit');
const [ content, setContent ] = useState(''); const [ content, setContent ] = useState('');
const [ modalVisible, setModalVisible ] = useState(false); const [ modalVisible, setModalVisible ] = useState(false);
const [ mode, setMode ] = useState('plain_text');
const history = useHistory(); const history = useHistory();
const { hash } = useLocation(); const { hash } = useLocation();
const { id, uuid } = ServerContext.useStoreState(state => state.server.data!); const id = ServerContext.useStoreState(state => state.server.data!.id);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { addError, clearFlashes } = useFlash();
let fetchFileContent: null | (() => Promise<string>) = null; let fetchFileContent: null | (() => Promise<string>) = null;
if (action !== 'new') { useEffect(() => {
useEffect(() => { if (action === 'new') return;
setLoading(true);
setError(''); setLoading(true);
getFileContents(uuid, hash.replace(/^#/, '')) setError('');
.then(setContent) getFileContents(uuid, hash.replace(/^#/, ''))
.catch(error => { .then(setContent)
console.error(error); .catch(error => {
setError(httpErrorToHuman(error)); console.error(error);
}) setError(httpErrorToHuman(error));
.then(() => setLoading(false)); })
}, [ uuid, hash ]); .then(() => setLoading(false));
} }, [ action, uuid, hash ]);
const save = (name?: string) => { const save = (name?: string) => {
if (!fetchFileContent) { if (!fetchFileContent) {
@ -75,10 +78,7 @@ export default () => {
if (error) { if (error) {
return ( return (
<ServerError <ServerError message={error} onBack={() => history.goBack()}/>
message={error}
onBack={() => history.goBack()}
/>
); );
} }
@ -109,15 +109,24 @@ export default () => {
<div css={tw`relative`}> <div css={tw`relative`}>
<SpinnerOverlay visible={loading}/> <SpinnerOverlay visible={loading}/>
<LazyAceEditor <LazyAceEditor
initialModePath={hash.replace(/^#/, '') || 'plain_text'} mode={mode}
filename={hash.replace(/^#/, '')}
onModeChanged={setMode}
initialContent={content} initialContent={content}
fetchContent={value => { fetchContent={value => {
fetchFileContent = value; fetchFileContent = value;
}} }}
onContentSaved={() => save()} onContentSaved={save}
/> />
</div> </div>
<div css={tw`flex justify-end mt-4`}> <div css={tw`flex justify-end mt-4`}>
<div css={tw`rounded bg-neutral-900 mr-4`}>
<Select value={mode} onChange={e => setMode(e.currentTarget.value)}>
{Object.keys(modes).map(key => (
<option key={key} value={key}>{modes[key]}</option>
))}
</Select>
</div>
{action === 'edit' ? {action === 'edit' ?
<Can action={'file.update'}> <Can action={'file.update'}>
<Button onClick={() => save()}> <Button onClick={() => save()}>

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { NavLink } from 'react-router-dom'; import { NavLink, useRouteMatch } from 'react-router-dom';
import { cleanDirectoryPath } from '@/helpers'; import { cleanDirectoryPath } from '@/helpers';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox'; import { FileActionCheckbox } from '@/components/server/files/SelectFileCheckbox';
@ -13,6 +13,7 @@ interface Props {
export default ({ withinFileEditor, isNewFile }: Props) => { export default ({ withinFileEditor, isNewFile }: Props) => {
const [ file, setFile ] = useState<string | null>(null); const [ file, setFile ] = useState<string | null>(null);
const { params } = useRouteMatch<Record<string, string>>();
const id = ServerContext.useStoreState(state => state.server.data!.id); const id = ServerContext.useStoreState(state => state.server.data!.id);
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);
@ -44,7 +45,7 @@ export default ({ withinFileEditor, isNewFile }: Props) => {
return ( return (
<div css={tw`flex items-center text-sm mb-4 text-neutral-500`}> <div css={tw`flex items-center text-sm mb-4 text-neutral-500`}>
{!!(files && files.length) && {(files && files.length > 0 && !params?.action) &&
<FileActionCheckbox <FileActionCheckbox
type={'checkbox'} type={'checkbox'}
css={tw`mx-4`} css={tw`mx-4`}

View file

@ -1,5 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
@ -9,14 +8,14 @@ import { FileObject } from '@/api/server/files/loadDirectory';
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton'; import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError'; import ServerError from '@/components/screens/ServerError';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import useServer from '@/plugins/useServer';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import MassActionsBar from '@/components/server/files/MassActionsBar'; import MassActionsBar from '@/components/server/files/MassActionsBar';
import UploadButton from '@/components/server/files/UploadButton';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
const sortFiles = (files: FileObject[]): FileObject[] => { const sortFiles = (files: FileObject[]): FileObject[] => {
return files.sort((a, b) => a.name.localeCompare(b.name)) return files.sort((a, b) => a.name.localeCompare(b.name))
@ -24,7 +23,7 @@ const sortFiles = (files: FileObject[]): FileObject[] => {
}; };
export default () => { export default () => {
const { id, name: serverName } = useServer(); const id = ServerContext.useStoreState(state => state.server.data!.id);
const { hash } = useLocation(); const { hash } = useLocation();
const { data: files, error, mutate } = useFileManagerSwr(); const { data: files, error, mutate } = useFileManagerSwr();
@ -43,10 +42,7 @@ export default () => {
} }
return ( return (
<PageContentBlock showFlashKey={'files'}> <ServerContentBlock title={'File Manager'} showFlashKey={'files'}>
<Helmet>
<title> {serverName} | File Manager </title>
</Helmet>
<FileManagerBreadcrumbs/> <FileManagerBreadcrumbs/>
{ {
!files ? !files ?
@ -80,6 +76,7 @@ export default () => {
<Can action={'file.create'}> <Can action={'file.create'}>
<div css={tw`flex justify-end mt-8`}> <div css={tw`flex justify-end mt-8`}>
<NewDirectoryButton/> <NewDirectoryButton/>
<UploadButton/>
<Button <Button
// @ts-ignore // @ts-ignore
as={Link} as={Link}
@ -91,6 +88,6 @@ export default () => {
</Can> </Can>
</> </>
} }
</PageContentBlock> </ServerContentBlock>
); );
}; };

View file

@ -8,14 +8,14 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import compressFiles from '@/api/server/files/compressFiles'; import compressFiles from '@/api/server/files/compressFiles';
import useServer from '@/plugins/useServer';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import ConfirmationModal from '@/components/elements/ConfirmationModal'; import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteFiles from '@/api/server/files/deleteFiles'; import deleteFiles from '@/api/server/files/deleteFiles';
import RenameFileModal from '@/components/server/files/RenameFileModal'; import RenameFileModal from '@/components/server/files/RenameFileModal';
const MassActionsBar = () => { const MassActionsBar = () => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { mutate } = useFileManagerSwr(); const { mutate } = useFileManagerSwr();
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ loading, setLoading ] = useState(false); const [ loading, setLoading ] = useState(false);
@ -72,7 +72,7 @@ const MassActionsBar = () => {
title={'Delete these files?'} title={'Delete these files?'}
buttonText={'Yes, Delete Files'} buttonText={'Yes, Delete Files'}
onConfirmed={onClickConfirmDeletion} onConfirmed={onClickConfirmDeletion}
onDismissed={() => setShowConfirm(false)} onModalDismissed={() => setShowConfirm(false)}
> >
Deleting files is a permanent operation, you cannot undo this action. Deleting files is a permanent operation, you cannot undo this action.
</ConfirmationModal> </ConfirmationModal>

View file

@ -8,7 +8,6 @@ import { object, string } from 'yup';
import createDirectory from '@/api/server/files/createDirectory'; import createDirectory from '@/api/server/files/createDirectory';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import useServer from '@/plugins/useServer';
import { FileObject } from '@/api/server/files/loadDirectory'; import { FileObject } from '@/api/server/files/loadDirectory';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFileManagerSwr from '@/plugins/useFileManagerSwr';
@ -36,7 +35,7 @@ const generateDirectoryData = (name: string): FileObject => ({
}); });
export default () => { export default () => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearAndAddHttpError } = useFlash(); const { clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);

View file

@ -7,7 +7,6 @@ import renameFiles from '@/api/server/files/renameFiles';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import useServer from '@/plugins/useServer';
import useFileManagerSwr from '@/plugins/useFileManagerSwr'; import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
@ -18,7 +17,7 @@ interface FormikValues {
type OwnProps = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean }; type OwnProps = RequiredModalProps & { files: string[]; useMoveTerminology?: boolean };
const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => { const RenameFileModal = ({ files, useMoveTerminology, ...props }: OwnProps) => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { mutate } = useFileManagerSwr(); const { mutate } = useFileManagerSwr();
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);

View file

@ -0,0 +1,103 @@
import axios from 'axios';
import getFileUploadUrl from '@/api/server/files/getFileUploadUrl';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components/macro';
import { ModalMask } from '@/components/elements/Modal';
import Fade from '@/components/elements/Fade';
import useEventListener from '@/plugins/useEventListener';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
import { ServerContext } from '@/state/server';
const InnerContainer = styled.div`
max-width: 600px;
${tw`bg-black w-full border-4 border-primary-500 border-dashed rounded p-10 mx-10`}
`;
export default () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const [ visible, setVisible ] = useState(false);
const [ loading, setLoading ] = useState(false);
const { mutate } = useFileManagerSwr();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const directory = ServerContext.useStoreState(state => state.files.directory);
useEventListener('dragenter', e => {
e.stopPropagation();
setVisible(true);
}, true);
useEventListener('dragexit', e => {
e.stopPropagation();
setVisible(false);
}, true);
useEffect(() => {
if (!visible) return;
const hide = () => setVisible(false);
window.addEventListener('keydown', hide);
return () => {
window.removeEventListener('keydown', hide);
};
}, [ visible ]);
const onFileDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setVisible(false);
if (e.dataTransfer === undefined || e.dataTransfer === null) {
return;
}
const form = new FormData();
Array.from(e.dataTransfer.files).forEach(file => form.append('files', file));
setLoading(true);
clearFlashes('files');
getFileUploadUrl(uuid)
.then(url => axios.post(`${url}&directory=${directory}`, form, {
headers: {
'Content-Type': 'multipart/form-data',
},
}))
.then(() => mutate())
.catch(error => {
console.error(error);
clearAndAddHttpError({ error, key: 'files' });
})
.then(() => setVisible(false))
.then(() => setLoading(false));
};
return (
<>
<Fade
appear
in={visible}
timeout={75}
key={'upload_modal_mask'}
unmountOnExit
>
<ModalMask onClick={() => setVisible(false)} onDrop={onFileDrop} onDragOver={e => e.preventDefault()}>
<div css={tw`w-full flex items-center justify-center`} style={{ pointerEvents: 'none' }}>
<InnerContainer>
<p css={tw`text-lg text-neutral-200 text-center`}>
Drag and drop files to upload.
</p>
</InnerContainer>
</div>
</ModalMask>
</Fade>
<SpinnerOverlay visible={loading} size={'large'}/>
<Button css={tw`mr-2`} onClick={() => setVisible(true)}>
Upload
</Button>
</>
);
};

View file

@ -1,14 +1,11 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faNetworkWired } from '@fortawesome/free-solid-svg-icons'; import { faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import PageContentBlock from '@/components/elements/PageContentBlock';
import GreyRowBox from '@/components/elements/GreyRowBox'; import GreyRowBox from '@/components/elements/GreyRowBox';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useSWR from 'swr'; import useSWR from 'swr';
import getServerAllocations from '@/api/server/network/getServerAllocations'; import getServerAllocations from '@/api/server/network/getServerAllocations';
import { Allocation } from '@/api/server/getServer'; import { Allocation } from '@/api/server/getServer';
@ -19,12 +16,17 @@ import { Textarea } from '@/components/elements/Input';
import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes'; import setServerAllocationNotes from '@/api/server/network/setServerAllocationNotes';
import { debounce } from 'debounce'; import { debounce } from 'debounce';
import InputSpinner from '@/components/elements/InputSpinner'; 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 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 Label = styled.label`${tw`uppercase text-xs mt-1 text-neutral-400 block px-1 select-none transition-colors duration-150`}`;
const NetworkContainer = () => { 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 { clearFlashes, clearAndAddHttpError } = useFlash();
const [ loading, setLoading ] = useState<false | number>(false); const [ loading, setLoading ] = useState<false | number>(false);
const { data, error, mutate } = useSWR<Allocation[]>(uuid, key => getServerAllocations(key), { initialData: allocations }); const { data, error, mutate } = useSWR<Allocation[]>(uuid, key => getServerAllocations(key), { initialData: allocations });
@ -61,10 +63,7 @@ const NetworkContainer = () => {
}, [ error ]); }, [ error ]);
return ( return (
<PageContentBlock showFlashKey={'server:network'}> <ServerContentBlock showFlashKey={'server:network'} title={'Network'}>
<Helmet>
<title> {serverName} | Network </title>
</Helmet>
{!data ? {!data ?
<Spinner size={'large'} centered/> <Spinner size={'large'} centered/>
: :
@ -112,7 +111,7 @@ const NetworkContainer = () => {
</GreyRowBox> </GreyRowBox>
)) ))
} }
</PageContentBlock> </ServerContentBlock>
); );
}; };

View file

@ -39,12 +39,12 @@ export default ({ scheduleId, onDeleted }: Props) => {
return ( return (
<> <>
<ConfirmationModal <ConfirmationModal
showSpinnerOverlay={isLoading} visible={visible}
title={'Delete schedule?'} title={'Delete schedule?'}
buttonText={'Yes, delete schedule'} buttonText={'Yes, delete schedule'}
onConfirmed={onDelete} onConfirmed={onDelete}
visible={visible} showSpinnerOverlay={isLoading}
onDismissed={() => setVisible(false)} onModalDismissed={() => setVisible(false)}
> >
Are you sure you want to delete this schedule? All tasks will be removed and any running processes Are you sure you want to delete this schedule? All tasks will be removed and any running processes
will be terminated. will be terminated.

View file

@ -8,7 +8,6 @@ import createOrUpdateSchedule from '@/api/server/schedules/createOrUpdateSchedul
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
@ -75,7 +74,7 @@ const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdate
}; };
export default ({ schedule, visible, ...props }: Props) => { export default ({ schedule, visible, ...props }: Props) => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { addError, clearFlashes } = useFlash(); const { addError, clearFlashes } = useFlash();
const [ modalVisible, setModalVisible ] = useState(visible); const [ modalVisible, setModalVisible ] = useState(visible);

View file

@ -1,5 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import getServerSchedules from '@/api/server/schedules/getServerSchedules'; import getServerSchedules from '@/api/server/schedules/getServerSchedules';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
@ -9,15 +8,14 @@ import ScheduleRow from '@/components/server/schedules/ScheduleRow';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import EditScheduleModal from '@/components/server/schedules/EditScheduleModal'; import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox'; import GreyRowBox from '@/components/elements/GreyRowBox';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
export default ({ match, history }: RouteComponentProps) => { export default ({ match, history }: RouteComponentProps) => {
const { uuid, name: serverName } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useFlash(); const { clearFlashes, addError } = useFlash();
const [ loading, setLoading ] = useState(true); const [ loading, setLoading ] = useState(true);
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
@ -37,10 +35,7 @@ export default ({ match, history }: RouteComponentProps) => {
}, []); }, []);
return ( return (
<PageContentBlock> <ServerContentBlock title={'Schedules'}>
<Helmet>
<title> {serverName} | Schedules </title>
</Helmet>
<FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/> <FlashMessageRender byKey={'schedules'} css={tw`mb-4`}/>
{(!schedules.length && loading) ? {(!schedules.length && loading) ?
<Spinner size={'large'} centered/> <Spinner size={'large'} centered/>
@ -77,6 +72,6 @@ export default ({ match, history }: RouteComponentProps) => {
</Can> </Can>
</> </>
} }
</PageContentBlock> </ServerContentBlock>
); );
}; };

View file

@ -11,7 +11,6 @@ import EditScheduleModal from '@/components/server/schedules/EditScheduleModal';
import NewTaskButton from '@/components/server/schedules/NewTaskButton'; import NewTaskButton from '@/components/server/schedules/NewTaskButton';
import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton'; import DeleteScheduleButton from '@/components/server/schedules/DeleteScheduleButton';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock'; import PageContentBlock from '@/components/elements/PageContentBlock';
@ -28,7 +27,9 @@ interface State {
} }
export default ({ match, history, location: { state } }: RouteComponentProps<Params, Record<string, unknown>, State>) => { export default ({ match, history, location: { state } }: RouteComponentProps<Params, Record<string, unknown>, 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 { clearFlashes, addError } = useFlash();
const [ isLoading, setIsLoading ] = useState(true); const [ isLoading, setIsLoading ] = useState(true);
const [ showEditModal, setShowEditModal ] = useState(false); const [ showEditModal, setShowEditModal ] = useState(false);

View file

@ -7,7 +7,6 @@ import { httpErrorToHuman } from '@/api/http';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal'; import TaskDetailsModal from '@/components/server/schedules/TaskDetailsModal';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import tw from 'twin.macro'; import tw from 'twin.macro';
@ -32,7 +31,7 @@ const getActionDetails = (action: string): [ string, any ] => {
}; };
export default ({ schedule, task }: Props) => { export default ({ schedule, task }: Props) => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useFlash(); const { clearFlashes, addError } = useFlash();
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false); const [ isLoading, setIsLoading ] = useState(false);
@ -69,7 +68,7 @@ export default ({ schedule, task }: Props) => {
buttonText={'Delete Task'} buttonText={'Delete Task'}
onConfirmed={onConfirmDeletion} onConfirmed={onConfirmDeletion}
visible={visible} visible={visible}
onDismissed={() => setVisible(false)} onModalDismissed={() => setVisible(false)}
> >
Are you sure you want to delete this task? This action cannot be undone. Are you sure you want to delete this task? This action cannot be undone.
</ConfirmationModal> </ConfirmationModal>

View file

@ -9,7 +9,6 @@ import Field from '@/components/elements/Field';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { number, object, string } from 'yup'; import { number, object, string } from 'yup';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper'; import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Label from '@/components/elements/Label'; import Label from '@/components/elements/Label';
@ -108,7 +107,7 @@ const TaskDetailsForm = ({ isEditingTask }: { isEditingTask: boolean }) => {
}; };
export default ({ task, schedule, onDismissed }: Props) => { export default ({ task, schedule, onDismissed }: Props) => {
const { uuid } = useServer(); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const { clearFlashes, addError } = useFlash(); const { clearFlashes, addError } = useFlash();
const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule); const appendSchedule = ServerContext.useStoreActions(actions => actions.schedules.appendSchedule);

View file

@ -46,10 +46,10 @@ export default () => {
<ConfirmationModal <ConfirmationModal
title={'Confirm server reinstallation'} title={'Confirm server reinstallation'}
buttonText={'Yes, reinstall server'} buttonText={'Yes, reinstall server'}
onConfirmed={() => reinstall()} onConfirmed={reinstall}
showSpinnerOverlay={isSubmitting} showSpinnerOverlay={isSubmitting}
visible={modalVisible} 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 Your server will be stopped and some files may be deleted or modified during this process, are you sure
you wish to continue? you wish to continue?

View file

@ -1,29 +1,25 @@
import React from 'react'; import React from 'react';
import { Helmet } from 'react-helmet';
import TitledGreyBox from '@/components/elements/TitledGreyBox'; import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { useStoreState } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { UserData } from '@/state/user';
import RenameServerBox from '@/components/server/settings/RenameServerBox'; import RenameServerBox from '@/components/server/settings/RenameServerBox';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import ReinstallServerBox from '@/components/server/settings/ReinstallServerBox'; import ReinstallServerBox from '@/components/server/settings/ReinstallServerBox';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Input from '@/components/elements/Input'; import Input from '@/components/elements/Input';
import Label from '@/components/elements/Label'; import Label from '@/components/elements/Label';
import { LinkButton } from '@/components/elements/Button'; import { LinkButton } from '@/components/elements/Button';
import ServerContentBlock from '@/components/elements/ServerContentBlock';
export default () => { export default () => {
const user = useStoreState<ApplicationStore, UserData>(state => state.user.data!); const username = useStoreState(state => state.user.data!.username);
const server = ServerContext.useStoreState(state => state.server.data!); 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 ( return (
<PageContentBlock> <ServerContentBlock title={'Settings'}>
<Helmet>
<title> {server.name} | Settings </title>
</Helmet>
<FlashMessageRender byKey={'settings'} css={tw`mb-4`}/> <FlashMessageRender byKey={'settings'} css={tw`mb-4`}/>
<div css={tw`md:flex`}> <div css={tw`md:flex`}>
<div css={tw`w-full md:flex-1 md:mr-10`}> <div css={tw`w-full md:flex-1 md:mr-10`}>
@ -33,7 +29,7 @@ export default () => {
<Label>Server Address</Label> <Label>Server Address</Label>
<Input <Input
type={'text'} type={'text'}
value={`sftp://${server.sftpDetails.ip}:${server.sftpDetails.port}`} value={`sftp://${sftpIp}:${sftpPort}`}
readOnly readOnly
/> />
</div> </div>
@ -41,7 +37,7 @@ export default () => {
<Label>Username</Label> <Label>Username</Label>
<Input <Input
type={'text'} type={'text'}
value={`${user.username}.${server.id}`} value={`${username}.${id}`}
readOnly readOnly
/> />
</div> </div>
@ -56,7 +52,7 @@ export default () => {
<div css={tw`ml-4`}> <div css={tw`ml-4`}>
<LinkButton <LinkButton
isSecondary isSecondary
href={`sftp://${user.username}.${server.id}@${server.sftpDetails.ip}:${server.sftpDetails.port}`} href={`sftp://${username}.${id}@${sftpIp}:${sftpPort}`}
> >
Launch SFTP Launch SFTP
</LinkButton> </LinkButton>
@ -76,6 +72,6 @@ export default () => {
</Can> </Can>
</div> </div>
</div> </div>
</PageContentBlock> </ServerContentBlock>
); );
}; };

View file

@ -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)) ?
<Spinner centered size={Spinner.Size.LARGE}/>
:
<ServerError
title={'Oops!'}
message={httpErrorToHuman(error)}
onRetry={() => mutate()}
/>
:
<ServerContentBlock title={'Startup Settings'}>
<TitledGreyBox title={'Startup Command'}>
<div css={tw`px-1 py-2`}>
<p css={tw`font-mono bg-neutral-900 rounded py-2 px-4`}>
{data.invocation}
</p>
</div>
</TitledGreyBox>
<div css={tw`grid gap-8 grid-cols-2 mt-10`}>
{data.variables.map(variable => <VariableBox key={variable.envVariable} variable={variable}/>)}
</div>
</ServerContentBlock>
);
};
export default StartupContainer;

View file

@ -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 (
<TitledGreyBox
title={
<p css={tw`text-sm uppercase`}>
{!variable.isEditable &&
<span css={tw`bg-neutral-700 text-xs py-1 px-2 rounded-full mr-2`}>Read Only</span>
}
{variable.name}
</p>
}
>
<FlashMessageRender byKey={FLASH_KEY} css={tw`mb-4`}/>
<InputSpinner visible={loading}>
<Input
onKeyUp={e => {
if (canEdit && variable.isEditable) {
setVariableValue(e.currentTarget.value);
}
}}
readOnly={!canEdit || !variable.isEditable}
name={variable.envVariable}
defaultValue={variable.serverValue}
placeholder={variable.defaultValue}
/>
</InputSpinner>
<p css={tw`mt-1 text-xs text-neutral-400`}>
{variable.description}
</p>
</TitledGreyBox>
);
};
export default memo(VariableBox, isEqual);

Some files were not shown because too many files have changed in this diff Show more