Merge branch 'develop' into feature/file-uploads

This commit is contained in:
Dane Everitt 2020-08-22 18:33:09 -07:00
commit 54f9c5f187
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
136 changed files with 2178 additions and 971 deletions

2
.github/FUNDING.yml vendored
View file

@ -1,2 +1,2 @@
#github: [DaneEveritt] github: [DaneEveritt]
custom: ["https://paypal.me/PterodactylSoftware"] custom: ["https://paypal.me/PterodactylSoftware"]

View file

@ -36,7 +36,7 @@ jobs:
- name: Create release archive - name: Create release archive
run: | run: |
rm -rf node_modules/ test/ codecov.yml CODE_OF_CONDUCT.md CONTRIBUTING.md phpunit.dusk.xml phpunit.xml Vagrantfile rm -rf node_modules/ test/ codecov.yml CODE_OF_CONDUCT.md CONTRIBUTING.md phpunit.dusk.xml phpunit.xml Vagrantfile
tar -czf panel.tar.gz * tar -czf panel.tar.gz * .env.example
- name: Extract changelog - name: Extract changelog
id: extract_changelog id: extract_changelog

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
@ -33,4 +33,4 @@ EXPOSE 80 443
ENTRYPOINT ["/bin/ash", "docker/entrypoint.sh"] ENTRYPOINT ["/bin/ash", "docker/entrypoint.sh"]
CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ] CMD [ "supervisord", "-n", "-c", "/etc/supervisord.conf" ]

View file

@ -12,6 +12,30 @@ What more are you waiting for? Make game servers a first class citizen on your p
![Image](https://cdn.pterodactyl.io/site-assets/mockup-macbook-grey.png) ![Image](https://cdn.pterodactyl.io/site-assets/mockup-macbook-grey.png)
## Sponsors
I would like to extend my sincere thanks to the following sponsors for funding Pterodactyl's developement. [Interested
in becoming a sponsor?](https://github.com/sponsors/DaneEveritt)
#### [BloomVPS](https://bloomvps.com)
> BloomVPS offers dedicated core VPS and Minecraft hosting with Ryzen 9 processors. With owned-hardware, we offer truly
> unbeatable prices on high-performance hosting.
#### [VersatileNode](https://versatilenode.com/)
> Looking to host a minecraft server, vps, or a website? VersatileNode is one of the most affordable hosting providers
> to provide quality yet cheap services with incredible support.
#### [MineStrator](https://minestrator.com/)
> Looking for a French highend hosting company for you minecraft server? More than 14,000 members on our discord
> trust us.
#### [DedicatedMC](https://dedicatedmc.io/)
> DedicatedMC provides Raw Power hosting at affordable pricing, making sure to never compromise on your performance
> and giving you the best performance money can buy.
#### [Skynode](https://www.skynode.pro/)
> Skynode provides blazing fast game servers along with a top notch user experience. Whatever our clients are looking
> for, we're able to provide it!
## Support & Documentation ## Support & 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).
@ -43,7 +67,7 @@ In addition to our standard nest of supported games, our community is constantly
## Credits ## Credits
This software would not be possible without the work of other open-source authors who provide tools such as: This software would not be possible without the work of other open-source authors who provide tools such as:
[Ace Editor](https://ace.c9.io), [AdminLTE](https://almsaeedstudio.com), [Animate.css](http://daneden.github.io/animate.css/), [AnsiUp](https://github.com/drudru/ansi_up), [Async.js](https://github.com/caolan/async), [Ace Editor](https://ace.c9.io), [AdminLTE](https://adminlte.io), [Animate.css](http://daneden.github.io/animate.css/), [AnsiUp](https://github.com/drudru/ansi_up), [Async.js](https://github.com/caolan/async),
[Bootstrap](http://getbootstrap.com), [Bootstrap Notify](http://bootstrap-notify.remabledesigns.com), [Chart.js](http://www.chartjs.org), [FontAwesome](http://fontawesome.io), [Bootstrap](http://getbootstrap.com), [Bootstrap Notify](http://bootstrap-notify.remabledesigns.com), [Chart.js](http://www.chartjs.org), [FontAwesome](http://fontawesome.io),
[FontAwesome Animations](https://github.com/l-lin/font-awesome-animation), [jQuery](http://jquery.com), [Laravel](https://laravel.com), [Lodash](https://lodash.com), [FontAwesome Animations](https://github.com/l-lin/font-awesome-animation), [jQuery](http://jquery.com), [Laravel](https://laravel.com), [Lodash](https://lodash.com),
[Select2](https://select2.github.io), [Socket.io](http://socket.io), [Socket.io File Upload](https://github.com/vote539/socketio-file-upload), [SweetAlert](http://t4t5.github.io/sweetalert), [Select2](https://select2.github.io), [Socket.io](http://socket.io), [Socket.io File Upload](https://github.com/vote539/socketio-file-upload), [SweetAlert](http://t4t5.github.io/sweetalert),

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

@ -213,6 +213,13 @@ class Handler extends ExceptionHandler
'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

@ -2,6 +2,7 @@
namespace Pterodactyl\Exceptions\Http\Connection; namespace Pterodactyl\Exceptions\Http\Connection;
use Illuminate\Support\Arr;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Exceptions\DisplayException;
@ -22,18 +23,34 @@ class DaemonConnectionException extends DisplayException
* @param \GuzzleHttp\Exception\GuzzleException $previous * @param \GuzzleHttp\Exception\GuzzleException $previous
* @param bool $useStatusCode * @param bool $useStatusCode
*/ */
public function __construct(GuzzleException $previous, bool $useStatusCode = false) public function __construct(GuzzleException $previous, bool $useStatusCode = true)
{ {
/** @var \GuzzleHttp\Psr7\Response|null $response */ /** @var \GuzzleHttp\Psr7\Response|null $response */
$response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null; $response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null;
if ($useStatusCode) { if ($useStatusCode) {
$this->statusCode = is_null($response) ? 500 : $response->getStatusCode(); $this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode();
} }
parent::__construct(trans('admin/server.exceptions.daemon_exception', [ $message = trans('admin/server.exceptions.daemon_exception', [
'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(), 'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(),
]), $previous, DisplayException::LEVEL_WARNING); ]);
// Attempt to pull the actual error message off the response and return that if it is not
// a 500 level error.
if ($this->statusCode < 500 && ! is_null($response)) {
$body = $response->getBody();
if (is_string($body) || (is_object($body) && method_exists($body, '__toString'))) {
$body = json_decode(is_string($body) ? $body : $body->__toString(), true);
$message = "[Wings Error]: " . Arr::get($body, 'error', $message);
}
}
$level = $this->statusCode >= 500 && $this->statusCode !== 504
? DisplayException::LEVEL_ERROR
: DisplayException::LEVEL_WARNING;
parent::__construct($message, $previous, $level);
} }
/** /**

View file

@ -2,7 +2,6 @@
namespace Pterodactyl\Http\Controllers\Api\Client; namespace Pterodactyl\Http\Controllers\Api\Client;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Models\Permission; use Pterodactyl\Models\Permission;
use Spatie\QueryBuilder\QueryBuilder; use Spatie\QueryBuilder\QueryBuilder;
@ -39,31 +38,27 @@ class ClientController extends ClientApiController
public function index(GetServersRequest $request): array public function index(GetServersRequest $request): array
{ {
$user = $request->user(); $user = $request->user();
$level = $request->getFilterLevel();
$transformer = $this->getTransformer(ServerTransformer::class); $transformer = $this->getTransformer(ServerTransformer::class);
// Start the query builder and ensure we eager load any requested relationships from the request. // Start the query builder and ensure we eager load any requested relationships from the request.
$builder = Server::query()->with($this->getIncludesForTransformer($transformer, ['node'])); $builder = QueryBuilder::for(
Server::query()->with($this->getIncludesForTransformer($transformer, ['node']))
)->allowedFilters('uuid', 'name', 'external_id');
if ($level === User::FILTER_LEVEL_OWNER) { // Either return all of the servers the user has access to because they are an admin `?type=admin` or
$builder = $builder->where('owner_id', $request->user()->id); // just return all of the servers the user has access to because they are the owner or a subuser of the
} // server.
// If set to all, display all servers they can access, including those they access as an if ($request->input('type') === 'admin') {
// admin. If set to subuser, only return the servers they can access because they are owner, $builder = $user->root_admin
// or marked as a subuser of the server. ? $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all())
elseif (($level === User::FILTER_LEVEL_ALL && ! $user->root_admin) || $level === User::FILTER_LEVEL_SUBUSER) { // If they aren't an admin but want all the admin servers don't fail the request, just
// make it a query that will never return any results back.
: $builder->whereRaw('1 = 2');
} elseif ($request->input('type') === 'owner') {
$builder = $builder->where('owner_id', $user->id);
} else {
$builder = $builder->whereIn('id', $user->accessibleServers()->pluck('id')->all()); $builder = $builder->whereIn('id', $user->accessibleServers()->pluck('id')->all());
} }
// If set to admin, only display the servers a user can access because they are an administrator.
// This means only servers the user would not have access to if they were not an admin (because they
// are not an owner or subuser) are returned.
elseif ($level === User::FILTER_LEVEL_ADMIN && $user->root_admin) {
$builder = $builder->whereNotIn('id', $user->accessibleServers()->pluck('id')->all());
}
$builder = QueryBuilder::for($builder)->allowedFilters(
'uuid', 'name', 'external_id'
);
$servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query()); $servers = $builder->paginate(min($request->query('per_page', 50), 100))->appends($request->query());

View file

@ -5,7 +5,6 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Exception\BadResponseException; use GuzzleHttp\Exception\BadResponseException;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Pterodactyl\Repositories\Wings\DaemonCommandRepository; use Pterodactyl\Repositories\Wings\DaemonCommandRepository;
@ -45,11 +44,13 @@ class CommandController extends ClientApiController
{ {
try { try {
$this->repository->setServer($server)->send($request->input('command')); $this->repository->setServer($server)->send($request->input('command'));
} catch (RequestException $exception) { } catch (DaemonConnectionException $exception) {
if ($exception instanceof BadResponseException) { $previous = $exception->getPrevious();
if ($previous instanceof BadResponseException) {
if ( if (
$exception->getResponse() instanceof ResponseInterface $previous->getResponse() instanceof ResponseInterface
&& $exception->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY && $previous->getResponse()->getStatusCode() === Response::HTTP_BAD_GATEWAY
) { ) {
throw new HttpException( throw new HttpException(
Response::HTTP_BAD_GATEWAY, 'Server must be online in order to send commands.', $exception Response::HTTP_BAD_GATEWAY, 'Server must be online in order to send commands.', $exception
@ -57,7 +58,7 @@ class CommandController extends ClientApiController
} }
} }
throw new DaemonConnectionException($exception); throw $exception;
} }
return $this->returnNoContent(); return $this->returnNoContent();

View file

@ -6,19 +6,18 @@ use Carbon\CarbonImmutable;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Services\Nodes\NodeJWTService; use Pterodactyl\Services\Nodes\NodeJWTService;
use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Contracts\Routing\ResponseFactory;
use Pterodactyl\Repositories\Wings\DaemonFileRepository; use Pterodactyl\Repositories\Wings\DaemonFileRepository;
use Pterodactyl\Transformers\Daemon\FileObjectTransformer; use Pterodactyl\Transformers\Daemon\FileObjectTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\ListFilesRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\ListFilesRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CreateFolderRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CreateFolderRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CompressFilesRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\CompressFilesRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\DecompressFilesRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest;
@ -69,13 +68,9 @@ class FileController extends ClientApiController
*/ */
public function directory(ListFilesRequest $request, Server $server): array public function directory(ListFilesRequest $request, Server $server): array
{ {
try { $contents = $this->fileRepository
$contents = $this->fileRepository ->setServer($server)
->setServer($server) ->getDirectory($request->get('directory') ?? '/');
->getDirectory($request->get('directory') ?? '/');
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception, true);
}
return $this->fractal->collection($contents) return $this->fractal->collection($contents)
->transformWith($this->getTransformer(FileObjectTransformer::class)) ->transformWith($this->getTransformer(FileObjectTransformer::class))
@ -88,7 +83,9 @@ class FileController extends ClientApiController
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*
* @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException * @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function contents(GetFileContentsRequest $request, Server $server): Response public function contents(GetFileContentsRequest $request, Server $server): Response
{ {
@ -139,6 +136,8 @@ class FileController extends ClientApiController
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\WriteFileContentRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function write(WriteFileContentRequest $request, Server $server): JsonResponse public function write(WriteFileContentRequest $request, Server $server): JsonResponse
{ {
@ -156,6 +155,8 @@ class FileController extends ClientApiController
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CreateFolderRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CreateFolderRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function create(CreateFolderRequest $request, Server $server): JsonResponse public function create(CreateFolderRequest $request, Server $server): JsonResponse
{ {
@ -172,6 +173,8 @@ class FileController extends ClientApiController
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\RenameFileRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function rename(RenameFileRequest $request, Server $server): JsonResponse public function rename(RenameFileRequest $request, Server $server): JsonResponse
{ {
@ -188,6 +191,8 @@ class FileController extends ClientApiController
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CopyFileRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function copy(CopyFileRequest $request, Server $server): JsonResponse public function copy(CopyFileRequest $request, Server $server): JsonResponse
{ {
@ -202,9 +207,14 @@ class FileController extends ClientApiController
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CompressFilesRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\CompressFilesRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return array * @return array
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function compress(CompressFilesRequest $request, Server $server): array public function compress(CompressFilesRequest $request, Server $server): array
{ {
// Allow up to five minutes for this request to process before timing out.
set_time_limit(300);
$file = $this->fileRepository->setServer($server) $file = $this->fileRepository->setServer($server)
->compressFiles( ->compressFiles(
$request->input('root'), $request->input('files') $request->input('root'), $request->input('files')
@ -215,12 +225,32 @@ class FileController extends ClientApiController
->toArray(); ->toArray();
} }
/**
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DecompressFilesRequest $request
* @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function decompress(DecompressFilesRequest $request, Server $server): JsonResponse
{
// Allow up to five minutes for this request to process before timing out.
set_time_limit(300);
$this->fileRepository->setServer($server)
->decompressFile($request->input('root'), $request->input('file'));
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/** /**
* Deletes files or folders for the server in the given root directory. * Deletes files or folders for the server in the given root directory.
* *
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function delete(DeleteFileRequest $request, Server $server): JsonResponse public function delete(DeleteFileRequest $request, Server $server): JsonResponse
{ {

View file

@ -33,6 +33,8 @@ class PowerController extends ClientApiController
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\SendPowerRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\SendPowerRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\Response * @return \Illuminate\Http\Response
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function index(SendPowerRequest $request, Server $server): Response public function index(SendPowerRequest $request, Server $server): Response
{ {

View file

@ -0,0 +1,81 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
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\UpdateStartupVariableRequest;
class StartupController extends ClientApiController
{
/**
* @var \Pterodactyl\Services\Servers\VariableValidatorService
*/
private $service;
/**
* @var \Pterodactyl\Repositories\Eloquent\ServerVariableRepository
*/
private $repository;
/**
* StartupController constructor.
*
* @param \Pterodactyl\Services\Servers\VariableValidatorService $service
* @param \Pterodactyl\Repositories\Eloquent\ServerVariableRepository $repository
*/
public function __construct(VariableValidatorService $service, ServerVariableRepository $repository)
{
parent::__construct();
$this->service = $service;
$this->repository = $repository;
}
/**
* Updates a single variable for a server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Startup\UpdateStartupVariableRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*
* @throws \Illuminate\Validation\ValidationException
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function update(UpdateStartupVariableRequest $request, Server $server)
{
/** @var \Pterodactyl\Models\EggVariable $variable */
$variable = $server->variables()->where('env_variable', $request->input('key'))->first();
if (is_null($variable) || !$variable->user_viewable || !$variable->user_editable) {
throw new BadRequestHttpException(
"The environment variable you are trying to edit [\"{$request->input('key')}\"] does not exist."
);
}
// Revalidate the variable value using the egg variable specific validation rules for it.
$this->validate($request, ['value' => $variable->rules]);
$this->repository->updateOrCreate([
'server_id' => $server->id,
'variable_id' => $variable->id,
], [
'variable_value' => $request->input('value'),
]);
$variable = $variable->refresh();
$variable->server_value = $request->input('value');
return $this->fractal->item($variable)
->transformWith($this->getTransformer(EggVariableTransformer::class))
->toArray();
}
}

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,6 +3,7 @@
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;
@ -31,25 +32,16 @@ class BackupStatusController extends Controller
* @param \Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest $request * @param \Pterodactyl\Http\Requests\Api\Remote\ReportBackupCompleteRequest $request
* @param string $backup * @param string $backup
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/ */
public function __invoke(ReportBackupCompleteRequest $request, string $backup) public function __invoke(ReportBackupCompleteRequest $request, string $backup)
{ {
/** @var \Pterodactyl\Models\Backup $backup */ $this->repository->updateWhere([['uuid', '=', $backup]], [
$backup = $this->repository->findFirstWhere([['uuid', '=', $backup]]); 'is_successful' => $request->input('successful') ? true : false,
'sha256_hash' => $request->input('checksum'),
'bytes' => $request->input('size'),
'completed_at' => CarbonImmutable::now(),
]);
if ($request->input('successful')) { return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
$this->repository->update($backup->id, [
'sha256_hash' => $request->input('checksum'),
'bytes' => $request->input('size'),
'completed_at' => Carbon::now(),
], true, true);
} else {
$this->repository->delete($backup->id);
}
return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT);
} }
} }

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

@ -19,6 +19,7 @@ class BaseSettingsFormRequest extends AdminFormRequest
'app:name' => 'required|string|max:255', 'app:name' => 'required|string|max:255',
'pterodactyl:auth:2fa_required' => 'required|integer|in:0,1,2', 'pterodactyl:auth:2fa_required' => 'required|integer|in:0,1,2',
'app:locale' => ['required', 'string', Rule::in(array_keys($this->getAvailableLanguages()))], 'app:locale' => ['required', 'string', Rule::in(array_keys($this->getAvailableLanguages()))],
'app:analytics' => 'nullable|string',
]; ];
} }
@ -31,6 +32,7 @@ class BaseSettingsFormRequest extends AdminFormRequest
'app:name' => 'Company Name', 'app:name' => 'Company Name',
'pterodactyl:auth:2fa_required' => 'Require 2-Factor Authentication', 'pterodactyl:auth:2fa_required' => 'Require 2-Factor Authentication',
'app:locale' => 'Default Language', 'app:locale' => 'Default Language',
'app:analytics' => 'Google Analytics',
]; ];
} }
} }

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

@ -2,8 +2,6 @@
namespace Pterodactyl\Http\Requests\Api\Client; namespace Pterodactyl\Http\Requests\Api\Client;
use Pterodactyl\Models\User;
class GetServersRequest extends ClientApiRequest class GetServersRequest extends ClientApiRequest
{ {
/** /**
@ -13,28 +11,4 @@ class GetServersRequest extends ClientApiRequest
{ {
return true; return true;
} }
/**
* Return the filtering method for servers when the client base endpoint is requested.
*
* @return int
*/
public function getFilterLevel(): int
{
switch ($this->input('type')) {
case 'all':
return User::FILTER_LEVEL_ALL;
break;
case 'admin':
return User::FILTER_LEVEL_ADMIN;
break;
case 'owner':
return User::FILTER_LEVEL_OWNER;
break;
case 'subuser-of':
default:
return User::FILTER_LEVEL_SUBUSER;
break;
}
}
} }

View file

@ -0,0 +1,32 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Files;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class DecompressFilesRequest extends ClientApiRequest
{
/**
* Checks that the authenticated user is allowed to create new files for the server. We don't
* rely on the archive permission here as it makes more sense to make sure the user can create
* additional files rather than make an archive.
*
* @return string
*/
public function permission(): string
{
return Permission::ACTION_FILE_CREATE;
}
/**
* @return array
*/
public function rules(): array
{
return [
'root' => 'sometimes|nullable|string',
'file' => 'required|string',
];
}
}

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

@ -37,6 +37,7 @@ class AssetComposer
'enabled' => config('recaptcha.enabled', false), 'enabled' => config('recaptcha.enabled', false),
'siteKey' => config('recaptcha.website_key') ?? '', 'siteKey' => config('recaptcha.website_key') ?? '',
], ],
'analytics' => config('app.analytics') ?? '',
]); ]);
} }
} }

View file

@ -8,6 +8,7 @@ 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
@ -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,6 +61,7 @@ class Backup extends Model
* @var array * @var array
*/ */
protected $attributes = [ protected $attributes = [
'is_successful' => true,
'sha256_hash' => null, 'sha256_hash' => null,
'bytes' => 0, 'bytes' => 0,
]; ];
@ -69,6 +72,7 @@ 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',

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

@ -38,14 +38,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 +270,9 @@ 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', 'server_variables.variable_id', '=', 'egg_variables.id');
} }
/** /**

View file

@ -57,11 +57,6 @@ class User extends Model implements
const USER_LEVEL_USER = 0; const USER_LEVEL_USER = 0;
const USER_LEVEL_ADMIN = 1; const USER_LEVEL_ADMIN = 1;
const FILTER_LEVEL_ALL = 0;
const FILTER_LEVEL_OWNER = 1;
const FILTER_LEVEL_ADMIN = 2;
const FILTER_LEVEL_SUBUSER = 3;
/** /**
* The resource name for this model when it is transformed into an * The resource name for this model when it is transformed into an
* API representation using fractal. * API representation using fractal.

View file

@ -21,6 +21,7 @@ class SettingsServiceProvider extends ServiceProvider
protected $keys = [ protected $keys = [
'app:name', 'app:name',
'app:locale', 'app:locale',
'app:analytics',
'recaptcha:enabled', 'recaptcha:enabled',
'recaptcha:secret_key', 'recaptcha:secret_key',
'recaptcha:website_key', 'recaptcha:website_key',

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

@ -143,6 +143,10 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt
*/ */
public function getVariablesWithValues(int $id, bool $returnAsObject = false) public function getVariablesWithValues(int $id, bool $returnAsObject = false)
{ {
$this->getBuilder()
->with('variables', 'egg.variables')
->findOrFail($id);
try { try {
$instance = $this->getBuilder()->with('variables', 'egg.variables')->find($id, $this->getColumns()); $instance = $this->getBuilder()->with('variables', 'egg.variables')->find($id, $this->getColumns());
} catch (ModelNotFoundException $exception) { } catch (ModelNotFoundException $exception) {

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

@ -5,6 +5,8 @@ namespace Pterodactyl\Repositories\Wings;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class DaemonCommandRepository extends DaemonRepository class DaemonCommandRepository extends DaemonRepository
{ {
@ -13,16 +15,22 @@ class DaemonCommandRepository extends DaemonRepository
* *
* @param string|string[] $command * @param string|string[] $command
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function send($command): ResponseInterface public function send($command): ResponseInterface
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
return $this->getHttpClient()->post( try {
sprintf('/api/servers/%s/commands', $this->server->uuid), return $this->getHttpClient()->post(
[ sprintf('/api/servers/%s/commands', $this->server->uuid),
'json' => ['commands' => is_array($command) ? $command : [$command]], [
] 'json' => ['commands' => is_array($command) ? $command : [$command]],
); ]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
} }
} }

View file

@ -5,7 +5,9 @@ namespace Pterodactyl\Repositories\Wings;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException; use Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class DaemonFileRepository extends DaemonRepository class DaemonFileRepository extends DaemonRepository
{ {
@ -18,17 +20,22 @@ class DaemonFileRepository extends DaemonRepository
* *
* @throws \GuzzleHttp\Exception\TransferException * @throws \GuzzleHttp\Exception\TransferException
* @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException * @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function getContent(string $path, int $notLargerThan = null): string public function getContent(string $path, int $notLargerThan = null): string
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
$response = $this->getHttpClient()->get( try {
sprintf('/api/servers/%s/files/contents', $this->server->uuid), $response = $this->getHttpClient()->get(
[ sprintf('/api/servers/%s/files/contents', $this->server->uuid),
'query' => ['file' => $path], [
] 'query' => ['file' => $path],
); ]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
$length = (int) $response->getHeader('Content-Length')[0] ?? 0; $length = (int) $response->getHeader('Content-Length')[0] ?? 0;
@ -47,19 +54,23 @@ class DaemonFileRepository extends DaemonRepository
* @param string $content * @param string $content
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
* *
* @throws \GuzzleHttp\Exception\TransferException * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function putContent(string $path, string $content): ResponseInterface public function putContent(string $path, string $content): ResponseInterface
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
return $this->getHttpClient()->post( try {
sprintf('/api/servers/%s/files/write', $this->server->uuid), return $this->getHttpClient()->post(
[ sprintf('/api/servers/%s/files/write', $this->server->uuid),
'query' => ['file' => $path], [
'body' => $content, 'query' => ['file' => $path],
] 'body' => $content,
); ]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
} }
/** /**
@ -68,18 +79,22 @@ class DaemonFileRepository extends DaemonRepository
* @param string $path * @param string $path
* @return array * @return array
* *
* @throws \GuzzleHttp\Exception\TransferException * @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function getDirectory(string $path): array public function getDirectory(string $path): array
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
$response = $this->getHttpClient()->get( try {
sprintf('/api/servers/%s/files/list-directory', $this->server->uuid), $response = $this->getHttpClient()->get(
[ sprintf('/api/servers/%s/files/list-directory', $this->server->uuid),
'query' => ['directory' => $path], [
] 'query' => ['directory' => $path],
); ]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
return json_decode($response->getBody(), true); return json_decode($response->getBody(), true);
} }
@ -90,20 +105,26 @@ class DaemonFileRepository extends DaemonRepository
* @param string $name * @param string $name
* @param string $path * @param string $path
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function createDirectory(string $name, string $path): ResponseInterface public function createDirectory(string $name, string $path): ResponseInterface
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
return $this->getHttpClient()->post( try {
sprintf('/api/servers/%s/files/create-directory', $this->server->uuid), return $this->getHttpClient()->post(
[ sprintf('/api/servers/%s/files/create-directory', $this->server->uuid),
'json' => [ [
'name' => urldecode($name), 'json' => [
'path' => urldecode($path), 'name' => urldecode($name),
], 'path' => urldecode($path),
] ],
); ]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
} }
/** /**
@ -112,20 +133,26 @@ class DaemonFileRepository extends DaemonRepository
* @param string|null $root * @param string|null $root
* @param array $files * @param array $files
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function renameFiles(?string $root, array $files): ResponseInterface public function renameFiles(?string $root, array $files): ResponseInterface
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
return $this->getHttpClient()->put( try {
sprintf('/api/servers/%s/files/rename', $this->server->uuid), return $this->getHttpClient()->put(
[ sprintf('/api/servers/%s/files/rename', $this->server->uuid),
'json' => [ [
'root' => $root ?? '/', 'json' => [
'files' => $files, 'root' => $root ?? '/',
], 'files' => $files,
] ],
); ]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
} }
/** /**
@ -133,19 +160,25 @@ class DaemonFileRepository extends DaemonRepository
* *
* @param string $location * @param string $location
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function copyFile(string $location): ResponseInterface public function copyFile(string $location): ResponseInterface
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
return $this->getHttpClient()->post( try {
sprintf('/api/servers/%s/files/copy', $this->server->uuid), return $this->getHttpClient()->post(
[ sprintf('/api/servers/%s/files/copy', $this->server->uuid),
'json' => [ [
'location' => urldecode($location), 'json' => [
], 'location' => urldecode($location),
] ],
); ]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
} }
/** /**
@ -154,20 +187,26 @@ class DaemonFileRepository extends DaemonRepository
* @param string|null $root * @param string|null $root
* @param array $files * @param array $files
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function deleteFiles(?string $root, array $files): ResponseInterface public function deleteFiles(?string $root, array $files): ResponseInterface
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
return $this->getHttpClient()->post( try {
sprintf('/api/servers/%s/files/delete', $this->server->uuid), return $this->getHttpClient()->post(
[ sprintf('/api/servers/%s/files/delete', $this->server->uuid),
'json' => [ [
'root' => $root ?? '/', 'json' => [
'files' => $files, 'root' => $root ?? '/',
], 'files' => $files,
] ],
); ]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
} }
/** /**
@ -176,21 +215,58 @@ class DaemonFileRepository extends DaemonRepository
* @param string|null $root * @param string|null $root
* @param array $files * @param array $files
* @return array * @return array
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function compressFiles(?string $root, array $files): array public function compressFiles(?string $root, array $files): array
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
$response = $this->getHttpClient()->post( try {
sprintf('/api/servers/%s/files/compress', $this->server->uuid), $response = $this->getHttpClient()->post(
[ sprintf('/api/servers/%s/files/compress', $this->server->uuid),
'json' => [ [
'root' => $root ?? '/', 'json' => [
'files' => $files, 'root' => $root ?? '/',
], 'files' => $files,
] ],
); // Wait for up to 15 minutes for the archive to be completed when calling this endpoint
// since it will likely take quite awhile for large directories.
'timeout' => 60 * 15,
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
return json_decode($response->getBody(), true); return json_decode($response->getBody(), true);
} }
/**
* Decompresses a given archive file.
*
* @param string|null $root
* @param string $file
* @return \Psr\Http\Message\ResponseInterface
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function decompressFile(?string $root, string $file): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/files/decompress', $this->server->uuid),
[
'json' => [
'root' => $root ?? '/',
'file' => $file,
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
} }

View file

@ -5,6 +5,8 @@ namespace Pterodactyl\Repositories\Wings;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
class DaemonPowerRepository extends DaemonRepository class DaemonPowerRepository extends DaemonRepository
{ {
@ -13,14 +15,20 @@ class DaemonPowerRepository extends DaemonRepository
* *
* @param string $action * @param string $action
* @return \Psr\Http\Message\ResponseInterface * @return \Psr\Http\Message\ResponseInterface
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function send(string $action): ResponseInterface public function send(string $action): ResponseInterface
{ {
Assert::isInstanceOf($this->server, Server::class); Assert::isInstanceOf($this->server, Server::class);
return $this->getHttpClient()->post( try {
sprintf('/api/servers/%s/power', $this->server->uuid), return $this->getHttpClient()->post(
['json' => ['action' => $action]] sprintf('/api/servers/%s/power', $this->server->uuid),
); ['json' => ['action' => $action]]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
} }
} }

View file

@ -23,7 +23,7 @@ class DaemonServerRepository extends DaemonRepository
sprintf('/api/servers/%s', $this->server->uuid) sprintf('/api/servers/%s', $this->server->uuid)
); );
} catch (TransferException $exception) { } catch (TransferException $exception) {
throw new DaemonConnectionException($exception); throw new DaemonConnectionException($exception, false);
} }
return json_decode($response->getBody()->__toString(), true); return json_decode($response->getBody()->__toString(), true);

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

@ -51,12 +51,29 @@ class EggConfigurationService
); );
return [ return [
'startup' => json_decode($server->egg->inherit_config_startup), 'startup' => $this->convertStartupToNewFormat(json_decode($server->egg->inherit_config_startup, true)),
'stop' => $this->convertStopToNewFormat($server->egg->inherit_config_stop), 'stop' => $this->convertStopToNewFormat($server->egg->inherit_config_stop),
'configs' => $configs, 'configs' => $configs,
]; ];
} }
/**
* Convert the "done" variable into an array if it is not currently one.
*
* @param array $startup
* @return array
*/
protected function convertStartupToNewFormat(array $startup)
{
$done = Arr::get($startup, 'done');
return [
'done' => is_string($done) ? [$done] : $done,
'user_interaction' => Arr::get($startup, 'userInteraction') ?? Arr::get($startup, 'user_interaction') ?? [],
'strip_ansi' => Arr::get($startup, 'strip_ansi') ?? false,
];
}
/** /**
* Converts a legacy stop string into a new generation stop option for a server. * Converts a legacy stop string into a new generation stop option for a server.
* *

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

@ -0,0 +1,27 @@
<?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
* @return string
*/
public function handle(Server $server): 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 ? ($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,6 +22,7 @@ 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, 'sha256_hash' => $backup->sha256_hash,

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),
'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

@ -27,11 +27,11 @@ class StatsTransformer extends BaseClientTransformer
'current_state' => Arr::get($data, 'state', 'stopped'), 'current_state' => Arr::get($data, 'state', 'stopped'),
'is_suspended' => Arr::get($data, 'suspended', false), 'is_suspended' => Arr::get($data, 'suspended', false),
'resources' => [ 'resources' => [
'memory_bytes' => Arr::get($data, 'resources.memory_bytes', 0), 'memory_bytes' => Arr::get($data, 'memory_bytes', 0),
'cpu_absolute' => Arr::get($data, 'resources.cpu_absolute', 0), 'cpu_absolute' => Arr::get($data, 'cpu_absolute', 0),
'disk_bytes' => Arr::get($data, 'resources.disk_bytes', 0), 'disk_bytes' => Arr::get($data, 'disk_bytes', 0),
'network_rx_bytes' => Arr::get($data, 'resources.network.rx_bytes', 0), 'network_rx_bytes' => Arr::get($data, 'network.rx_bytes', 0),
'network_tx_bytes' => Arr::get($data, 'resources.network.tx_bytes', 0), 'network_tx_bytes' => Arr::get($data, 'network.tx_bytes', 0),
], ],
]; ];
} }

View file

@ -85,8 +85,8 @@ return [
| Configure the timeout to be used for Guzzle connections here. | Configure the timeout to be used for Guzzle connections here.
*/ */
'guzzle' => [ 'guzzle' => [
'timeout' => env('GUZZLE_TIMEOUT', 5), 'timeout' => env('GUZZLE_TIMEOUT', 30),
'connect_timeout' => env('GUZZLE_CONNECT_TIMEOUT', 3), 'connect_timeout' => env('GUZZLE_CONNECT_TIMEOUT', 10),
], ],
/* /*

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

@ -4,7 +4,6 @@
"@fortawesome/fontawesome-svg-core": "1.2.19", "@fortawesome/fontawesome-svg-core": "1.2.19",
"@fortawesome/free-solid-svg-icons": "^5.9.0", "@fortawesome/free-solid-svg-icons": "^5.9.0",
"@fortawesome/react-fontawesome": "0.1.4", "@fortawesome/react-fontawesome": "0.1.4",
"@types/react-google-recaptcha": "^1.1.1",
"axios": "^0.19.2", "axios": "^0.19.2",
"ayu-ace": "^2.0.4", "ayu-ace": "^2.0.4",
"brace": "^0.11.1", "brace": "^0.11.1",
@ -26,11 +25,14 @@
"react-dom": "npm:@hot-loader/react-dom", "react-dom": "npm:@hot-loader/react-dom",
"react-fast-compare": "^3.2.0", "react-fast-compare": "^3.2.0",
"react-google-recaptcha": "^2.0.1", "react-google-recaptcha": "^2.0.1",
"react-helmet": "^6.1.0",
"react-ga": "^3.1.2",
"react-hot-loader": "^4.12.21", "react-hot-loader": "^4.12.21",
"react-i18next": "^11.2.1", "react-i18next": "^11.2.1",
"react-redux": "^7.1.0", "react-redux": "^7.1.0",
"react-router-dom": "^5.1.2", "react-router-dom": "^5.1.2",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"reaptcha": "^1.7.2",
"sockette": "^2.0.6", "sockette": "^2.0.6",
"styled-components": "^5.1.1", "styled-components": "^5.1.1",
"styled-components-breakpoint": "^3.0.0-preview.20", "styled-components-breakpoint": "^3.0.0-preview.20",
@ -61,6 +63,7 @@
"@types/query-string": "^6.3.0", "@types/query-string": "^6.3.0",
"@types/react": "^16.9.41", "@types/react": "^16.9.41",
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.8",
"@types/react-helmet": "^6.0.0",
"@types/react-redux": "^7.1.1", "@types/react-redux": "^7.1.1",
"@types/react-router": "^5.1.3", "@types/react-router": "^5.1.3",
"@types/react-router-dom": "^5.1.3", "@types/react-router-dom": "^5.1.3",

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

@ -1,8 +1,8 @@
import http from '@/api/http'; import http from '@/api/http';
export default (email: string): Promise<string> => { export default (email: string, recaptchaData?: string): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.post('/auth/password', { email }) http.post('/auth/password', { email, 'g-recaptcha-response': recaptchaData })
.then(response => resolve(response.data.status || '')) .then(response => resolve(response.data.status || ''))
.catch(reject); .catch(reject);
}); });

View file

@ -1,13 +1,19 @@
import { rawDataToServerObject, Server } from '@/api/server/getServer'; import { rawDataToServerObject, Server } from '@/api/server/getServer';
import http, { getPaginationSet, PaginatedResult } from '@/api/http'; import http, { getPaginationSet, PaginatedResult } from '@/api/http';
export default (query?: string, includeAdmin?: boolean): Promise<PaginatedResult<Server>> => { interface QueryParams {
query?: string;
page?: number;
onlyAdmin?: boolean;
}
export default ({ query, page = 1, onlyAdmin = false }: QueryParams): Promise<PaginatedResult<Server>> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get('/api/client', { http.get('/api/client', {
params: { params: {
include: [ 'allocation' ], type: onlyAdmin ? 'admin' : undefined,
type: includeAdmin ? 'all' : undefined,
'filter[name]': query, 'filter[name]': query,
page,
}, },
}) })
.then(({ data }) => resolve({ .then(({ data }) => resolve({

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

@ -4,8 +4,8 @@ import { rawDataToFileObject } from '@/api/transformers';
export default async (uuid: string, directory: string, files: string[]): Promise<FileObject> => { export default async (uuid: string, directory: string, files: string[]): Promise<FileObject> => {
const { data } = await http.post(`/api/client/servers/${uuid}/files/compress`, { root: directory, files }, { const { data } = await http.post(`/api/client/servers/${uuid}/files/compress`, { root: directory, files }, {
timeout: 300000, timeout: 60000,
timeoutErrorMessage: 'It looks like this archive is taking a long time to generate. It will appear when completed.', timeoutErrorMessage: 'It looks like this archive is taking a long time to generate. It will appear once completed.',
}); });
return rawDataToFileObject(data); return rawDataToFileObject(data);

View file

@ -0,0 +1,8 @@
import http from '@/api/http';
export default async (uuid: string, directory: string, file: string): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/files/decompress`, { root: directory, file }, {
timeout: 300000,
timeoutErrorMessage: 'It looks like this archive is taking a long time to be unarchived. Once completed the unarchived files will appear.',
});
};

View file

@ -2,7 +2,7 @@ import http from '@/api/http';
import { rawDataToFileObject } from '@/api/transformers'; import { rawDataToFileObject } from '@/api/transformers';
export interface FileObject { export interface FileObject {
uuid: string; key: string;
name: string; name: string;
mode: string; mode: string;
size: number; size: number;
@ -12,6 +12,7 @@ export interface FileObject {
mimetype: string; mimetype: string;
createdAt: Date; createdAt: Date;
modifiedAt: Date; modifiedAt: Date;
isArchiveType: () => boolean;
} }
export default async (uuid: string, directory?: string): Promise<FileObject[]> => { export default async (uuid: string, directory?: string): Promise<FileObject[]> => {

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;
sha256Hash: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}
export interface ServerEggVariable {
name: string;
description: string;
envVariable: string;
defaultValue: string;
serverValue: string;
isEditable: boolean;
rules: string[];
}

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> => {
const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value });
return rawDataToServerEggVariable(data);
};

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 useServer from '@/plugins/useServer';
export default (page?: number | string) => {
const { uuid } = useServer();
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

@ -1,7 +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 v4 from 'uuid/v4'; 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,
@ -13,7 +13,7 @@ export const rawDataToServerAllocation = (data: FractalResponseData): Allocation
}); });
export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
uuid: v4(), key: `${data.attributes.is_file ? 'file' : 'dir'}_${data.attributes.name}`,
name: data.attributes.name, name: data.attributes.name,
mode: data.attributes.mode, mode: data.attributes.mode,
size: Number(data.attributes.size), size: Number(data.attributes.size),
@ -23,4 +23,41 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
mimetype: data.attributes.mimetype, mimetype: data.attributes.mimetype,
createdAt: new Date(data.attributes.created_at), createdAt: new Date(data.attributes.created_at),
modifiedAt: new Date(data.attributes.modified_at), modifiedAt: new Date(data.attributes.modified_at),
isArchiveType: function () {
return this.isFile && [
'application/vnd.rar', // .rar
'application/x-rar-compressed', // .rar (2)
'application/x-tar', // .tar
'application/x-br', // .tar.br
'application/x-bzip2', // .tar.bz2, .bz2
'application/gzip', // .tar.gz, .gz
'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct)
'application/x-sz', // .tar.sz, .sz (not sure if this mime type is correct)
'application/x-xz', // .tar.xz, .xz
'application/zstd', // .tar.zst, .zst
'application/zip', // .zip
].indexOf(this.mimetype) >= 0;
},
});
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
isSuccessful: attributes.is_successful,
name: attributes.name,
ignoredFiles: attributes.ignored_files,
sha256Hash: attributes.sha256_hash,
bytes: attributes.bytes,
createdAt: new Date(attributes.created_at),
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
});
export const rawDataToServerEggVariable = ({ attributes }: FractalResponseData): ServerEggVariable => ({
name: attributes.name,
description: attributes.description,
envVariable: attributes.env_variable,
defaultValue: attributes.default_value,
serverValue: attributes.server_value,
isEditable: attributes.is_editable,
rules: attributes.rules.split('|'),
}); });

View file

@ -6,19 +6,19 @@ export default createGlobalStyle`
${tw`font-sans bg-neutral-800 text-neutral-200`}; ${tw`font-sans bg-neutral-800 text-neutral-200`};
letter-spacing: 0.015em; letter-spacing: 0.015em;
} }
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
${tw`font-medium tracking-normal font-header`}; ${tw`font-medium tracking-normal font-header`};
} }
p { p {
${tw`text-neutral-200 leading-snug font-sans`}; ${tw`text-neutral-200 leading-snug font-sans`};
} }
form { form {
${tw`m-0`}; ${tw`m-0`};
} }
textarea, select, input, button, button:focus, button:focus-visible { textarea, select, input, button, button:focus, button:focus-visible {
${tw`outline-none`}; ${tw`outline-none`};
} }
@ -32,4 +32,41 @@ export default createGlobalStyle`
input[type=number] { input[type=number] {
-moz-appearance: textfield !important; -moz-appearance: textfield !important;
} }
/* Scroll Bar Style */
::-webkit-scrollbar {
background: none;
width: 16px;
height: 16px;
}
::-webkit-scrollbar-thumb {
border: solid 0 rgb(0 0 0 / 0%);
border-right-width: 4px;
border-left-width: 4px;
-webkit-border-radius: 9px 4px;
-webkit-box-shadow: inset 0 0 0 1px hsl(211, 10%, 53%), inset 0 0 0 4px hsl(209deg 18% 30%);
}
::-webkit-scrollbar-track-piece {
margin: 4px 0;
}
::-webkit-scrollbar-thumb:horizontal {
border-right-width: 0;
border-left-width: 0;
border-top-width: 4px;
border-bottom-width: 4px;
-webkit-border-radius: 4px 9px;
}
::-webkit-scrollbar-thumb:hover {
-webkit-box-shadow:
inset 0 0 0 1px hsl(212, 92%, 43%),
inset 0 0 0 4px hsl(212, 92%, 43%);
}
::-webkit-scrollbar-corner {
background: transparent;
}
`; `;

View file

@ -1,4 +1,5 @@
import * as React from 'react'; import React, { useEffect } from 'react';
import ReactGA from 'react-ga';
import { hot } from 'react-hot-loader/root'; import { hot } from 'react-hot-loader/root';
import { BrowserRouter, Route, Switch } from 'react-router-dom'; import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { StoreProvider } from 'easy-peasy'; import { StoreProvider } from 'easy-peasy';
@ -48,6 +49,11 @@ const App = () => {
store.getActions().settings.setSettings(SiteConfiguration!); store.getActions().settings.setSettings(SiteConfiguration!);
} }
useEffect(() => {
ReactGA.initialize(SiteConfiguration!.analytics);
ReactGA.pageview(location.pathname);
}, []);
return ( return (
<> <>
<GlobalStylesheet/> <GlobalStylesheet/>

View file

@ -1,27 +1,40 @@
import * as React from 'react'; import * as React from 'react';
import { useRef, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail'; import requestPasswordResetEmail from '@/api/auth/requestPasswordResetEmail';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import LoginFormContainer from '@/components/auth/LoginFormContainer'; import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { Actions, useStoreActions } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import Field from '@/components/elements/Field'; import Field from '@/components/elements/Field';
import { Formik, FormikHelpers } from 'formik'; import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup'; import { object, string } from 'yup';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Reaptcha from 'reaptcha';
import useFlash from '@/plugins/useFlash';
interface Values { interface Values {
email: string; email: string;
} }
export default () => { export default () => {
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const ref = useRef<Reaptcha>(null);
const [ token, setToken ] = useState('');
const { clearFlashes, addFlash } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => { const handleSubmission = ({ email }: Values, { setSubmitting, resetForm }: FormikHelpers<Values>) => {
setSubmitting(true);
clearFlashes(); clearFlashes();
requestPasswordResetEmail(email)
// If there is no token in the state yet, request the token and then abort this submit request
// since it will be re-submitted when the recaptcha data is returned by the component.
if (recaptchaEnabled && !token) {
ref.current!.execute().catch(error => console.error(error));
return;
}
requestPasswordResetEmail(email, token)
.then(response => { .then(response => {
resetForm(); resetForm();
addFlash({ type: 'success', title: 'Success', message: response }); addFlash({ type: 'success', title: 'Success', message: response });
@ -42,7 +55,7 @@ export default () => {
.required('A valid email address must be provided to continue.'), .required('A valid email address must be provided to continue.'),
})} })}
> >
{({ isSubmitting }) => ( {({ isSubmitting, setSubmitting, submitForm }) => (
<LoginFormContainer <LoginFormContainer
title={'Request Password Reset'} title={'Request Password Reset'}
css={tw`w-full flex`} css={tw`w-full flex`}
@ -64,6 +77,21 @@ export default () => {
Send Email Send Email
</Button> </Button>
</div> </div>
{recaptchaEnabled &&
<Reaptcha
ref={ref}
size={'invisible'}
sitekey={siteKey || '_invalid_key'}
onVerify={response => {
setToken(response);
submitForm();
}}
onExpire={() => {
setSubmitting(false);
setToken('');
}}
/>
}
<div css={tw`mt-6 text-center`}> <div css={tw`mt-6 text-center`}>
<Link <Link
type={'button'} type={'button'}

View file

@ -1,105 +1,39 @@
import React, { useRef } from 'react'; import React, { useRef, useState } from 'react';
import { Link, RouteComponentProps } from 'react-router-dom'; import { Link, RouteComponentProps } from 'react-router-dom';
import login, { LoginData } from '@/api/auth/login'; import login from '@/api/auth/login';
import LoginFormContainer from '@/components/auth/LoginFormContainer'; import LoginFormContainer from '@/components/auth/LoginFormContainer';
import { ActionCreator, Actions, useStoreActions, useStoreState } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { Formik, FormikHelpers } from 'formik';
import { FormikProps, withFormik } from 'formik';
import { object, string } from 'yup'; import { object, string } from 'yup';
import Field from '@/components/elements/Field'; import Field from '@/components/elements/Field';
import { httpErrorToHuman } from '@/api/http';
import { FlashMessage } from '@/state/flashes';
import ReCAPTCHA from 'react-google-recaptcha';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Reaptcha from 'reaptcha';
import useFlash from '@/plugins/useFlash';
type OwnProps = RouteComponentProps & { interface Values {
clearFlashes: ActionCreator<void>; username: string;
addFlash: ActionCreator<FlashMessage>; password: string;
} }
const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handleSubmit }: OwnProps & FormikProps<LoginData>) => { const LoginContainer = ({ history }: RouteComponentProps) => {
const ref = useRef<ReCAPTCHA | null>(null); const ref = useRef<Reaptcha>(null);
const { enabled: recaptchaEnabled, siteKey } = useStoreState<ApplicationStore, any>(state => state.settings.data!.recaptcha); const [ token, setToken ] = useState('');
const submit = (e: React.FormEvent<HTMLFormElement>) => { const { clearFlashes, clearAndAddHttpError } = useFlash();
e.preventDefault(); const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
if (ref.current && !values.recaptchaData) { const onSubmit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
return ref.current.execute(); clearFlashes();
// If there is no token in the state yet, request the token and then abort this submit request
// since it will be re-submitted when the recaptcha data is returned by the component.
if (recaptchaEnabled && !token) {
ref.current!.execute().catch(error => console.error(error));
return;
} }
handleSubmit(e); login({ ...values, recaptchaData: token })
};
return (
<React.Fragment>
{ref.current && ref.current.render()}
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`} onSubmit={submit}>
<Field
type={'text'}
label={'Username or Email'}
id={'username'}
name={'username'}
light
/>
<div css={tw`mt-6`}>
<Field
type={'password'}
label={'Password'}
id={'password'}
name={'password'}
light
/>
</div>
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting}>
Login
</Button>
</div>
{recaptchaEnabled &&
<ReCAPTCHA
ref={ref}
size={'invisible'}
sitekey={siteKey || '_invalid_key'}
onChange={token => {
ref.current && ref.current.reset();
setFieldValue('recaptchaData', token);
submitForm();
}}
onExpired={() => setFieldValue('recaptchaData', null)}
/>
}
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/password'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
Forgot password?
</Link>
</div>
</LoginFormContainer>
</React.Fragment>
);
};
const EnhancedForm = withFormik<OwnProps, LoginData>({
displayName: 'LoginContainerForm',
mapPropsToValues: () => ({
username: '',
password: '',
recaptchaData: null,
}),
validationSchema: () => object().shape({
username: string().required('A username or email must be provided.'),
password: string().required('Please enter your account password.'),
}),
handleSubmit: (values, { props, setFieldValue, setSubmitting }) => {
props.clearFlashes();
login(values)
.then(response => { .then(response => {
if (response.complete) { if (response.complete) {
// @ts-ignore // @ts-ignore
@ -107,26 +41,75 @@ const EnhancedForm = withFormik<OwnProps, LoginData>({
return; return;
} }
props.history.replace('/auth/login/checkpoint', { token: response.confirmationToken }); history.replace('/auth/login/checkpoint', { token: response.confirmationToken });
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
setSubmitting(false); setSubmitting(false);
setFieldValue('recaptchaData', null); clearAndAddHttpError({ error });
props.addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
}); });
}, };
})(LoginContainer);
export default (props: RouteComponentProps) => {
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
return ( return (
<EnhancedForm <Formik
{...props} onSubmit={onSubmit}
addFlash={addFlash} initialValues={{ username: '', password: '' }}
clearFlashes={clearFlashes} validationSchema={object().shape({
/> username: string().required('A username or email must be provided.'),
password: string().required('Please enter your account password.'),
})}
>
{({ isSubmitting, setSubmitting, submitForm }) => (
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`}>
<Field
type={'text'}
label={'Username or Email'}
id={'username'}
name={'username'}
light
/>
<div css={tw`mt-6`}>
<Field
type={'password'}
label={'Password'}
id={'password'}
name={'password'}
light
/>
</div>
<div css={tw`mt-6`}>
<Button type={'submit'} size={'xlarge'} isLoading={isSubmitting}>
Login
</Button>
</div>
{recaptchaEnabled &&
<Reaptcha
ref={ref}
size={'invisible'}
sitekey={siteKey || '_invalid_key'}
onVerify={response => {
setToken(response);
submitForm();
}}
onExpire={() => {
setSubmitting(false);
setToken('');
}}
/>
}
<div css={tw`mt-6 text-center`}>
<Link
to={'/auth/password'}
css={tw`text-xs text-neutral-500 tracking-wide no-underline uppercase hover:text-neutral-600`}
>
Forgot password?
</Link>
</div>
</LoginFormContainer>
)}
</Formik>
); );
}; };
export default LoginContainer;

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import ContentBox from '@/components/elements/ContentBox'; import ContentBox from '@/components/elements/ContentBox';
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm'; import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys'; import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
@ -7,7 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import ConfirmationModal from '@/components/elements/ConfirmationModal'; import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteApiKey from '@/api/account/deleteApiKey'; import deleteApiKey from '@/api/account/deleteApiKey';
import { Actions, useStoreActions } from 'easy-peasy'; import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state'; import { ApplicationStore } from '@/state';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
@ -21,6 +22,7 @@ export default () => {
const [ keys, setKeys ] = useState<ApiKey[]>([]); const [ keys, setKeys ] = useState<ApiKey[]>([]);
const [ loading, setLoading ] = useState(true); const [ loading, setLoading ] = useState(true);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
useEffect(() => { useEffect(() => {
clearFlashes('account'); clearFlashes('account');
@ -49,6 +51,9 @@ export default () => {
return ( return (
<PageContentBlock> <PageContentBlock>
<Helmet>
<title> {name} | API</title>
</Helmet>
<FlashMessageRender byKey={'account'} css={tw`mb-4`}/> <FlashMessageRender byKey={'account'} css={tw`mb-4`}/>
<div css={tw`flex`}> <div css={tw`flex`}>
<ContentBox title={'Create API Key'} css={tw`flex-1`}> <ContentBox title={'Create API Key'} css={tw`flex-1`}>
@ -56,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

@ -1,4 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { Helmet } from 'react-helmet';
import { ApplicationStore } from '@/state';
import ContentBox from '@/components/elements/ContentBox'; import ContentBox from '@/components/elements/ContentBox';
import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm'; import UpdatePasswordForm from '@/components/dashboard/forms/UpdatePasswordForm';
import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm'; import UpdateEmailAddressForm from '@/components/dashboard/forms/UpdateEmailAddressForm';
@ -7,6 +9,7 @@ import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { breakpoint } from '@/theme'; import { breakpoint } from '@/theme';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import { useStoreState } from 'easy-peasy';
const Container = styled.div` const Container = styled.div`
${tw`flex flex-wrap my-10`}; ${tw`flex flex-wrap my-10`};
@ -25,8 +28,12 @@ const Container = styled.div`
`; `;
export default () => { export default () => {
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
return ( return (
<PageContentBlock> <PageContentBlock>
<Helmet>
<title> {name} | Account Overview</title>
</Helmet>
<Container> <Container>
<ContentBox title={'Update Password'} showFlashes={'account:password'}> <ContentBox title={'Update Password'} showFlashes={'account:password'}>
<UpdatePasswordForm/> <UpdatePasswordForm/>

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

@ -1,5 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { Server } from '@/api/server/getServer'; import { Server } from '@/api/server/getServer';
import { ApplicationStore } from '@/state';
import getServers from '@/api/getServers'; import getServers from '@/api/getServers';
import ServerRow from '@/components/dashboard/ServerRow'; import ServerRow from '@/components/dashboard/ServerRow';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
@ -11,15 +13,18 @@ import Switch from '@/components/elements/Switch';
import tw from 'twin.macro'; import tw from 'twin.macro';
import useSWR from 'swr'; import useSWR from 'swr';
import { PaginatedResult } from '@/api/http'; import { PaginatedResult } from '@/api/http';
import Pagination from '@/components/elements/Pagination';
export default () => { export default () => {
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ page, setPage ] = useState(1);
const { rootAdmin } = useStoreState(state => state.user.data!); const { rootAdmin } = useStoreState(state => state.user.data!);
const [ showAdmin, setShowAdmin ] = usePersistedState('show_all_servers', false); const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState('show_all_servers', false);
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
const { data: servers, error } = useSWR<PaginatedResult<Server>>( const { data: servers, error } = useSWR<PaginatedResult<Server>>(
[ '/api/client/servers', showAdmin ], [ '/api/client/servers', showOnlyAdmin, page ],
() => getServers(undefined, showAdmin) () => getServers({ onlyAdmin: showOnlyAdmin, page }),
); );
useEffect(() => { useEffect(() => {
@ -29,29 +34,44 @@ export default () => {
return ( return (
<PageContentBlock showFlashKey={'dashboard'}> <PageContentBlock showFlashKey={'dashboard'}>
<Helmet>
<title> {name} | Dashboard</title>
</Helmet>
{rootAdmin && {rootAdmin &&
<div css={tw`mb-2 flex justify-end items-center`}> <div css={tw`mb-2 flex justify-end items-center`}>
<p css={tw`uppercase text-xs text-neutral-400 mr-2`}> <p css={tw`uppercase text-xs text-neutral-400 mr-2`}>
{showAdmin ? 'Showing all servers' : 'Showing your servers'} {showOnlyAdmin ? 'Showing other\'s servers' : 'Showing your servers'}
</p> </p>
<Switch <Switch
name={'show_all_servers'} name={'show_all_servers'}
defaultChecked={showAdmin} defaultChecked={showOnlyAdmin}
onChange={() => setShowAdmin(s => !s)} onChange={() => setShowOnlyAdmin(s => !s)}
/> />
</div> </div>
} }
{!servers ? {!servers ?
<Spinner centered size={'large'}/> <Spinner centered size={'large'}/>
: :
servers.items.length > 0 ? <Pagination data={servers} onPageSelect={setPage}>
servers.items.map((server, index) => ( {({ items }) => (
<ServerRow key={server.uuid} server={server} css={index > 0 ? tw`mt-2` : undefined}/> items.length > 0 ?
)) items.map((server, index) => (
: <ServerRow
<p css={tw`text-center text-sm text-neutral-400`}> key={server.uuid}
There are no servers associated with your account. server={server}
</p> css={index > 0 ? tw`mt-2` : undefined}
/>
))
:
<p css={tw`text-center text-sm text-neutral-400`}>
{showOnlyAdmin ?
'There are no other servers to display.'
:
'There are no servers associated with your account.'
}
</p>
)}
</Pagination>
} }
</PageContentBlock> </PageContentBlock>
); );

View file

@ -1,9 +1,8 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faServer, faEthernet, faMicrochip, faMemory, faHdd } from '@fortawesome/free-solid-svg-icons'; import { faEthernet, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Server } from '@/api/server/getServer'; import { Server } from '@/api/server/getServer';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage'; import getServerResourceUsage, { ServerStats } from '@/api/server/getServerResourceUsage';
import { bytesToHuman, megabytesToHuman } from '@/helpers'; import { bytesToHuman, megabytesToHuman } from '@/helpers';
import tw from 'twin.macro'; import tw from 'twin.macro';

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

@ -57,7 +57,7 @@ export default ({ ...props }: Props) => {
setSubmitting(false); setSubmitting(false);
clearFlashes('search'); clearFlashes('search');
getServers(term) getServers({ query: term })
.then(servers => setServers(servers.items.filter((_, index) => index < 5))) .then(servers => setServers(servers.items.filter((_, index) => index < 5)))
.catch(error => { .catch(error => {
console.error(error); console.error(error);

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: (content: string) => 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')));
@ -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,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,7 +13,7 @@ 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;
@ -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,48 @@ 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';
import useServer from '@/plugins/useServer';
const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => ( interface Props {
<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<Props> = ({ title, showFlashKey, className, children }) => {
const { name } = useServer();
return (
<CSSTransition timeout={150} classNames={'fade'} appear in>
<>
{!!title &&
<Helmet>
<title>{name} | {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,87 @@
import React from 'react';
import { PaginatedResult } from '@/api/http';
import tw from 'twin.macro';
import styled from 'styled-components/macro';
import Button from '@/components/elements/Button';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faAngleDoubleLeft, faAngleDoubleRight } from '@fortawesome/free-solid-svg-icons';
interface RenderFuncProps<T> {
items: T[];
isLastPage: boolean;
isFirstPage: boolean;
}
interface Props<T> {
data: PaginatedResult<T>;
showGoToLast?: boolean;
showGoToFirst?: boolean;
onPageSelect: (page: number) => void;
children: (props: RenderFuncProps<T>) => React.ReactNode;
}
const Block = styled(Button)`
${tw`p-0 w-10 h-10`}
&:not(:last-of-type) {
${tw`mr-2`};
}
`;
function Pagination<T> ({ data: { items, pagination }, onPageSelect, children }: Props<T>) {
const isFirstPage = pagination.currentPage === 1;
const isLastPage = pagination.currentPage >= pagination.totalPages;
const pages = [];
// Start two spaces before the current page. If that puts us before the starting page default
// to the first page as the starting point.
const start = Math.max(pagination.currentPage - 2, 1);
const end = Math.min(pagination.totalPages, pagination.currentPage + 5);
for (let i = start; i <= end; i++) {
pages.push(i);
}
return (
<>
{children({ items, isFirstPage, isLastPage })}
{(pages.length > 1) &&
<div css={tw`mt-4 flex justify-center`}>
{(pages[0] > 1 && !isFirstPage) &&
<Block
isSecondary
color={'primary'}
onClick={() => onPageSelect(1)}
>
<FontAwesomeIcon icon={faAngleDoubleLeft}/>
</Block>
}
{
pages.map(i => (
<Block
isSecondary={pagination.currentPage !== i}
color={'primary'}
key={`block_page_${i}`}
onClick={() => onPageSelect(i)}
>
{i}
</Block>
))
}
{(pages[4] < pagination.totalPages && !isLastPage) &&
<Block
isSecondary
color={'primary'}
onClick={() => onPageSelect(pagination.totalPages)}
>
<FontAwesomeIcon icon={faAngleDoubleRight}/>
</Block>
}
</div>
}
</>
);
}
export default Pagination;

View file

@ -0,0 +1,26 @@
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import useServer from '@/plugins/useServer';
const InstallListener = () => {
const server = useServer();
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const setServer = ServerContext.useStoreActions(actions => actions.server.setServer);
// Listen for the installation completion event and then fire off a request to fetch the updated
// server information. This allows the server to automatically become available to the user if they
// just sit on the page.
useWebsocketEvent('install completed', () => {
getServer(server.uuid).catch(error => console.error(error));
});
// When we see the install started event immediately update the state to indicate such so that the
// screens automatically update.
useWebsocketEvent('install started', () => {
setServer({ ...server, isInstalling: true });
});
return null;
};
export default InstallListener;

View file

@ -1,4 +1,5 @@
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';
@ -61,6 +62,9 @@ export default () => {
return ( return (
<PageContentBlock css={tw`flex`}> <PageContentBlock 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={server.name} icon={faServer}>
<p css={tw`text-xs uppercase`}> <p css={tw`text-xs uppercase`}>

View file

@ -8,7 +8,7 @@ const StopOrKillButton = ({ onPress }: { onPress: (action: PowerAction) => void
const status = ServerContext.useStoreState(state => state.status.value); const status = ServerContext.useStoreState(state => state.status.value);
useEffect(() => { useEffect(() => {
setClicked(state => [ 'stopping' ].indexOf(status) < 0 ? false : state); setClicked(status === 'stopping');
}, [ status ]); }, [ status ]);
return ( return (

View file

@ -1,50 +1,49 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import getServerBackups from '@/api/server/backups/getServerBackups';
import useServer from '@/plugins/useServer'; 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 PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
export default () => { export default () => {
const { uuid, featureLimits } = useServer(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const { addError, clearFlashes } = useFlash(); const { featureLimits, name: serverName } = useServer();
const [ loading, setLoading ] = useState(true);
const backups = ServerContext.useStoreState(state => state.backups.data); const { data: backups, error, isValidating } = getServerBackups();
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> <PageContentBlock>
<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}
@ -52,17 +51,17 @@ export default () => {
</div> </div>
} }
{featureLimits.backups === 0 && {featureLimits.backups === 0 &&
<p className="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) && {(featureLimits.backups > 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 {featureLimits.backups} backups have been created for this server.
</p> </p>
} }
{featureLimits.backups > 0 && featureLimits.backups !== backups.length && {featureLimits.backups > 0 && featureLimits.backups !== backups.items.length &&
<div css={tw`mt-6 flex justify-end`}> <div css={tw`mt-6 flex justify-end`}>
<CreateBackupButton/> <CreateBackupButton/>
</div> </div>

View file

@ -1,19 +1,18 @@
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 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';
interface Props { interface Props {
backup: ServerBackup; backup: ServerBackup;
@ -24,8 +23,8 @@ export default ({ backup }: Props) => {
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);
}); });
@ -65,48 +69,55 @@ export default ({ backup }: Props) => {
checksum={backup.sha256Hash} checksum={backup.sha256Hash}
/> />
} }
{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,
sha256Hash: parsed.sha256_hash || '',
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

@ -7,12 +7,11 @@ import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer'; 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';
interface Values { interface Values {
name: string; name: string;
@ -49,7 +48,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
</FormikFieldWrapper> </FormikFieldWrapper>
</div> </div>
<div css={tw`flex justify-end`}> <div css={tw`flex justify-end`}>
<Button type={'submit'}> <Button type={'submit'} disabled={isSubmitting}>
Start backup Start backup
</Button> </Button>
</div> </div>
@ -60,10 +59,9 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
export default () => { export default () => {
const { uuid } = useServer(); const { uuid } = useServer();
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);
}); });
}; };
@ -94,11 +91,7 @@ export default () => {
ignored: string(), ignored: string(),
})} })}
> >
<ModalContent <ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
</Formik> </Formik>
} }
<Button onClick={() => setVisible(true)}> <Button onClick={() => setVisible(true)}>

View file

@ -1,4 +1,5 @@
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';
@ -14,7 +15,7 @@ import tw from 'twin.macro';
import Fade from '@/components/elements/Fade'; import Fade from '@/components/elements/Fade';
export default () => { export default () => {
const { uuid, featureLimits } = useServer(); const { uuid, featureLimits, name: serverName } = useServer();
const { addError, clearFlashes } = useFlash(); const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true); const [ loading, setLoading ] = useState(true);
@ -36,6 +37,9 @@ export default () => {
return ( return (
<PageContentBlock> <PageContentBlock>
<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/>

View file

@ -0,0 +1,10 @@
export enum SocketEvent {
DAEMON_MESSAGE = 'daemon message',
INSTALL_OUTPUT = 'install output',
INSTALL_STARTED = 'install started',
INSTALL_COMPLETED = 'install completed',
CONSOLE_OUTPUT = 'console output',
STATUS = 'status',
STATS = 'stats',
BACKUP_COMPLETED = 'backup completed',
}

View file

@ -1,6 +1,7 @@
import React, { useRef, useState } from 'react'; import React, { memo, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faBoxOpen,
faCopy, faCopy,
faEllipsisH, faEllipsisH,
faFileArchive, faFileArchive,
@ -27,6 +28,8 @@ import DropdownMenu from '@/components/elements/DropdownMenu';
import styled from 'styled-components/macro'; import styled from 'styled-components/macro';
import useEventListener from '@/plugins/useEventListener'; import useEventListener from '@/plugins/useEventListener';
import compressFiles from '@/api/server/files/compressFiles'; import compressFiles from '@/api/server/files/compressFiles';
import decompressFiles from '@/api/server/files/decompressFiles';
import isEqual from 'react-fast-compare';
type ModalType = 'rename' | 'move'; type ModalType = 'rename' | 'move';
@ -43,12 +46,12 @@ interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
const Row = ({ icon, title, ...props }: RowProps) => ( const Row = ({ icon, title, ...props }: RowProps) => (
<StyledRow {...props}> <StyledRow {...props}>
<FontAwesomeIcon icon={icon} css={tw`text-xs`}/> <FontAwesomeIcon icon={icon} css={tw`text-xs`} fixedWidth/>
<span css={tw`ml-2`}>{title}</span> <span css={tw`ml-2`}>{title}</span>
</StyledRow> </StyledRow>
); );
export default ({ file }: { file: FileObject }) => { const FileDropdownMenu = ({ file }: { file: FileObject }) => {
const onClickRef = useRef<DropdownMenu>(null); const onClickRef = useRef<DropdownMenu>(null);
const [ showSpinner, setShowSpinner ] = useState(false); const [ showSpinner, setShowSpinner ] = useState(false);
const [ modal, setModal ] = useState<ModalType | null>(null); const [ modal, setModal ] = useState<ModalType | null>(null);
@ -58,7 +61,7 @@ export default ({ file }: { file: FileObject }) => {
const { clearAndAddHttpError, clearFlashes } = useFlash(); const { clearAndAddHttpError, clearFlashes } = useFlash();
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);
useEventListener(`pterodactyl:files:ctx:${file.uuid}`, (e: CustomEvent) => { useEventListener(`pterodactyl:files:ctx:${file.key}`, (e: CustomEvent) => {
if (onClickRef.current) { if (onClickRef.current) {
onClickRef.current.triggerMenu(e.detail); onClickRef.current.triggerMenu(e.detail);
} }
@ -69,7 +72,7 @@ export default ({ file }: { file: FileObject }) => {
// For UI speed, immediately remove the file from the listing before calling the deletion function. // For UI speed, immediately remove the file from the listing before calling the deletion function.
// If the delete actually fails, we'll fetch the current directory contents again automatically. // If the delete actually fails, we'll fetch the current directory contents again automatically.
mutate(files => files.filter(f => f.uuid !== file.uuid), false); mutate(files => files.filter(f => f.key !== file.key), false);
deleteFiles(uuid, directory, [ file.name ]).catch(error => { deleteFiles(uuid, directory, [ file.name ]).catch(error => {
mutate(); mutate();
@ -110,6 +113,16 @@ export default ({ file }: { file: FileObject }) => {
.then(() => setShowSpinner(false)); .then(() => setShowSpinner(false));
}; };
const doUnarchive = () => {
setShowSpinner(true);
clearFlashes('files');
decompressFiles(uuid, directory, file.name)
.then(() => mutate())
.catch(error => clearAndAddHttpError({ key: 'files', error }))
.then(() => setShowSpinner(false));
};
return ( return (
<DropdownMenu <DropdownMenu
ref={onClickRef} ref={onClickRef}
@ -138,9 +151,15 @@ export default ({ file }: { file: FileObject }) => {
<Row onClick={doCopy} icon={faCopy} title={'Copy'}/> <Row onClick={doCopy} icon={faCopy} title={'Copy'}/>
</Can> </Can>
} }
<Can action={'file.archive'}> {file.isArchiveType() ?
<Row onClick={doArchive} icon={faFileArchive} title={'Archive'}/> <Can action={'file.create'}>
</Can> <Row onClick={doUnarchive} icon={faBoxOpen} title={'Unarchive'}/>
</Can>
:
<Can action={'file.archive'}>
<Row onClick={doArchive} icon={faFileArchive} title={'Archive'}/>
</Can>
}
<Row onClick={doDownload} icon={faFileDownload} title={'Download'}/> <Row onClick={doDownload} icon={faFileDownload} title={'Download'}/>
<Can action={'file.delete'}> <Can action={'file.delete'}>
<Row onClick={doDeletion} icon={faTrashAlt} title={'Delete'} $danger/> <Row onClick={doDeletion} icon={faTrashAlt} title={'Delete'} $danger/>
@ -148,3 +167,5 @@ export default ({ file }: { file: FileObject }) => {
</DropdownMenu> </DropdownMenu>
); );
}; };
export default memo(FileDropdownMenu, isEqual);

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 useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor')); const LazyAceEditor = lazy(() => import(/* webpackChunkName: "editor" */'@/components/elements/AceEditor'));
@ -24,12 +25,13 @@ 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, uuid } = useServer();
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const { addError, clearFlashes } = useFlash();
let fetchFileContent: null | (() => Promise<string>) = null; let fetchFileContent: null | (() => Promise<string>) = null;
@ -75,10 +77,7 @@ export default () => {
if (error) { if (error) {
return ( return (
<ServerError <ServerError message={error} onBack={() => history.goBack()}/>
message={error}
onBack={() => history.goBack()}
/>
); );
} }
@ -109,15 +108,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,4 +1,5 @@
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';
@ -24,17 +25,14 @@ const sortFiles = (files: FileObject[]): FileObject[] => {
}; };
export default () => { export default () => {
const { id } = useServer(); const { id, name: serverName } = useServer();
const { hash } = useLocation(); const { hash } = useLocation();
const { data: files, error, mutate } = useFileManagerSwr(); const { data: files, error, mutate } = useFileManagerSwr();
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles); const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles);
useEffect(() => { useEffect(() => {
// We won't automatically mutate the store when the component re-mounts, otherwise because of
// my (horrible) programming this fires off way more than we intend it to.
mutate();
setSelectedFiles([]); setSelectedFiles([]);
setDirectory(hash.length > 0 ? hash : '/'); setDirectory(hash.length > 0 ? hash : '/');
}, [ hash ]); }, [ hash ]);
@ -47,6 +45,9 @@ export default () => {
return ( return (
<PageContentBlock showFlashKey={'files'}> <PageContentBlock showFlashKey={'files'}>
<Helmet>
<title> {serverName} | File Manager </title>
</Helmet>
<FileManagerBreadcrumbs/> <FileManagerBreadcrumbs/>
{ {
!files ? !files ?
@ -70,7 +71,7 @@ export default () => {
} }
{ {
sortFiles(files.slice(0, 250)).map(file => ( sortFiles(files.slice(0, 250)).map(file => (
<FileObjectRow key={file.uuid} file={file}/> <FileObjectRow key={file.key} file={file}/>
)) ))
} }
<MassActionsBar/> <MassActionsBar/>

View file

@ -1,5 +1,5 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileAlt, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons'; import { faFileAlt, faFileArchive, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, cleanDirectoryPath } from '@/helpers'; import { bytesToHuman, cleanDirectoryPath } from '@/helpers';
import { differenceInHours, format, formatDistanceToNow } from 'date-fns'; import { differenceInHours, format, formatDistanceToNow } from 'date-fns';
import React, { memo } from 'react'; import React, { memo } from 'react';
@ -18,7 +18,6 @@ const Row = styled.div`
const FileObjectRow = ({ file }: { file: FileObject }) => { const FileObjectRow = ({ file }: { file: FileObject }) => {
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const history = useHistory(); const history = useHistory();
const match = useRouteMatch(); const match = useRouteMatch();
@ -31,9 +30,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
// Just trust me future me, leave this be. // Just trust me future me, leave this be.
if (!file.isFile) { if (!file.isFile) {
e.preventDefault(); e.preventDefault();
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`); history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
} }
}; };
@ -42,7 +39,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
key={file.name} key={file.name}
onContextMenu={e => { onContextMenu={e => {
e.preventDefault(); e.preventDefault();
window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX })); window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.key}`, { detail: e.clientX }));
}} }}
> >
<SelectFileCheckbox name={file.name}/> <SelectFileCheckbox name={file.name}/>
@ -53,7 +50,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
> >
<div css={tw`flex-none self-center text-neutral-400 mr-4 text-lg pl-3 ml-6`}> <div css={tw`flex-none self-center text-neutral-400 mr-4 text-lg pl-3 ml-6`}>
{file.isFile ? {file.isFile ?
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : faFileAlt}/> <FontAwesomeIcon icon={file.isSymlink ? faFileImport : file.isArchiveType() ? faFileArchive : faFileAlt}/>
: :
<FontAwesomeIcon icon={faFolder}/> <FontAwesomeIcon icon={faFolder}/>
} }

View file

@ -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

@ -6,14 +6,12 @@ import Field from '@/components/elements/Field';
import { join } from 'path'; import { join } from 'path';
import { object, string } from 'yup'; import { object, string } from 'yup';
import createDirectory from '@/api/server/files/createDirectory'; import createDirectory from '@/api/server/files/createDirectory';
import v4 from 'uuid/v4';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import { mutate } from 'swr';
import useServer from '@/plugins/useServer'; import useServer from '@/plugins/useServer';
import { FileObject } from '@/api/server/files/loadDirectory'; import { FileObject } from '@/api/server/files/loadDirectory';
import { useLocation } from 'react-router';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
interface Values { interface Values {
directoryName: string; directoryName: string;
@ -24,7 +22,7 @@ const schema = object().shape({
}); });
const generateDirectoryData = (name: string): FileObject => ({ const generateDirectoryData = (name: string): FileObject => ({
uuid: v4(), key: `dir_${name}`,
name: name, name: name,
mode: '0644', mode: '0644',
size: 0, size: 0,
@ -34,24 +32,21 @@ const generateDirectoryData = (name: string): FileObject => ({
mimetype: '', mimetype: '',
createdAt: new Date(), createdAt: new Date(),
modifiedAt: new Date(), modifiedAt: new Date(),
isArchiveType: () => false,
}); });
export default () => { export default () => {
const { uuid } = useServer(); const { uuid } = useServer();
const { hash } = useLocation();
const { clearAndAddHttpError } = useFlash(); const { clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const { mutate } = useFileManagerSwr();
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);
const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers<Values>) => { const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers<Values>) => {
createDirectory(uuid, directory, directoryName) createDirectory(uuid, directory, directoryName)
.then(() => { .then(() => mutate(data => [ ...data, generateDirectoryData(directoryName) ], false))
mutate( .then(() => setVisible(false))
`${uuid}:files:${hash}`,
(data: FileObject[]) => [ ...data, generateDirectoryData(directoryName) ],
);
setVisible(false);
})
.catch(error => { .catch(error => {
console.error(error); console.error(error);
setSubmitting(false); setSubmitting(false);
@ -78,6 +73,7 @@ export default () => {
> >
<Form css={tw`m-0`}> <Form css={tw`m-0`}>
<Field <Field
autoFocus
id={'directoryName'} id={'directoryName'}
name={'directoryName'} name={'directoryName'}
label={'Directory Name'} label={'Directory Name'}

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