Merge branch 'develop' into feature/file-uploads
This commit is contained in:
commit
54f9c5f187
136 changed files with 2178 additions and 971 deletions
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
@ -1,2 +1,2 @@
|
||||||
#github: [DaneEveritt]
|
github: [DaneEveritt]
|
||||||
custom: ["https://paypal.me/PterodactylSoftware"]
|
custom: ["https://paypal.me/PterodactylSoftware"]
|
||||||
|
|
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
@ -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
|
||||||
|
|
|
@ -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" ]
|
||||||
|
|
26
README.md
26
README.md
|
@ -12,6 +12,30 @@ What more are you waiting for? Make game servers a first class citizen on your p
|
||||||
|
|
||||||
![Image](https://cdn.pterodactyl.io/site-assets/mockup-macbook-grey.png)
|
![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),
|
||||||
|
|
|
@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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') ?? '',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
49
app/Rules/ResolvesToIPAddress.php
Normal file
49
app/Rules/ResolvesToIPAddress.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
@ -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 : [];
|
||||||
}
|
}
|
||||||
|
|
27
app/Services/Servers/StartupCommandService.php
Normal file
27
app/Services/Servers/StartupCommandService.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
|
|
@ -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),
|
||||||
];
|
];
|
||||||
|
|
33
app/Transformers/Api/Client/EggVariableTransformer.php
Normal file
33
app/Transformers/Api/Client/EggVariableTransformer.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
};
|
|
|
@ -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);
|
||||||
|
|
8
resources/scripts/api/server/files/decompressFiles.ts
Normal file
8
resources/scripts/api/server/files/decompressFiles.ts
Normal 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.',
|
||||||
|
});
|
||||||
|
};
|
|
@ -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[]> => {
|
||||||
|
|
|
@ -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
20
resources/scripts/api/server/types.d.ts
vendored
Normal 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[];
|
||||||
|
}
|
9
resources/scripts/api/server/updateStartupVariable.ts
Normal file
9
resources/scripts/api/server/updateStartupVariable.ts
Normal 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);
|
||||||
|
};
|
18
resources/scripts/api/swr/getServerBackups.ts
Normal file
18
resources/scripts/api/swr/getServerBackups.ts
Normal 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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
|
@ -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('|'),
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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/>
|
||||||
|
|
|
@ -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'}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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`}>
|
||||||
|
|
|
@ -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/>
|
||||||
|
|
38
resources/scripts/components/dashboard/ApiKeyModal.tsx
Normal file
38
resources/scripts/components/dashboard/ApiKeyModal.tsx
Normal 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);
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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`}>
|
}
|
||||||
© 2015 - 2020
|
{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'}
|
© 2015 - 2020
|
||||||
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;
|
||||||
|
|
87
resources/scripts/components/elements/Pagination.tsx
Normal file
87
resources/scripts/components/elements/Pagination.tsx
Normal 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;
|
26
resources/scripts/components/server/InstallListener.tsx
Normal file
26
resources/scripts/components/server/InstallListener.tsx
Normal 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;
|
|
@ -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`}>
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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)}>
|
||||||
|
|
|
@ -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/>
|
||||||
|
|
10
resources/scripts/components/server/events.ts
Normal file
10
resources/scripts/components/server/events.ts
Normal 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',
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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()}>
|
||||||
|
|
|
@ -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/>
|
||||||
|
|
|
@ -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}/>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
Loading…
Reference in a new issue