Merge branch 'develop' into feature/file-uploads

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

2
.github/FUNDING.yml vendored
View file

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

View file

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

View file

@ -25,7 +25,7 @@ RUN cp docker/default.conf /etc/nginx/conf.d/default.conf \
&& cat docker/www.conf > /usr/local/etc/php-fpm.d/www.conf \
&& rm /usr/local/etc/php-fpm.d/www.conf.default \
&& cat docker/supervisord.conf > /etc/supervisord.conf \
&& echo "* * * * * /usr/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \
&& echo "* * * * * /usr/local/bin/php /app/artisan schedule:run >> /dev/null 2>&1" >> /var/spool/cron/crontabs/root \
&& sed -i s/ssl_session_cache/#ssl_session_cache/g /etc/nginx/nginx.conf \
&& mkdir -p /var/run/php /var/run/nginx

View file

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

View file

@ -0,0 +1,51 @@
<?php
namespace Pterodactyl\Console\Commands\Maintenance;
use Carbon\CarbonImmutable;
use InvalidArgumentException;
use Illuminate\Console\Command;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
class PruneOrphanedBackupsCommand extends Command
{
/**
* @var string
*/
protected $signature = 'p:maintenance:prune-backups {--since-minutes=30}';
/**
* @var string
*/
protected $description = 'Marks all backups that have not completed in the last "n" minutes as being failed.';
/**
* @param \Pterodactyl\Repositories\Eloquent\BackupRepository $repository
*/
public function handle(BackupRepository $repository)
{
$since = $this->option('since-minutes');
if (! is_digit($since)) {
throw new InvalidArgumentException('The --since-minutes option must be a valid numeric digit.');
}
$query = $repository->getBuilder()
->whereNull('completed_at')
->whereDate('created_at', '<=', CarbonImmutable::now()->subMinutes($since));
$count = $query->count();
if (! $count) {
$this->info('There are no orphaned backups to be marked as failed.');
return;
}
$this->warn("Marking {$count} backups that have not been marked as completed in the last {$since} minutes as failed.");
$query->update([
'is_successful' => false,
'completed_at' => CarbonImmutable::now(),
'updated_at' => CarbonImmutable::now(),
]);
}
}

View file

@ -22,7 +22,16 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule)
{
// Execute scheduled commands for servers every minute, as if there was a normal cron running.
$schedule->command('p:schedule:process')->everyMinute()->withoutOverlapping();
// Every 30 minutes, run the backup pruning command so that any abandoned backups can be removed
// from the UI view for the server.
$schedule->command('p:maintenance:prune-backups', [
'--since-minutes' => '30',
])->everyThirtyMinutes();
// Every day cleanup any internal backups of service files.
$schedule->command('p:maintenance:clean-service-backups')->daily();
}
}

View file

@ -213,6 +213,13 @@ class Handler extends ExceptionHandler
'detail' => 'An error was encountered while processing this request.',
];
if ($exception instanceof ModelNotFoundException || $exception->getPrevious() instanceof ModelNotFoundException) {
// Show a nicer error message compared to the standard "No query results for model"
// response that is normally returned. If we are in debug mode this will get overwritten
// with a more specific error message to help narrow down things.
$error['detail'] = 'The requested resource could not be found on the server.';
}
if (config('app.debug')) {
$error = array_merge($error, [
'detail' => $exception->getMessage(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,9 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Request;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Subuser;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Permission;
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
@ -57,6 +59,21 @@ class SubuserController extends ClientApiController
->toArray();
}
/**
* Returns a single subuser associated with this server instance.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\GetSubuserRequest $request
* @return array
*/
public function view(GetSubuserRequest $request)
{
$subuser = $request->attributes->get('subuser');
return $this->fractal->item($subuser)
->transformWith($this->getTransformer(SubuserTransformer::class))
->toArray();
}
/**
* Create a new subuser for the given server.
*
@ -84,15 +101,16 @@ class SubuserController extends ClientApiController
* Update a given subuser in the system for the server.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\UpdateSubuserRequest $request
* @param \Pterodactyl\Models\Server $server
* @return array
*
* @throws \Pterodactyl\Exceptions\Model\DataValidationException
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function update(UpdateSubuserRequest $request, Server $server): array
public function update(UpdateSubuserRequest $request): array
{
$subuser = $request->endpointSubuser();
/** @var \Pterodactyl\Models\Subuser $subuser */
$subuser = $request->attributes->get('subuser');
$this->repository->update($subuser->id, [
'permissions' => $this->getDefaultPermissions($request),
]);
@ -106,14 +124,16 @@ class SubuserController extends ClientApiController
* Removes a subusers from a server's assignment.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Subusers\DeleteSubuserRequest $request
* @param \Pterodactyl\Models\Server $server
* @return \Illuminate\Http\JsonResponse
*/
public function delete(DeleteSubuserRequest $request, Server $server)
public function delete(DeleteSubuserRequest $request)
{
$this->repository->delete($request->endpointSubuser()->id);
/** @var \Pterodactyl\Models\Subuser $subuser */
$subuser = $request->attributes->get('subuser');
return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT);
$this->repository->delete($subuser->id);
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**

View file

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

View file

@ -0,0 +1,36 @@
<?php
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
use Closure;
use Illuminate\Http\Request;
class SubuserBelongsToServer
{
/**
* Ensure that the user being accessed in the request is a user that is currently assigned
* as a subuser for this server instance. We'll let the requests themselves handle wether or
* not the user making the request can actually modify or delete the subuser record.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
*
* @return mixed
*/
public function handle(Request $request, Closure $next)
{
/** @var \Pterodactyl\Models\Server $server */
$server = $request->route()->parameter('server');
/** @var \Pterodactyl\Models\User $user */
$user = $request->route()->parameter('user');
// Don't do anything if there isn't a user present in the request.
if (is_null($user)) {
return $next($request);
}
$request->attributes->set('subuser', $server->subusers()->where('user_id', $user->id)->firstOrFail());
return $next($request);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,12 +3,10 @@
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Subusers;
use Illuminate\Http\Request;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\User;
use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Repositories\Eloquent\SubuserRepository;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Services\Servers\GetUserPermissionsService;
abstract class SubuserRequest extends ClientApiRequest
{
@ -30,10 +28,10 @@ abstract class SubuserRequest extends ClientApiRequest
return false;
}
// If there is a subuser present in the URL, validate that it is not the same as the
// current request user. You're not allowed to modify yourself.
if ($this->route()->hasParameter('subuser')) {
if ($this->endpointSubuser()->user_id === $this->user()->id) {
$user = $this->route()->parameter('user');
// Don't allow a user to edit themselves on the server.
if ($user instanceof User) {
if ($user->uuid === $this->user()->uuid) {
return false;
}
}
@ -71,68 +69,14 @@ abstract class SubuserRequest extends ClientApiRequest
// Otherwise, get the current subuser's permission set, and ensure that the
// permissions they are trying to assign are not _more_ than the ones they
// already have.
if (count(array_diff($permissions, $this->currentUserPermissions())) > 0) {
/** @var \Pterodactyl\Models\Subuser|null $subuser */
/** @var \Pterodactyl\Services\Servers\GetUserPermissionsService $service */
$service = $this->container->make(GetUserPermissionsService::class);
if (count(array_diff($permissions, $service->handle($server, $user))) > 0) {
throw new HttpForbiddenException(
'Cannot assign permissions to a subuser that your account does not actively possess.'
);
}
}
/**
* Returns the currently authenticated user's permissions.
*
* @return array
*
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function currentUserPermissions(): array
{
/** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */
$repository = $this->container->make(SubuserRepository::class);
/* @var \Pterodactyl\Models\Subuser $model */
try {
$model = $repository->findFirstWhere([
['server_id', $this->route()->parameter('server')->id],
['user_id', $this->user()->id],
]);
} catch (RecordNotFoundException $exception) {
return [];
}
return $model->permissions;
}
/**
* Return the subuser model for the given request which can then be validated. If
* required request parameters are missing a 404 error will be returned, otherwise
* a model exception will be returned if the model is not found.
*
* This returns the subuser based on the endpoint being hit, not the actual subuser
* for the account making the request.
*
* @return \Pterodactyl\Models\Subuser
*
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
public function endpointSubuser()
{
/** @var \Pterodactyl\Repositories\Eloquent\SubuserRepository $repository */
$repository = $this->container->make(SubuserRepository::class);
$parameters = $this->route()->parameters();
if (
! isset($parameters['server'], $parameters['server'])
|| ! is_string($parameters['subuser'])
|| ! $parameters['server'] instanceof Server
) {
throw new NotFoundHttpException;
}
return $this->model ?: $this->model = $repository->getUserForServer(
$parameters['server']->id, $parameters['subuser']
);
}
}

View file

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

View file

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

View file

@ -2,6 +2,8 @@
namespace Pterodactyl\Models;
use Pterodactyl\Rules\ResolvesToIPAddress;
class DatabaseHost extends Model
{
/**
@ -51,13 +53,25 @@ class DatabaseHost extends Model
*/
public static $validationRules = [
'name' => 'required|string|max:255',
'host' => 'required|unique:database_hosts,host',
'host' => 'required|string',
'port' => 'required|numeric|between:1,65535',
'username' => 'required|string|max:32',
'password' => 'nullable|string',
'node_id' => 'sometimes|nullable|integer|exists:nodes,id',
];
/**
* @return array
*/
public static function getRules()
{
$rules = parent::getRules();
$rules['host'] = array_merge($rules['host'], [ new ResolvesToIPAddress() ]);
return $rules;
}
/**
* Gets the node associated with a database host.
*

View file

@ -2,6 +2,27 @@
namespace Pterodactyl\Models;
/**
* @property int $id
* @property int $egg_id
* @property string $name
* @property string $description
* @property string $env_variable
* @property string $default_value
* @property bool $user_viewable
* @property bool $user_editable
* @property string $rules
* @property \Carbon\CarbonImmutable $created_at
* @property \Carbon\CarbonImmutable $updated_at
*
* @property bool $required
* @property \Pterodactyl\Models\Egg $egg
* @property \Pterodactyl\Models\ServerVariable $serverVariable
*
* The "server_value" variable is only present on the object if you've loaded this model
* using the server relationship.
* @property string|null $server_value
*/
class EggVariable extends Model
{
/**
@ -17,6 +38,11 @@ class EggVariable extends Model
*/
const RESERVED_ENV_NAMES = 'SERVER_MEMORY,SERVER_IP,SERVER_PORT,ENV,HOME,USER,STARTUP,SERVER_UUID,UUID';
/**
* @var bool
*/
protected $immutableDates = true;
/**
* The table associated with the model.
*
@ -38,8 +64,8 @@ class EggVariable extends Model
*/
protected $casts = [
'egg_id' => 'integer',
'user_viewable' => 'integer',
'user_editable' => 'integer',
'user_viewable' => 'bool',
'user_editable' => 'bool',
];
/**
@ -65,12 +91,19 @@ class EggVariable extends Model
];
/**
* @param $value
* @return bool
*/
public function getRequiredAttribute($value)
public function getRequiredAttribute()
{
return $this->rules === 'required' || str_contains($this->rules, ['required|', '|required']);
return in_array('required', explode('|', $this->rules));
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function egg()
{
return $this->hasOne(Egg::class);
}
/**

View file

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

View file

@ -38,14 +38,14 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property \Carbon\Carbon $updated_at
*
* @property \Pterodactyl\Models\User $user
* @property \Pterodactyl\Models\User[]|\Illuminate\Database\Eloquent\Collection $subusers
* @property \Pterodactyl\Models\Subuser[]|\Illuminate\Database\Eloquent\Collection $subusers
* @property \Pterodactyl\Models\Allocation $allocation
* @property \Pterodactyl\Models\Allocation[]|\Illuminate\Database\Eloquent\Collection $allocations
* @property \Pterodactyl\Models\Pack|null $pack
* @property \Pterodactyl\Models\Node $node
* @property \Pterodactyl\Models\Nest $nest
* @property \Pterodactyl\Models\Egg $egg
* @property \Pterodactyl\Models\ServerVariable[]|\Illuminate\Database\Eloquent\Collection $variables
* @property \Pterodactyl\Models\EggVariable[]|\Illuminate\Database\Eloquent\Collection $variables
* @property \Pterodactyl\Models\Schedule[]|\Illuminate\Database\Eloquent\Collection $schedule
* @property \Pterodactyl\Models\Database[]|\Illuminate\Database\Eloquent\Collection $databases
* @property \Pterodactyl\Models\Location $location
@ -270,7 +270,9 @@ class Server extends Model
*/
public function variables()
{
return $this->hasMany(ServerVariable::class);
return $this->hasMany(EggVariable::class, 'egg_id', 'egg_id')
->select(['egg_variables.*', 'server_variables.variable_value as server_value'])
->leftJoin('server_variables', 'server_variables.variable_id', '=', 'egg_variables.id');
}
/**

View file

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

View file

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

View file

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

View file

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

View file

@ -18,30 +18,6 @@ class SubuserRepository extends EloquentRepository implements SubuserRepositoryI
return Subuser::class;
}
/**
* Returns a subuser model for the given user and server combination. If no record
* exists an exception will be thrown.
*
* @param int $server
* @param string $uuid
* @return \Pterodactyl\Models\Subuser
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException
*/
public function getUserForServer(int $server, string $uuid): Subuser
{
/** @var \Pterodactyl\Models\Subuser $model */
$model = $this->getBuilder()
->with('server', 'user')
->select('subusers.*')
->join('users', 'users.id', '=', 'subusers.user_id')
->where('subusers.server_id', $server)
->where('users.uuid', $uuid)
->firstOrFail();
return $model;
}
/**
* Return a subuser with the associated server relationship.
*

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,6 @@
namespace Pterodactyl\Services\Backups;
use Carbon\Carbon;
use Ramsey\Uuid\Uuid;
use Carbon\CarbonImmutable;
use Webmozart\Assert\Assert;
@ -101,14 +100,14 @@ class InitiateBackupService
public function handle(Server $server, string $name = null): Backup
{
// 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);
}
$previous = $this->repository->getBackupsGeneratedDuringTimespan($server->id, 10);
if ($previous->count() >= 2) {
throw new TooManyRequestsHttpException(
Carbon::now()->diffInSeconds($previous->last()->created_at->addMinutes(10)),
CarbonImmutable::now()->diffInSeconds($previous->last()->created_at->addMinutes(10)),
'Only two backups may be generated within a 10 minute span of time.'
);
}

View file

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

View file

@ -30,7 +30,7 @@ class GetUserPermissionsService
}
/** @var \Pterodactyl\Models\Subuser|null $subuserPermissions */
$subuserPermissions = $server->subusers->where('user_id', $user->id)->first();
$subuserPermissions = $server->subusers()->where('user_id', $user->id)->first();
return $subuserPermissions ? $subuserPermissions->permissions : [];
}

View file

@ -0,0 +1,27 @@
<?php
namespace Pterodactyl\Services\Servers;
use Pterodactyl\Models\Server;
class StartupCommandService
{
/**
* Generates a startup command for a given server instance.
*
* @param \Pterodactyl\Models\Server $server
* @return string
*/
public function handle(Server $server): string
{
$find = ['{{SERVER_MEMORY}}', '{{SERVER_IP}}', '{{SERVER_PORT}}'];
$replace = [$server->memory, $server->allocation->ip, $server->allocation->port];
foreach ($server->variables as $variable) {
$find[] = '{{' . $variable->env_variable . '}}';
$replace[] = $variable->user_viewable ? ($variable->server_value ?? $variable->default_value) : '[hidden]';
}
return str_replace($find, $replace, $server->startup);
}
}

View file

@ -1,56 +0,0 @@
<?php
namespace Pterodactyl\Services\Servers;
use Illuminate\Support\Collection;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
class StartupCommandViewService
{
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* StartupCommandViewService constructor.
*
* @param \Pterodactyl\Contracts\Repository\ServerRepositoryInterface $repository
*/
public function __construct(ServerRepositoryInterface $repository)
{
$this->repository = $repository;
}
/**
* Generate a startup command for a server and return all of the user-viewable variables
* as well as their assigned values.
*
* @param int $server
* @return \Illuminate\Support\Collection
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
*/
public function handle(int $server): Collection
{
$response = $this->repository->getVariablesWithValues($server, true);
$server = $this->repository->getPrimaryAllocation($response->server);
$find = ['{{SERVER_MEMORY}}', '{{SERVER_IP}}', '{{SERVER_PORT}}'];
$replace = [$server->memory, $server->getRelation('allocation')->ip, $server->getRelation('allocation')->port];
$variables = $server->getRelation('egg')->getRelation('variables')
->each(function ($variable) use (&$find, &$replace, $response) {
$find[] = '{{' . $variable->env_variable . '}}';
$replace[] = $variable->user_viewable ? $response->data[$variable->env_variable] : '[hidden]';
})->filter(function ($variable) {
return $variable->user_viewable === 1;
});
return collect([
'startup' => str_replace($find, $replace, $server->startup),
'variables' => $variables,
'server_values' => $response->data,
]);
}
}

View file

@ -22,6 +22,7 @@ class BackupTransformer extends BaseClientTransformer
{
return [
'uuid' => $backup->uuid,
'is_successful' => $backup->is_successful,
'name' => $backup->name,
'ignored_files' => $backup->ignored_files,
'sha256_hash' => $backup->sha256_hash,

View file

@ -4,6 +4,7 @@ namespace Pterodactyl\Transformers\Api\Client;
use Pterodactyl\Models\Database;
use League\Fractal\Resource\Item;
use Pterodactyl\Models\Permission;
use Illuminate\Contracts\Encryption\Encrypter;
use Pterodactyl\Contracts\Extensions\HashidsInterface;
@ -65,12 +66,16 @@ class DatabaseTransformer extends BaseClientTransformer
/**
* Include the database password in the request.
*
* @param \Pterodactyl\Models\Database $model
* @return \League\Fractal\Resource\Item
* @param \Pterodactyl\Models\Database $database
* @return \League\Fractal\Resource\Item|\League\Fractal\Resource\NullResource
*/
public function includePassword(Database $model): Item
public function includePassword(Database $database): Item
{
return $this->item($model, function (Database $model) {
if (!$this->getUser()->can(Permission::ACTION_DATABASE_VIEW_PASSWORD, $database->server)) {
return $this->null();
}
return $this->item($database, function (Database $model) {
return [
'password' => $this->encrypter->decrypt($model->password),
];

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddBackupStateColumnToBackups extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('backups', function (Blueprint $table) {
$table->boolean('is_successful')->after('uuid')->default(true);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('backups', function (Blueprint $table) {
$table->dropColumn('is_successful');
});
}
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class UpdateBytesToUnsignedBigint extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('backups', function (Blueprint $table) {
$table->unsignedBigInteger('bytes')->default(0)->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('backups', function (Blueprint $table) {
$table->integer('bytes')->default(0)->change();
});
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,32 +0,0 @@
import http, { FractalResponseData, getPaginationSet, PaginatedResult } from '@/api/http';
export interface ServerBackup {
uuid: string;
name: string;
ignoredFiles: string;
sha256Hash: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}
export const rawDataToServerBackup = ({ attributes }: FractalResponseData): ServerBackup => ({
uuid: attributes.uuid,
name: attributes.name,
ignoredFiles: attributes.ignored_files,
sha256Hash: attributes.sha256_hash,
bytes: attributes.bytes,
createdAt: new Date(attributes.created_at),
completedAt: attributes.completed_at ? new Date(attributes.completed_at) : null,
});
export default (uuid: string, page?: number | string): Promise<PaginatedResult<ServerBackup>> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/backups`, { params: { page } })
.then(({ data }) => resolve({
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
}))
.catch(reject);
});
};

View file

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

View file

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

View file

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

View file

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

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

@ -0,0 +1,20 @@
export interface ServerBackup {
uuid: string;
isSuccessful: boolean;
name: string;
ignoredFiles: string;
sha256Hash: string;
bytes: number;
createdAt: Date;
completedAt: Date | null;
}
export interface ServerEggVariable {
name: string;
description: string;
envVariable: string;
defaultValue: string;
serverValue: string;
isEditable: boolean;
rules: string[];
}

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
import { ServerEggVariable } from '@/api/server/types';
import { rawDataToServerEggVariable } from '@/api/transformers';
export default async (uuid: string, key: string, value: string): Promise<ServerEggVariable> => {
const { data } = await http.put(`/api/client/servers/${uuid}/startup/variable`, { key, value });
return rawDataToServerEggVariable(data);
};

View file

@ -0,0 +1,18 @@
import useSWR from 'swr';
import http, { getPaginationSet, PaginatedResult } from '@/api/http';
import { ServerBackup } from '@/api/server/types';
import { rawDataToServerBackup } from '@/api/transformers';
import useServer from '@/plugins/useServer';
export default (page?: number | string) => {
const { uuid } = useServer();
return useSWR<PaginatedResult<ServerBackup>>([ 'server:backups', uuid, page ], async () => {
const { data } = await http.get(`/api/client/servers/${uuid}/backups`, { params: { page } });
return ({
items: (data.data || []).map(rawDataToServerBackup),
pagination: getPaginationSet(data.meta.pagination),
});
});
};

View file

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

View file

@ -32,4 +32,41 @@ export default createGlobalStyle`
input[type=number] {
-moz-appearance: textfield !important;
}
/* Scroll Bar Style */
::-webkit-scrollbar {
background: none;
width: 16px;
height: 16px;
}
::-webkit-scrollbar-thumb {
border: solid 0 rgb(0 0 0 / 0%);
border-right-width: 4px;
border-left-width: 4px;
-webkit-border-radius: 9px 4px;
-webkit-box-shadow: inset 0 0 0 1px hsl(211, 10%, 53%), inset 0 0 0 4px hsl(209deg 18% 30%);
}
::-webkit-scrollbar-track-piece {
margin: 4px 0;
}
::-webkit-scrollbar-thumb:horizontal {
border-right-width: 0;
border-left-width: 0;
border-top-width: 4px;
border-bottom-width: 4px;
-webkit-border-radius: 4px 9px;
}
::-webkit-scrollbar-thumb:hover {
-webkit-box-shadow:
inset 0 0 0 1px hsl(212, 92%, 43%),
inset 0 0 0 4px hsl(212, 92%, 43%);
}
::-webkit-scrollbar-corner {
background: transparent;
}
`;

View file

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

View file

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

View file

@ -1,41 +1,67 @@
import React, { useRef } from 'react';
import React, { useRef, useState } from 'react';
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 { ActionCreator, Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { FormikProps, withFormik } from 'formik';
import { useStoreState } from 'easy-peasy';
import { Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
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 Button from '@/components/elements/Button';
import Reaptcha from 'reaptcha';
import useFlash from '@/plugins/useFlash';
type OwnProps = RouteComponentProps & {
clearFlashes: ActionCreator<void>;
addFlash: ActionCreator<FlashMessage>;
interface Values {
username: string;
password: string;
}
const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handleSubmit }: OwnProps & FormikProps<LoginData>) => {
const ref = useRef<ReCAPTCHA | null>(null);
const { enabled: recaptchaEnabled, siteKey } = useStoreState<ApplicationStore, any>(state => state.settings.data!.recaptcha);
const LoginContainer = ({ history }: RouteComponentProps) => {
const ref = useRef<Reaptcha>(null);
const [ token, setToken ] = useState('');
const submit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { enabled: recaptchaEnabled, siteKey } = useStoreState(state => state.settings.data!.recaptcha);
if (ref.current && !values.recaptchaData) {
return ref.current.execute();
const onSubmit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
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 })
.then(response => {
if (response.complete) {
// @ts-ignore
window.location = response.intended || '/';
return;
}
history.replace('/auth/login/checkpoint', { token: response.confirmationToken });
})
.catch(error => {
console.error(error);
setSubmitting(false);
clearAndAddHttpError({ error });
});
};
return (
<React.Fragment>
{ref.current && ref.current.render()}
<LoginFormContainer title={'Login to Continue'} css={tw`w-full flex`} onSubmit={submit}>
<Formik
onSubmit={onSubmit}
initialValues={{ username: '', password: '' }}
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'}
@ -58,16 +84,18 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
</Button>
</div>
{recaptchaEnabled &&
<ReCAPTCHA
<Reaptcha
ref={ref}
size={'invisible'}
sitekey={siteKey || '_invalid_key'}
onChange={token => {
ref.current && ref.current.reset();
setFieldValue('recaptchaData', token);
onVerify={response => {
setToken(response);
submitForm();
}}
onExpired={() => setFieldValue('recaptchaData', null)}
onExpire={() => {
setSubmitting(false);
setToken('');
}}
/>
}
<div css={tw`mt-6 text-center`}>
@ -79,54 +107,9 @@ const LoginContainer = ({ isSubmitting, setFieldValue, values, submitForm, handl
</Link>
</div>
</LoginFormContainer>
</React.Fragment>
)}
</Formik>
);
};
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 => {
if (response.complete) {
// @ts-ignore
window.location = response.intended || '/';
return;
}
props.history.replace('/auth/login/checkpoint', { token: response.confirmationToken });
})
.catch(error => {
console.error(error);
setSubmitting(false);
setFieldValue('recaptchaData', null);
props.addFlash({ type: 'error', title: 'Error', message: httpErrorToHuman(error) });
});
},
})(LoginContainer);
export default (props: RouteComponentProps) => {
const { clearFlashes, addFlash } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
return (
<EnhancedForm
{...props}
addFlash={addFlash}
clearFlashes={clearFlashes}
/>
);
};
export default LoginContainer;

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import ContentBox from '@/components/elements/ContentBox';
import CreateApiKeyForm from '@/components/dashboard/forms/CreateApiKeyForm';
import getApiKeys, { ApiKey } from '@/api/account/getApiKeys';
@ -7,7 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faKey, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import ConfirmationModal from '@/components/elements/ConfirmationModal';
import deleteApiKey from '@/api/account/deleteApiKey';
import { Actions, useStoreActions } from 'easy-peasy';
import { Actions, useStoreActions, useStoreState } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import FlashMessageRender from '@/components/FlashMessageRender';
import { httpErrorToHuman } from '@/api/http';
@ -21,6 +22,7 @@ export default () => {
const [ keys, setKeys ] = useState<ApiKey[]>([]);
const [ loading, setLoading ] = useState(true);
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
const name = useStoreState((state: ApplicationStore) => state.settings.data!.name);
useEffect(() => {
clearFlashes('account');
@ -49,6 +51,9 @@ export default () => {
return (
<PageContentBlock>
<Helmet>
<title> {name} | API</title>
</Helmet>
<FlashMessageRender byKey={'account'} css={tw`mb-4`}/>
<div css={tw`flex`}>
<ContentBox title={'Create API Key'} css={tw`flex-1`}>
@ -56,21 +61,19 @@ export default () => {
</ContentBox>
<ContentBox title={'API Keys'} css={tw`ml-10 flex-1`}>
<SpinnerOverlay visible={loading}/>
{deleteIdentifier &&
<ConfirmationModal
visible
visible={!!deleteIdentifier}
title={'Confirm key deletion'}
buttonText={'Yes, delete key'}
onConfirmed={() => {
doDeletion(deleteIdentifier);
setDeleteIdentifier('');
}}
onDismissed={() => setDeleteIdentifier('')}
onModalDismissed={() => setDeleteIdentifier('')}
>
Are you sure you wish to delete this API key? All requests using it will immediately be
invalidated and will fail.
</ConfirmationModal>
}
{
keys.length === 0 ?
<p css={tw`text-center text-sm`}>

View file

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

View file

@ -0,0 +1,38 @@
import React, { useContext } from 'react';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import asModal from '@/hoc/asModal';
import ModalContext from '@/context/ModalContext';
interface Props {
apiKey: string;
}
const ApiKeyModal = ({ apiKey }: Props) => {
const { dismiss } = useContext(ModalContext);
return (
<>
<h3 css={tw`mb-6`}>Your API Key</h3>
<p css={tw`text-sm mb-6`}>
The API key you have requested is shown below. Please store this in a safe location, it will not be
shown again.
</p>
<pre css={tw`text-sm bg-neutral-900 rounded py-2 px-4 font-mono`}>
<code css={tw`font-mono`}>{apiKey}</code>
</pre>
<div css={tw`flex justify-end mt-6`}>
<Button type={'button'} onClick={() => dismiss()}>
Close
</Button>
</div>
</>
);
};
ApiKeyModal.displayName = 'ApiKeyModal';
export default asModal<Props>({
closeOnEscape: false,
closeOnBackground: false,
})(ApiKeyModal);

View file

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

View file

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

View file

@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { Field, Form, Formik, FormikHelpers } from 'formik';
import { object, string } from 'yup';
import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import Modal from '@/components/elements/Modal';
import createApiKey from '@/api/account/createApiKey';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
@ -12,12 +11,16 @@ import { ApiKey } from '@/api/account/getApiKeys';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import Input, { Textarea } from '@/components/elements/Input';
import styled from 'styled-components/macro';
import ApiKeyModal from '@/components/dashboard/ApiKeyModal';
interface Values {
description: string;
allowedIps: string;
}
const CustomTextarea = styled(Textarea)`${tw`h-32`}`;
export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
const [ apiKey, setApiKey ] = useState('');
const { addError, clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
@ -41,35 +44,14 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
return (
<>
<Modal
<ApiKeyModal
visible={apiKey.length > 0}
onDismissed={() => setApiKey('')}
closeOnEscape={false}
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>
onModalDismissed={() => setApiKey('')}
apiKey={apiKey}
/>
<Formik
onSubmit={submit}
initialValues={{
description: '',
allowedIps: '',
}}
initialValues={{ description: '', allowedIps: '' }}
validationSchema={object().shape({
allowedIps: string(),
description: string().required().min(4),
@ -91,7 +73,7 @@ export default ({ onKeyCreated }: { onKeyCreated: (key: ApiKey) => void }) => {
name={'allowedIps'}
description={'Leave blank to allow any IP address to use this API key, otherwise provide each IP address on a new line.'}
>
<Field as={Textarea} name={'allowedIps'} css={tw`h-32`}/>
<Field name={'allowedIps'} as={CustomTextarea}/>
</FormikFieldWrapper>
<div css={tw`flex justify-end mt-6`}>
<Button>Create</Button>

View file

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

View file

@ -2,8 +2,6 @@ import React, { useCallback, useEffect, useState } from 'react';
import ace, { Editor } from 'brace';
import styled from 'styled-components/macro';
import tw from 'twin.macro';
import Select from '@/components/elements/Select';
// @ts-ignore
import modes from '@/modes';
// @ts-ignore
@ -21,42 +19,38 @@ const EditorContainer = styled.div`
`;
Object.keys(modes).forEach(mode => require(`brace/mode/${mode}`));
const modelist = ace.acequire('ace/ext/modelist');
export interface Props {
style?: React.CSSProperties;
initialContent?: string;
initialModePath?: string;
mode: string;
filename?: string;
onModeChanged: (mode: string) => void;
fetchContent: (callback: () => Promise<string>) => void;
onContentSaved: (content: string) => void;
}
export default ({ style, initialContent, initialModePath, fetchContent, onContentSaved }: Props) => {
const [ mode, setMode ] = useState('ace/mode/plain_text');
export default ({ style, initialContent, filename, mode, fetchContent, onContentSaved, onModeChanged }: Props) => {
const [ editor, setEditor ] = useState<Editor>();
const ref = useCallback(node => {
if (node) {
setEditor(ace.edit('editor'));
}
if (node) setEditor(ace.edit('editor'));
}, []);
useEffect(() => {
editor && editor.session.setMode(mode);
if (modelist && filename) {
onModeChanged(modelist.getModeForPath(filename).mode.replace(/^ace\/mode\//, ''));
}
}, [ filename ]);
useEffect(() => {
editor && editor.session.setMode(`ace/mode/${mode}`);
}, [ editor, mode ]);
useEffect(() => {
editor && editor.session.setValue(initialContent || '');
}, [ editor, initialContent ]);
useEffect(() => {
if (initialModePath) {
const modelist = ace.acequire('ace/ext/modelist');
if (modelist) {
setMode(modelist.getModeForPath(initialModePath).mode);
}
}
}, [ initialModePath ]);
useEffect(() => {
if (!editor) {
fetchContent(() => Promise.reject(new Error('no editor session has been configured')));
@ -85,20 +79,6 @@ export default ({ style, initialContent, initialModePath, fetchContent, onConten
return (
<EditorContainer style={style}>
<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>
);
};

View file

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

View file

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

View file

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

View file

@ -3,10 +3,26 @@ import ContentContainer from '@/components/elements/ContentContainer';
import { CSSTransition } from 'react-transition-group';
import tw from 'twin.macro';
import FlashMessageRender from '@/components/FlashMessageRender';
import { Helmet } from 'react-helmet';
import useServer from '@/plugins/useServer';
const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }> = ({ children, showFlashKey, className }) => (
interface Props {
title?: string;
className?: string;
showFlashKey?: string;
}
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>
}
<ContentContainer css={tw`my-10`} className={className}>
{showFlashKey &&
<FlashMessageRender byKey={showFlashKey} css={tw`mb-4`}/>
@ -28,6 +44,7 @@ const PageContentBlock: React.FC<{ showFlashKey?: string; className?: string }>
</ContentContainer>
</>
</CSSTransition>
);
);
};
export default PageContentBlock;

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import React, { lazy, useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { ServerContext } from '@/state/server';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCircle, faHdd, faMemory, faMicrochip, faServer } from '@fortawesome/free-solid-svg-icons';
@ -61,6 +62,9 @@ export default () => {
return (
<PageContentBlock css={tw`flex`}>
<Helmet>
<title> {server.name} | Console </title>
</Helmet>
<div css={tw`w-1/4`}>
<TitledGreyBox title={server.name} icon={faServer}>
<p css={tw`text-xs uppercase`}>

View file

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

View file

@ -1,50 +1,49 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import Spinner from '@/components/elements/Spinner';
import getServerBackups from '@/api/server/backups/getServerBackups';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can';
import CreateBackupButton from '@/components/server/backups/CreateBackupButton';
import FlashMessageRender from '@/components/FlashMessageRender';
import BackupRow from '@/components/server/backups/BackupRow';
import { ServerContext } from '@/state/server';
import PageContentBlock from '@/components/elements/PageContentBlock';
import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
export default () => {
const { uuid, featureLimits } = useServer();
const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { featureLimits, name: serverName } = useServer();
const backups = ServerContext.useStoreState(state => state.backups.data);
const setBackups = ServerContext.useStoreActions(actions => actions.backups.setBackups);
const { data: backups, error, isValidating } = getServerBackups();
useEffect(() => {
if (!error) {
clearFlashes('backups');
getServerBackups(uuid)
.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 (
<PageContentBlock>
<Helmet>
<title>{serverName} | Backups</title>
</Helmet>
<FlashMessageRender byKey={'backups'} css={tw`mb-4`}/>
{!backups.length ?
{!backups.items.length ?
<p css={tw`text-center text-sm text-neutral-400`}>
There are no backups stored for this server.
</p>
:
<div>
{backups.map((backup, index) => <BackupRow
{backups.items.map((backup, index) => <BackupRow
key={backup.uuid}
backup={backup}
css={index > 0 ? tw`mt-2` : undefined}
@ -52,17 +51,17 @@ export default () => {
</div>
}
{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.
</p>
}
<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`}>
{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>
}
{featureLimits.backups > 0 && featureLimits.backups !== backups.length &&
{featureLimits.backups > 0 && featureLimits.backups !== backups.items.length &&
<div css={tw`mt-6 flex justify-end`}>
<CreateBackupButton/>
</div>

View file

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

View file

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

View file

@ -7,12 +7,11 @@ import FormikFieldWrapper from '@/components/elements/FormikFieldWrapper';
import useFlash from '@/plugins/useFlash';
import useServer from '@/plugins/useServer';
import createServerBackup from '@/api/server/backups/createServerBackup';
import { httpErrorToHuman } from '@/api/http';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ServerContext } from '@/state/server';
import Button from '@/components/elements/Button';
import tw from 'twin.macro';
import { Textarea } from '@/components/elements/Input';
import getServerBackups from '@/api/swr/getServerBackups';
interface Values {
name: string;
@ -49,7 +48,7 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
</FormikFieldWrapper>
</div>
<div css={tw`flex justify-end`}>
<Button type={'submit'}>
<Button type={'submit'} disabled={isSubmitting}>
Start backup
</Button>
</div>
@ -60,10 +59,9 @@ const ModalContent = ({ ...props }: RequiredModalProps) => {
export default () => {
const { uuid } = useServer();
const { addError, clearFlashes } = useFlash();
const { clearFlashes, clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false);
const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup);
const { mutate } = getServerBackups();
useEffect(() => {
clearFlashes('backups:create');
@ -73,12 +71,11 @@ export default () => {
clearFlashes('backups:create');
createServerBackup(uuid, name, ignored)
.then(backup => {
appendBackup(backup);
mutate(data => ({ ...data, items: data.items.concat(backup) }), false);
setVisible(false);
})
.catch(error => {
console.error(error);
addError({ key: 'backups:create', message: httpErrorToHuman(error) });
clearAndAddHttpError({ key: 'backups:create', error });
setSubmitting(false);
});
};
@ -94,11 +91,7 @@ export default () => {
ignored: string(),
})}
>
<ModalContent
appear
visible={visible}
onDismissed={() => setVisible(false)}
/>
<ModalContent appear visible={visible} onDismissed={() => setVisible(false)}/>
</Formik>
}
<Button onClick={() => setVisible(true)}>

View file

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import getServerDatabases from '@/api/server/getServerDatabases';
import { ServerContext } from '@/state/server';
import { httpErrorToHuman } from '@/api/http';
@ -14,7 +15,7 @@ import tw from 'twin.macro';
import Fade from '@/components/elements/Fade';
export default () => {
const { uuid, featureLimits } = useServer();
const { uuid, featureLimits, name: serverName } = useServer();
const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(true);
@ -36,6 +37,9 @@ export default () => {
return (
<PageContentBlock>
<Helmet>
<title> {serverName} | Databases </title>
</Helmet>
<FlashMessageRender byKey={'databases'} css={tw`mb-4`}/>
{(!databases.length && loading) ?
<Spinner size={'large'} centered/>

View file

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

View file

@ -1,6 +1,7 @@
import React, { useRef, useState } from 'react';
import React, { memo, useRef, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faBoxOpen,
faCopy,
faEllipsisH,
faFileArchive,
@ -27,6 +28,8 @@ import DropdownMenu from '@/components/elements/DropdownMenu';
import styled from 'styled-components/macro';
import useEventListener from '@/plugins/useEventListener';
import compressFiles from '@/api/server/files/compressFiles';
import decompressFiles from '@/api/server/files/decompressFiles';
import isEqual from 'react-fast-compare';
type ModalType = 'rename' | 'move';
@ -43,12 +46,12 @@ interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
const Row = ({ icon, title, ...props }: RowProps) => (
<StyledRow {...props}>
<FontAwesomeIcon icon={icon} css={tw`text-xs`}/>
<FontAwesomeIcon icon={icon} css={tw`text-xs`} fixedWidth/>
<span css={tw`ml-2`}>{title}</span>
</StyledRow>
);
export default ({ file }: { file: FileObject }) => {
const FileDropdownMenu = ({ file }: { file: FileObject }) => {
const onClickRef = useRef<DropdownMenu>(null);
const [ showSpinner, setShowSpinner ] = useState(false);
const [ modal, setModal ] = useState<ModalType | null>(null);
@ -58,7 +61,7 @@ export default ({ file }: { file: FileObject }) => {
const { clearAndAddHttpError, clearFlashes } = useFlash();
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) {
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.
// 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 => {
mutate();
@ -110,6 +113,16 @@ export default ({ file }: { file: FileObject }) => {
.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 (
<DropdownMenu
ref={onClickRef}
@ -138,9 +151,15 @@ export default ({ file }: { file: FileObject }) => {
<Row onClick={doCopy} icon={faCopy} title={'Copy'}/>
</Can>
}
{file.isArchiveType() ?
<Can action={'file.create'}>
<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'}/>
<Can action={'file.delete'}>
<Row onClick={doDeletion} icon={faTrashAlt} title={'Delete'} $danger/>
@ -148,3 +167,5 @@ export default ({ file }: { file: FileObject }) => {
</DropdownMenu>
);
};
export default memo(FileDropdownMenu, isEqual);

View file

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

View file

@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import { Helmet } from 'react-helmet';
import { httpErrorToHuman } from '@/api/http';
import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner';
@ -24,17 +25,14 @@ const sortFiles = (files: FileObject[]): FileObject[] => {
};
export default () => {
const { id } = useServer();
const { id, name: serverName } = useServer();
const { hash } = useLocation();
const { data: files, error, mutate } = useFileManagerSwr();
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const setSelectedFiles = ServerContext.useStoreActions(actions => actions.files.setSelectedFiles);
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([]);
setDirectory(hash.length > 0 ? hash : '/');
}, [ hash ]);
@ -47,6 +45,9 @@ export default () => {
return (
<PageContentBlock showFlashKey={'files'}>
<Helmet>
<title> {serverName} | File Manager </title>
</Helmet>
<FileManagerBreadcrumbs/>
{
!files ?
@ -70,7 +71,7 @@ export default () => {
}
{
sortFiles(files.slice(0, 250)).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
<FileObjectRow key={file.key} file={file}/>
))
}
<MassActionsBar/>

View file

@ -1,5 +1,5 @@
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 { differenceInHours, format, formatDistanceToNow } from 'date-fns';
import React, { memo } from 'react';
@ -18,7 +18,6 @@ const Row = styled.div`
const FileObjectRow = ({ file }: { file: FileObject }) => {
const directory = ServerContext.useStoreState(state => state.files.directory);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const history = useHistory();
const match = useRouteMatch();
@ -31,9 +30,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
// Just trust me future me, leave this be.
if (!file.isFile) {
e.preventDefault();
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
}
};
@ -42,7 +39,7 @@ const FileObjectRow = ({ file }: { file: FileObject }) => {
key={file.name}
onContextMenu={e => {
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}/>
@ -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`}>
{file.isFile ?
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : faFileAlt}/>
<FontAwesomeIcon icon={file.isSymlink ? faFileImport : file.isArchiveType() ? faFileArchive : faFileAlt}/>
:
<FontAwesomeIcon icon={faFolder}/>
}

View file

@ -72,7 +72,7 @@ const MassActionsBar = () => {
title={'Delete these files?'}
buttonText={'Yes, Delete Files'}
onConfirmed={onClickConfirmDeletion}
onDismissed={() => setShowConfirm(false)}
onModalDismissed={() => setShowConfirm(false)}
>
Deleting files is a permanent operation, you cannot undo this action.
</ConfirmationModal>

View file

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

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