Merge branch 'dane/restore-backups' into develop

This commit is contained in:
Dane Everitt 2021-01-30 18:51:16 -08:00
commit 2f08456ed9
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
78 changed files with 1080 additions and 819 deletions

View file

@ -51,7 +51,7 @@ jobs:
- name: install dependencies - name: install dependencies
run: composer install --prefer-dist --no-interaction --no-progress run: composer install --prefer-dist --no-interaction --no-progress
- name: run cs-fixer - name: run cs-fixer
run: vendor/bin/php-cs-fixer fix --dry-run --diff --diff-format=udiff --rules=psr_autoloading run: vendor/bin/php-cs-fixer fix --dry-run --diff --diff-format=udiff --config .php_cs.dist
continue-on-error: true continue-on-error: true
- name: execute unit tests - name: execute unit tests
run: vendor/bin/phpunit --bootstrap bootstrap/app.php tests/Unit run: vendor/bin/phpunit --bootstrap bootstrap/app.php tests/Unit

View file

@ -2,8 +2,8 @@
namespace Pterodactyl\Exceptions\Http\Connection; namespace Pterodactyl\Exceptions\Http\Connection;
use Illuminate\Support\Arr;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Exceptions\DisplayException;
@ -17,6 +17,16 @@ class DaemonConnectionException extends DisplayException
*/ */
private $statusCode = Response::HTTP_GATEWAY_TIMEOUT; private $statusCode = Response::HTTP_GATEWAY_TIMEOUT;
/**
* Every request to the Wings instance will return a unique X-Request-Id header
* which allows for all errors to be efficiently tied to a specific request that
* triggered them, and gives users a more direct method of informing hosts when
* something goes wrong.
*
* @var string|null
*/
private $requestId;
/** /**
* Throw a displayable exception caused by a daemon connection error. * Throw a displayable exception caused by a daemon connection error.
*/ */
@ -24,23 +34,23 @@ class DaemonConnectionException extends DisplayException
{ {
/** @var \GuzzleHttp\Psr7\Response|null $response */ /** @var \GuzzleHttp\Psr7\Response|null $response */
$response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null; $response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null;
$this->requestId = $response ? $response->getHeaderLine('X-Request-Id') : null;
if ($useStatusCode) { if ($useStatusCode) {
$this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode(); $this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode();
} }
$message = trans('admin/server.exceptions.daemon_exception', [ if (is_null($response)) {
'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(), $message = 'Could not establish a connection to the machine running this server. Please try again.';
]); } else {
$message = sprintf('There was an error while communicating with the machine running this server. This error has been logged, please try again. (code: %s) (request_id: %s)', $response->getStatusCode(), $this->requestId ?? '<nil>');
}
// Attempt to pull the actual error message off the response and return that if it is not // Attempt to pull the actual error message off the response and return that if it is not
// a 500 level error. // a 500 level error.
if ($this->statusCode < 500 && !is_null($response)) { if ($this->statusCode < 500 && !is_null($response)) {
$body = $response->getBody(); $body = json_decode($response->getBody()->__toString(), true);
if (is_string($body) || (is_object($body) && method_exists($body, '__toString'))) { $message = sprintf('An error occurred on the remote host: %s. (request id: %s)', $body['error'] ?? $message, $this->requestId ?? '<nil>');
$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 $level = $this->statusCode >= 500 && $this->statusCode !== 504
@ -50,6 +60,19 @@ class DaemonConnectionException extends DisplayException
parent::__construct($message, $previous, $level); parent::__construct($message, $previous, $level);
} }
/**
* Override the default reporting method for DisplayException by just logging immediately
* here and including the specific X-Request-Id header that was returned by the call.
*
* @return void
*/
public function report()
{
Log::{$this->getErrorLevel()}($this->getPrevious(), [
'request_id' => $this->requestId,
]);
}
/** /**
* Return the HTTP status code for this exception. * Return the HTTP status code for this exception.
* *
@ -59,4 +82,12 @@ class DaemonConnectionException extends DisplayException
{ {
return $this->statusCode; return $this->statusCode;
} }
/**
* @return string|null
*/
public function getRequestId()
{
return $this->requestId;
}
} }

View file

@ -0,0 +1,30 @@
<?php
namespace Pterodactyl\Exceptions\Http\Server;
use Throwable;
use Pterodactyl\Models\Server;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
class ServerStateConflictException extends ConflictHttpException
{
/**
* Exception thrown when the server is in an unsupported state for API access or
* certain operations within the codebase.
*/
public function __construct(Server $server, Throwable $previous = null)
{
$message = 'This server is currently in an unsupported state, please try again later.';
if ($server->isSuspended()) {
$message = 'This server is currently suspended and the functionality requested is unavailable.';
} elseif (!$server->isInstalled()) {
$message = 'This server has not yet completed its installation process, please try again later.';
} elseif ($server->status === Server::STATUS_RESTORING_BACKUP) {
$message = 'This server is currently restoring from a backup, please try again later.';
} elseif (!is_null($server->transfer)) {
$message = 'This server is currently being transferred to a new machine, please try again later.';
}
parent::__construct($message, $previous);
}
}

View file

@ -1,17 +0,0 @@
<?php
namespace Pterodactyl\Exceptions\Http\Server;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ServerTransferringException extends HttpException
{
/**
* ServerTransferringException constructor.
*/
public function __construct()
{
parent::__construct(Response::HTTP_CONFLICT, 'This server is currently being transferred to a new machine, please try again laster.');
}
}

View file

@ -184,7 +184,7 @@ class ServerViewController extends Controller
*/ */
public function manage(Request $request, Server $server) public function manage(Request $request, Server $server)
{ {
if ($server->installed > 1) { if ($server->status === Server::STATUS_INSTALL_FAILED) {
throw new DisplayException('This server is in a failed install state and cannot be recovered. Please delete and re-create the server.'); throw new DisplayException('This server is in a failed install state and cannot be recovered. Please delete and re-create the server.');
} }

View file

@ -203,12 +203,12 @@ class ServersController extends Controller
*/ */
public function toggleInstall(Server $server) public function toggleInstall(Server $server)
{ {
if ($server->installed > 1) { if ($server->status === Server::STATUS_INSTALL_FAILED) {
throw new DisplayException(trans('admin/server.exceptions.marked_as_failed')); throw new DisplayException(trans('admin/server.exceptions.marked_as_failed'));
} }
$this->repository->update($server->id, [ $this->repository->update($server->id, [
'installed' => !$server->installed, 'status' => $server->isInstalled() ? Server::STATUS_INSTALLING : null,
], true, true); ], true, true);
$this->alert->success(trans('admin/server.alerts.install_toggled'))->flash(); $this->alert->success(trans('admin/server.alerts.install_toggled'))->flash();

View file

@ -2,17 +2,21 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers; namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup; use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Models\AuditLog;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Permission;
use Illuminate\Validation\UnauthorizedException;
use Pterodactyl\Services\Backups\DeleteBackupService; use Pterodactyl\Services\Backups\DeleteBackupService;
use Pterodactyl\Repositories\Eloquent\BackupRepository; use Pterodactyl\Services\Backups\DownloadLinkService;
use Pterodactyl\Services\Backups\InitiateBackupService; use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Transformers\Api\Client\BackupTransformer; use Pterodactyl\Transformers\Api\Client\BackupTransformer;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\GetBackupsRequest; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DeleteBackupRequest;
class BackupController extends ClientApiController class BackupController extends ClientApiController
{ {
@ -27,7 +31,12 @@ class BackupController extends ClientApiController
private $deleteBackupService; private $deleteBackupService;
/** /**
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository * @var \Pterodactyl\Services\Backups\DownloadLinkService
*/
private $downloadLinkService;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonBackupRepository
*/ */
private $repository; private $repository;
@ -35,25 +44,32 @@ class BackupController extends ClientApiController
* BackupController constructor. * BackupController constructor.
*/ */
public function __construct( public function __construct(
BackupRepository $repository, DaemonBackupRepository $repository,
DeleteBackupService $deleteBackupService, DeleteBackupService $deleteBackupService,
InitiateBackupService $initiateBackupService InitiateBackupService $initiateBackupService,
DownloadLinkService $downloadLinkService
) { ) {
parent::__construct(); parent::__construct();
$this->repository = $repository;
$this->initiateBackupService = $initiateBackupService; $this->initiateBackupService = $initiateBackupService;
$this->deleteBackupService = $deleteBackupService; $this->deleteBackupService = $deleteBackupService;
$this->repository = $repository; $this->downloadLinkService = $downloadLinkService;
} }
/** /**
* Returns all of the backups for a given server instance in a paginated * Returns all of the backups for a given server instance in a paginated
* result set. * result set.
* *
* @return array * @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
*/ */
public function index(GetBackupsRequest $request, Server $server) public function index(Request $request, Server $server): array
{ {
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
throw new UnauthorizedException();
}
$limit = min($request->query('per_page') ?? 20, 50); $limit = min($request->query('per_page') ?? 20, 50);
return $this->fractal->collection($server->backups()->paginate($limit)) return $this->fractal->collection($server->backups()->paginate($limit))
@ -64,17 +80,24 @@ class BackupController extends ClientApiController
/** /**
* Starts the backup process for a server. * Starts the backup process for a server.
* *
* @return array * @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
* * @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
* @throws \Exception|\Throwable * @throws \Throwable
*/ */
public function store(StoreBackupRequest $request, Server $server) public function store(StoreBackupRequest $request, Server $server): array
{ {
$backup = $this->initiateBackupService /** @var \Pterodactyl\Models\Backup $backup */
->setIgnoredFiles( $backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) {
explode(PHP_EOL, $request->input('ignored') ?? '') $backup = $this->initiateBackupService
) ->setIgnoredFiles(
->handle($server, $request->input('name')); explode(PHP_EOL, $request->input('ignored') ?? '')
)
->handle($server, $request->input('name'));
$model->metadata = ['backup_uuid' => $backup->uuid];
return $backup;
});
return $this->fractal->item($backup) return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class)) ->transformWith($this->getTransformer(BackupTransformer::class))
@ -84,10 +107,15 @@ class BackupController extends ClientApiController
/** /**
* Returns information about a single backup. * Returns information about a single backup.
* *
* @return array * @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
*/ */
public function view(GetBackupsRequest $request, Server $server, Backup $backup) public function view(Request $request, Server $server, Backup $backup): array
{ {
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
throw new UnauthorizedException();
}
return $this->fractal->item($backup) return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class)) ->transformWith($this->getTransformer(BackupTransformer::class))
->toArray(); ->toArray();
@ -97,14 +125,89 @@ class BackupController extends ClientApiController
* Deletes a backup from the panel as well as the remote source where it is currently * Deletes a backup from the panel as well as the remote source where it is currently
* being stored. * being stored.
* *
* @return \Illuminate\Http\JsonResponse * @throws \Throwable
*/
public function delete(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
throw new UnauthorizedException();
}
$server->audit(AuditLog::SERVER__BACKUP_DELETED, function (AuditLog $audit) use ($backup) {
$audit->metadata = ['backup_uuid' => $backup->uuid];
$this->deleteBackupService->handle($backup);
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Download the backup for a given server instance. For daemon local files, the file
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
* which the user is redirected to.
*/
public function download(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) {
throw new UnauthorizedException();
}
switch ($backup->disk) {
case Backup::ADAPTER_WINGS:
case Backup::ADAPTER_AWS_S3:
return new JsonResponse([
'object' => 'signed_url',
'attributes' => ['url' => ''],
]);
default:
throw new BadRequestHttpException();
}
}
/**
* Handles restoring a backup by making a request to the Wings instance telling it
* to begin the process of finding (or downloading) the backup and unpacking it
* over the server files.
*
* If the "truncate" flag is passed through in this request then all of the
* files that currently exist on the server will be deleted before restoring.
* Otherwise the archive will simply be unpacked over the existing files.
* *
* @throws \Throwable * @throws \Throwable
*/ */
public function delete(DeleteBackupRequest $request, Server $server, Backup $backup) public function restore(Request $request, Server $server, Backup $backup): JsonResponse
{ {
$this->deleteBackupService->handle($backup); if (!$request->user()->can(Permission::ACTION_BACKUP_RESTORE, $server)) {
throw new UnauthorizedException();
}
return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT); // Cannot restore a backup unless a server is fully installed and not currently
// processing a different backup restoration request.
if (!is_null($server->status)) {
throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.');
}
if (!$backup->is_successful && !$backup->completed_at) {
throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.');
}
$server->audit(AuditLog::SERVER__BACKUP_RESTORE_STARTED, function (AuditLog $audit, Server $server) use ($backup, $request) {
$audit->metadata = ['backup_uuid' => $backup->uuid];
// If the backup is for an S3 file we need to generate a unique Download link for
// it that will allow Wings to actually access the file.
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
$url = $this->downloadLinkService->handle($backup, $request->user());
}
// Update the status right away for the server so that we know not to allow certain
// actions against it via the Panel API.
$server->update(['status' => Server::STATUS_RESTORING_BACKUP]);
$this->repository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate') === 'true');
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
} }
} }

View file

@ -1,131 +0,0 @@
<?php
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Illuminate\Contracts\Routing\ResponseFactory;
use Pterodactyl\Extensions\Backups\BackupManager;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest;
class DownloadBackupController extends ClientApiController
{
/**
* @var \Pterodactyl\Repositories\Wings\DaemonBackupRepository
*/
private $daemonBackupRepository;
/**
* @var \Illuminate\Contracts\Routing\ResponseFactory
*/
private $responseFactory;
/**
* @var \Pterodactyl\Services\Nodes\NodeJWTService
*/
private $jwtService;
/**
* @var \Pterodactyl\Extensions\Backups\BackupManager
*/
private $backupManager;
/**
* DownloadBackupController constructor.
*/
public function __construct(
DaemonBackupRepository $daemonBackupRepository,
NodeJWTService $jwtService,
BackupManager $backupManager,
ResponseFactory $responseFactory
) {
parent::__construct();
$this->daemonBackupRepository = $daemonBackupRepository;
$this->responseFactory = $responseFactory;
$this->jwtService = $jwtService;
$this->backupManager = $backupManager;
}
/**
* Download the backup for a given server instance. For daemon local files, the file
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
* which the user is redirected to.
*
* @return \Illuminate\Http\JsonResponse
*/
public function __invoke(DownloadBackupRequest $request, Server $server, Backup $backup)
{
switch ($backup->disk) {
case Backup::ADAPTER_WINGS:
$url = $this->getLocalBackupUrl($backup, $server, $request->user());
break;
case Backup::ADAPTER_AWS_S3:
$url = $this->getS3BackupUrl($backup, $server);
break;
default:
throw new BadRequestHttpException();
}
return new JsonResponse([
'object' => 'signed_url',
'attributes' => [
'url' => $url,
],
]);
}
/**
* Returns a signed URL that allows us to download a file directly out of a non-public
* S3 bucket by using a signed URL.
*
* @return string
*/
protected function getS3BackupUrl(Backup $backup, Server $server)
{
/** @var \League\Flysystem\AwsS3v3\AwsS3Adapter $adapter */
$adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3);
$client = $adapter->getClient();
$request = $client->createPresignedRequest(
$client->getCommand('GetObject', [
'Bucket' => $adapter->getBucket(),
'Key' => sprintf('%s/%s.tar.gz', $server->uuid, $backup->uuid),
'ContentType' => 'application/x-gzip',
]),
CarbonImmutable::now()->addMinutes(5)
);
return $request->getUri()->__toString();
}
/**
* Returns a download link a backup stored on a wings instance.
*
* @return string
*/
protected function getLocalBackupUrl(Backup $backup, Server $server, User $user)
{
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setClaims([
'backup_uuid' => $backup->uuid,
'server_uuid' => $server->uuid,
])
->handle($server->node, $user->id . $server->uuid);
return sprintf(
'%s/download/backup?token=%s',
$server->node->getConnectionAddress(),
$token->toString()
);
}
}

View file

@ -5,6 +5,7 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Models\AuditLog;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Services\Nodes\NodeJWTService; use Pterodactyl\Services\Nodes\NodeJWTService;
use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Contracts\Routing\ResponseFactory;
@ -74,19 +75,16 @@ class FileController extends ClientApiController
/** /**
* Return the contents of a specified file for the user. * Return the contents of a specified file for the user.
* *
* @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException * @throws \Throwable
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/ */
public function contents(GetFileContentsRequest $request, Server $server): Response public function contents(GetFileContentsRequest $request, Server $server): Response
{ {
return new Response( $response = $this->fileRepository->setServer($server)->getContent(
$this->fileRepository->setServer($server)->getContent( $request->get('file'),
$request->get('file'), config('pterodactyl.files.max_edit_size')
config('pterodactyl.files.max_edit_size')
),
Response::HTTP_OK,
['Content-Type' => 'text/plain']
); );
return new Response($response, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
} }
/** /**
@ -95,17 +93,21 @@ class FileController extends ClientApiController
* *
* @return array * @return array
* *
* @throws \Exception * @throws \Throwable
*/ */
public function download(GetFileContentsRequest $request, Server $server) public function download(GetFileContentsRequest $request, Server $server)
{ {
$token = $this->jwtService $token = $server->audit(AuditLog::SERVER__FILESYSTEM_DOWNLOAD, function (AuditLog $audit, Server $server) use ($request) {
->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) $audit->metadata = ['file' => $request->get('file')];
->setClaims([
'file_path' => rawurldecode($request->get('file')), return $this->jwtService
'server_uuid' => $server->uuid, ->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
]) ->setClaims([
->handle($server->node, $request->user()->id . $server->uuid); 'file_path' => rawurldecode($request->get('file')),
'server_uuid' => $server->uuid,
])
->handle($server->node, $request->user()->id . $server->uuid);
});
return [ return [
'object' => 'signed_url', 'object' => 'signed_url',
@ -126,7 +128,14 @@ class FileController extends ClientApiController
*/ */
public function write(WriteFileContentRequest $request, Server $server): JsonResponse public function write(WriteFileContentRequest $request, Server $server): JsonResponse
{ {
$this->fileRepository->setServer($server)->putContent($request->get('file'), $request->getContent()); $server->audit(AuditLog::SERVER__FILESYSTEM_WRITE, function (AuditLog $audit, Server $server) use ($request) {
$audit->subaction = 'write_content';
$audit->metadata = ['file' => $request->get('file')];
$this->fileRepository
->setServer($server)
->putContent($request->get('file'), $request->getContent());
});
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
@ -134,13 +143,18 @@ class FileController extends ClientApiController
/** /**
* Creates a new folder on the server. * Creates a new folder on the server.
* *
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException * @throws \Throwable
*/ */
public function create(CreateFolderRequest $request, Server $server): JsonResponse public function create(CreateFolderRequest $request, Server $server): JsonResponse
{ {
$this->fileRepository $server->audit(AuditLog::SERVER__FILESYSTEM_WRITE, function (AuditLog $audit, Server $server) use ($request) {
->setServer($server) $audit->subaction = 'create_folder';
->createDirectory($request->input('name'), $request->input('root', '/')); $audit->metadata = ['file' => $request->input('root', '/') . $request->input('name')];
$this->fileRepository
->setServer($server)
->createDirectory($request->input('name'), $request->input('root', '/'));
});
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
@ -148,13 +162,17 @@ class FileController extends ClientApiController
/** /**
* Renames a file on the remote machine. * Renames a file on the remote machine.
* *
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException * @throws \Throwable
*/ */
public function rename(RenameFileRequest $request, Server $server): JsonResponse public function rename(RenameFileRequest $request, Server $server): JsonResponse
{ {
$this->fileRepository $server->audit(AuditLog::SERVER__FILESYSTEM_RENAME, function (AuditLog $audit, Server $server) use ($request) {
->setServer($server) $audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('files')];
->renameFiles($request->input('root'), $request->input('files'));
$this->fileRepository
->setServer($server)
->renameFiles($request->input('root'), $request->input('files'));
});
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
@ -166,9 +184,14 @@ class FileController extends ClientApiController
*/ */
public function copy(CopyFileRequest $request, Server $server): JsonResponse public function copy(CopyFileRequest $request, Server $server): JsonResponse
{ {
$this->fileRepository $server->audit(AuditLog::SERVER__FILESYSTEM_WRITE, function (AuditLog $audit, Server $server) use ($request) {
->setServer($server) $audit->subaction = 'copy_file';
->copyFile($request->input('location')); $audit->metadata = ['file' => $request->input('location')];
$this->fileRepository
->setServer($server)
->copyFile($request->input('location'));
});
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
@ -178,14 +201,18 @@ class FileController extends ClientApiController
*/ */
public function compress(CompressFilesRequest $request, Server $server): array public function compress(CompressFilesRequest $request, Server $server): array
{ {
// Allow up to five minutes for this request to process before timing out. $file = $server->audit(AuditLog::SERVER__FILESYSTEM_COMPRESS, function (AuditLog $audit, Server $server) use ($request) {
set_time_limit(300); // Allow up to five minutes for this request to process before timing out.
set_time_limit(300);
$file = $this->fileRepository->setServer($server) $audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('files')];
->compressFiles(
$request->input('root'), return $this->fileRepository->setServer($server)
$request->input('files') ->compressFiles(
); $request->input('root'),
$request->input('files')
);
});
return $this->fractal->item($file) return $this->fractal->item($file)
->transformWith($this->getTransformer(FileObjectTransformer::class)) ->transformWith($this->getTransformer(FileObjectTransformer::class))
@ -197,11 +224,15 @@ class FileController extends ClientApiController
*/ */
public function decompress(DecompressFilesRequest $request, Server $server): JsonResponse public function decompress(DecompressFilesRequest $request, Server $server): JsonResponse
{ {
// Allow up to five minutes for this request to process before timing out. $file = $server->audit(AuditLog::SERVER__FILESYSTEM_DECOMPRESS, function (AuditLog $audit, Server $server) use ($request) {
set_time_limit(300); // Allow up to five minutes for this request to process before timing out.
set_time_limit(300);
$this->fileRepository->setServer($server) $audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('file')];
->decompressFile($request->input('root'), $request->input('file'));
$this->fileRepository->setServer($server)
->decompressFile($request->input('root'), $request->input('file'));
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
} }
@ -213,11 +244,15 @@ class FileController extends ClientApiController
*/ */
public function delete(DeleteFileRequest $request, Server $server): JsonResponse public function delete(DeleteFileRequest $request, Server $server): JsonResponse
{ {
$this->fileRepository->setServer($server) $server->audit(AuditLog::SERVER__FILESYSTEM_DELETE, function (AuditLog $audit, Server $server) use ($request) {
->deleteFiles( $audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('files')];
$request->input('root'),
$request->input('files') $this->fileRepository->setServer($server)
); ->deleteFiles(
$request->input('root'),
$request->input('files')
);
});
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
@ -243,11 +278,15 @@ class FileController extends ClientApiController
* *
* @param $request * @param $request
* *
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException * @throws \Throwable
*/ */
public function pull(PullFileRequest $request, Server $server): JsonResponse public function pull(PullFileRequest $request, Server $server): JsonResponse
{ {
$this->fileRepository->setServer($server)->pull($request->input('url'), $request->input('directory')); $server->audit(AuditLog::SERVER__FILESYSTEM_PULL, function (AuditLog $audit, Server $server) use ($request) {
$audit->metadata = ['directory' => $request->input('directory'), 'url' => $request->input('url')];
$this->fileRepository->setServer($server)->pull($request->input('url'), $request->input('directory'));
});
return new JsonResponse([], Response::HTTP_NO_CONTENT); return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }

View file

@ -3,7 +3,10 @@
namespace Pterodactyl\Http\Controllers\Api\Remote\Backups; namespace Pterodactyl\Http\Controllers\Api\Remote\Backups;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup; use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\AuditLog;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use League\Flysystem\AwsS3v3\AwsS3Adapter; use League\Flysystem\AwsS3v3\AwsS3Adapter;
use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Exceptions\DisplayException;
@ -39,9 +42,9 @@ class BackupStatusController extends Controller
* *
* @return \Illuminate\Http\JsonResponse * @return \Illuminate\Http\JsonResponse
* *
* @throws \Exception * @throws \Throwable
*/ */
public function __invoke(ReportBackupCompleteRequest $request, string $backup) public function index(ReportBackupCompleteRequest $request, string $backup)
{ {
/** @var \Pterodactyl\Models\Backup $model */ /** @var \Pterodactyl\Models\Backup $model */
$model = Backup::query()->where('uuid', $backup)->firstOrFail(); $model = Backup::query()->where('uuid', $backup)->firstOrFail();
@ -50,21 +53,60 @@ class BackupStatusController extends Controller
throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.'); throw new BadRequestHttpException('Cannot update the status of a backup that is already marked as completed.');
} }
$successful = $request->input('successful') ? true : false; $action = $request->input('successful')
? AuditLog::SERVER__BACKUP_COMPELTED
: AuditLog::SERVER__BACKUP_FAILED;
$model->fill([ $model->server->audit($action, function (AuditLog $audit) use ($model, $request) {
'is_successful' => $successful, $audit->is_system = true;
'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null, $audit->metadata = ['backup_uuid' => $model->uuid];
'bytes' => $successful ? $request->input('size') : 0,
'completed_at' => CarbonImmutable::now(),
])->save();
// Check if we are using the s3 backup adapter. If so, make sure we mark the backup as $successful = $request->input('successful') ? true : false;
// being completed in S3 correctly. $model->fill([
$adapter = $this->backupManager->adapter(); 'is_successful' => $successful,
if ($adapter instanceof AwsS3Adapter) { 'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null,
$this->completeMultipartUpload($model, $adapter, $successful); 'bytes' => $successful ? $request->input('size') : 0,
} 'completed_at' => CarbonImmutable::now(),
])->save();
// Check if we are using the s3 backup adapter. If so, make sure we mark the backup as
// being completed in S3 correctly.
$adapter = $this->backupManager->adapter();
if ($adapter instanceof AwsS3Adapter) {
$this->completeMultipartUpload($model, $adapter, $successful);
}
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
/**
* Handles toggling the restoration status of a server. The server status field should be
* set back to null, even if the restoration failed. This is not an unsolvable state for
* the server, and the user can keep trying to restore, or just use the reinstall button.
*
* The only thing the successful field does is update the entry value for the audit logs
* table tracking for this restoration.
*
* @return \Illuminate\Http\JsonResponse
*
* @throws \Throwable
*/
public function restore(Request $request, string $backup)
{
/** @var \Pterodactyl\Models\Backup $model */
$model = Backup::query()->where('uuid', $backup)->firstOrFail();
$action = $request->get('successful')
? AuditLog::SERVER__BACKUP_RESTORE_COMPLETED
: AuditLog::SERVER__BACKUP_RESTORE_FAILED;
// Just create a new audit entry for this event and update the server state
// so that power actions, file management, and backups can resume as normal.
$model->server->audit($action, function (AuditLog $audit, Server $server) use ($backup) {
$audit->is_system = true;
$audit->metadata = ['backup_uuid' => $backup];
$server->update(['status' => null]);
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT); return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
} }

View file

@ -4,6 +4,7 @@ namespace Pterodactyl\Http\Controllers\Api\Remote\Servers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Pterodactyl\Http\Controllers\Controller; use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
@ -55,10 +56,13 @@ class ServerInstallController extends Controller
{ {
$server = $this->repository->getByUuid($uuid); $server = $this->repository->getByUuid($uuid);
$this->repository->update($server->id, [ $status = $request->input('successful') === '1' ? null : Server::STATUS_INSTALL_FAILED;
'installed' => (string) $request->input('successful') === '1' ? 1 : 2, if ($server->status === Server::STATUS_SUSPENDED) {
], true, true); $status = Server::STATUS_SUSPENDED;
}
return JsonResponse::create([], Response::HTTP_NO_CONTENT); $this->repository->update($server->id, ['status' => $status], true, true);
return new JsonResponse([], Response::HTTP_NO_CONTENT);
} }
} }

View file

@ -12,8 +12,6 @@ use Pterodactyl\Exceptions\Http\HttpForbiddenException;
use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Repositories\Eloquent\ServerRepository;
use Pterodactyl\Services\Servers\GetUserPermissionsService; use Pterodactyl\Services\Servers\GetUserPermissionsService;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Pterodactyl\Http\Requests\Api\Remote\SftpAuthenticationFormRequest; use Pterodactyl\Http\Requests\Api\Remote\SftpAuthenticationFormRequest;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException; use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
@ -98,16 +96,7 @@ class SftpAuthenticationController extends Controller
} }
} }
// Prevent SFTP access to servers that are being transferred. $server->validateCurrentState();
if (!is_null($server->transfer)) {
throw new ServerTransferringException();
}
// Remember, for security purposes, only reveal the existence of the server to people that
// have provided valid credentials, and have permissions to know about it.
if ($server->installed !== 1 || $server->suspended) {
throw new BadRequestHttpException('Server is not installed or is currently suspended.');
}
return new JsonResponse([ return new JsonResponse([
'server' => $server->uuid, 'server' => $server->uuid,

View file

@ -28,7 +28,6 @@ use Pterodactyl\Http\Middleware\Api\AuthenticateIPAccess;
use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings; use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Pterodactyl\Http\Middleware\Server\AccessingValidServer;
use Pterodactyl\Http\Middleware\Api\Daemon\DaemonAuthenticate; use Pterodactyl\Http\Middleware\Api\Daemon\DaemonAuthenticate;
use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication; use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
@ -106,7 +105,6 @@ class Kernel extends HttpKernel
'auth' => Authenticate::class, 'auth' => Authenticate::class,
'auth.basic' => AuthenticateWithBasicAuth::class, 'auth.basic' => AuthenticateWithBasicAuth::class,
'guest' => RedirectIfAuthenticated::class, 'guest' => RedirectIfAuthenticated::class,
'server' => AccessingValidServer::class,
'admin' => AdminAuthenticate::class, 'admin' => AdminAuthenticate::class,
'csrf' => VerifyCsrfToken::class, 'csrf' => VerifyCsrfToken::class,
'throttle' => ThrottleRequests::class, 'throttle' => ThrottleRequests::class,

View file

@ -25,7 +25,7 @@ class ServerInstalled
throw new NotFoundHttpException('No server resource was located in the request parameters.'); throw new NotFoundHttpException('No server resource was located in the request parameters.');
} }
if ($server->installed !== 1) { if (!$server->isInstalled()) {
throw new HttpException(Response::HTTP_FORBIDDEN, 'Access to this resource is not allowed due to the current installation state.'); throw new HttpException(Response::HTTP_FORBIDDEN, 'Access to this resource is not allowed due to the current installation state.');
} }

View file

@ -6,10 +6,8 @@ use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface; use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
class AuthenticateServerAccess class AuthenticateServerAccess
{ {
@ -60,23 +58,17 @@ class AuthenticateServerAccess
} }
} }
if ($server->suspended && !$request->routeIs('api:client:server.resources')) { try {
throw new BadRequestHttpException('This server is currently suspended and the functionality requested is unavailable.'); $server->validateCurrentState();
} } catch (ServerStateConflictException $exception) {
// Still allow users to get information about their server if it is installing or
// Still allow users to get information about their server if it is installing or being transferred. // being transferred.
if (!$request->routeIs('api:client:server.view')) { if (!$request->routeIs('api:client:server.view')) {
if (!$server->isInstalled()) { if ($server->isSuspended() && !$request->routeIs('api:client:server.resources')) {
// Throw an exception for all server routes; however if the user is an admin and requesting the throw $exception;
// server details, don't throw the exception for them.
if (!$user->root_admin || ($user->root_admin && !$request->routeIs($this->except))) {
throw new ConflictHttpException('Server has not completed the installation process.');
} }
} if (!$user->root_admin || !$request->routeIs($this->except)) {
throw $exception;
if (!is_null($server->transfer)) {
if (!$user->root_admin || ($user->root_admin && !$request->routeIs($this->except))) {
throw new ServerTransferringException();
} }
} }
} }

View file

@ -1,92 +0,0 @@
<?php
namespace Pterodactyl\Http\Middleware\Server;
use Closure;
use Illuminate\Http\Request;
use Pterodactyl\Models\Server;
use Illuminate\Contracts\Routing\ResponseFactory;
use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AccessingValidServer
{
/**
* @var \Illuminate\Contracts\Config\Repository
*/
private $config;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface
*/
private $repository;
/**
* @var \Illuminate\Contracts\Routing\ResponseFactory
*/
private $response;
/**
* AccessingValidServer constructor.
*/
public function __construct(
ConfigRepository $config,
ResponseFactory $response,
ServerRepositoryInterface $repository
) {
$this->config = $config;
$this->repository = $repository;
$this->response = $response;
}
/**
* Determine if a given user has permission to access a server.
*
* @return \Illuminate\Http\Response|mixed
*
* @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
* @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
*/
public function handle(Request $request, Closure $next)
{
$attributes = $request->route()->parameter('server');
$isApiRequest = $request->expectsJson() || $request->is(...$this->config->get('pterodactyl.json_routes', []));
$server = $this->repository->getByUuid($attributes instanceof Server ? $attributes->uuid : $attributes);
if ($server->suspended) {
if ($isApiRequest) {
throw new AccessDeniedHttpException('Server is suspended and cannot be accessed.');
}
return $this->response->view('errors.suspended', [], 403);
}
// Servers can have install statuses other than 1 or 0, so don't check
// for a bool-type operator here.
if ($server->installed !== 1) {
if ($isApiRequest) {
throw new ConflictHttpException('Server is still completing the installation process.');
}
return $this->response->view('errors.installing', [], 409);
}
if (!is_null($server->transfer)) {
if ($isApiRequest) {
throw new ServerTransferringException();
}
return $this->response->view('errors.transferring', [], 409);
}
// Add server to the request attributes. This will replace sessions
// as files are updated.
$request->attributes->set('server', $server);
return $next($request);
}
}

View file

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

View file

@ -1,39 +0,0 @@
<?php
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Backups;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\Permission;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
class DownloadBackupRequest extends ClientApiRequest
{
/**
* @return string
*/
public function permission()
{
return Permission::ACTION_BACKUP_DOWNLOAD;
}
/**
* Ensure that this backup belongs to the server that is also present in the
* request.
*/
public function resourceExists(): bool
{
/** @var \Pterodactyl\Models\Server|mixed $server */
$server = $this->route()->parameter('server');
/** @var \Pterodactyl\Models\Backup|mixed $backup */
$backup = $this->route()->parameter('backup');
if ($server instanceof Server && $backup instanceof Backup) {
if ($server->exists && $backup->exists && $server->id === $backup->server_id) {
return true;
}
}
return false;
}
}

View file

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

125
app/Models/AuditLog.php Normal file
View file

@ -0,0 +1,125 @@
<?php
namespace Pterodactyl\Models;
use Ramsey\Uuid\Uuid;
use Illuminate\Http\Request;
use Illuminate\Container\Container;
/**
* @property int $id
* @property string $uuid
* @property bool $is_system
* @property int|null $user_id
* @property int|null $server_id
* @property string $action
* @property string|null $subaction
* @property array $device
* @property array $metadata
* @property \Carbon\CarbonImmutable $created_at
* @property \Pterodactyl\Models\User|null $user
* @property \Pterodactyl\Models\Server|null $server
*/
class AuditLog extends Model
{
public const UPDATED_AT = null;
public const SERVER__FILESYSTEM_DOWNLOAD = 'server:filesystem.download';
public const SERVER__FILESYSTEM_WRITE = 'server:filesystem.write';
public const SERVER__FILESYSTEM_DELETE = 'server:filesystem.delete';
public const SERVER__FILESYSTEM_RENAME = 'server:filesystem.rename';
public const SERVER__FILESYSTEM_COMPRESS = 'server:filesystem.compress';
public const SERVER__FILESYSTEM_DECOMPRESS = 'server:filesystem.decompress';
public const SERVER__FILESYSTEM_PULL = 'server:filesystem.pull';
public const SERVER__BACKUP_STARTED = 'server:backup.started';
public const SERVER__BACKUP_FAILED = 'server:backup.failed';
public const SERVER__BACKUP_COMPELTED = 'server:backup.completed';
public const SERVER__BACKUP_DELETED = 'server:backup.deleted';
public const SERVER__BACKUP_RESTORE_STARTED = 'server:backup.restore.started';
public const SERVER__BACKUP_RESTORE_COMPLETED = 'server:backup.restore.completed';
public const SERVER__BACKUP_RESTORE_FAILED = 'server:backup.restore.failed';
/**
* @var string[]
*/
public static $validationRules = [
'uuid' => 'required|uuid',
'action' => 'required|string|max:191',
'subaction' => 'nullable|string|max:191',
'device' => 'array',
'device.ip_address' => 'ip',
'device.user_agent' => 'string',
'metadata' => 'array',
];
/**
* @var string
*/
protected $table = 'audit_logs';
/**
* @var bool
*/
protected $immutableDates = true;
/**
* @var string[]
*/
protected $casts = [
'device' => 'array',
'metadata' => 'array',
];
/**
* @var string[]
*/
protected $guarded = [
'id',
'created_at',
];
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function server()
{
return $this->belongsTo(Server::class);
}
/**
* Creates a new AuditLog model and returns it, attaching device information and the
* currently authenticated user if available. This model is not saved at this point, so
* you can always make modifications to it as needed before saving.
*
* @return $this
*/
public static function instance(string $action, array $metadata, bool $isSystem = false)
{
/** @var \Illuminate\Http\Request $request */
$request = Container::getInstance()->make('request');
if ($isSystem || !$request instanceof Request) {
$request = null;
}
return (new self())->fill([
'uuid' => Uuid::uuid4()->toString(),
'is_system' => $isSystem,
'user_id' => ($request && $request->user()) ? $request->user()->id : null,
'server_id' => null,
'action' => $action,
'device' => $request ? [
'ip_address' => $request->getClientIp(),
'user_agent' => $request->userAgent(),
] : [],
'metadata' => $metadata,
]);
}
}

View file

@ -39,9 +39,9 @@ class Permission extends Model
public const ACTION_BACKUP_READ = 'backup.read'; public const ACTION_BACKUP_READ = 'backup.read';
public const ACTION_BACKUP_CREATE = 'backup.create'; public const ACTION_BACKUP_CREATE = 'backup.create';
public const ACTION_BACKUP_UPDATE = 'backup.update';
public const ACTION_BACKUP_DELETE = 'backup.delete'; public const ACTION_BACKUP_DELETE = 'backup.delete';
public const ACTION_BACKUP_DOWNLOAD = 'backup.download'; public const ACTION_BACKUP_DOWNLOAD = 'backup.download';
public const ACTION_BACKUP_RESTORE = 'backup.restore';
public const ACTION_ALLOCATION_READ = 'allocation.read'; public const ACTION_ALLOCATION_READ = 'allocation.read';
public const ACTION_ALLOCATION_CREATE = 'allocation.create'; public const ACTION_ALLOCATION_CREATE = 'allocation.create';
@ -155,9 +155,9 @@ class Permission extends Model
'keys' => [ 'keys' => [
'create' => 'Allows a user to create new backups for this server.', 'create' => 'Allows a user to create new backups for this server.',
'read' => 'Allows a user to view all backups that exist for this server.', 'read' => 'Allows a user to view all backups that exist for this server.',
'update' => '',
'delete' => 'Allows a user to remove backups from the system.', 'delete' => 'Allows a user to remove backups from the system.',
'download' => 'Allows a user to download backups.', 'download' => 'Allows a user to download a backup for the server. Danger: this allows a user to access all files for the server in the backup.',
'restore' => 'Allows a user to restore a backup for the server. Danger: this allows the user to delete all of the server files in the process.',
], ],
], ],

View file

@ -2,9 +2,11 @@
namespace Pterodactyl\Models; namespace Pterodactyl\Models;
use Closure;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Query\JoinClause; use Illuminate\Database\Query\JoinClause;
use Znck\Eloquent\Traits\BelongsToThrough; use Znck\Eloquent\Traits\BelongsToThrough;
use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
/** /**
* @property int $id * @property int $id
@ -14,8 +16,8 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property int $node_id * @property int $node_id
* @property string $name * @property string $name
* @property string $description * @property string $description
* @property string|null $status
* @property bool $skip_scripts * @property bool $skip_scripts
* @property bool $suspended
* @property int $owner_id * @property int $owner_id
* @property int $memory * @property int $memory
* @property int $swap * @property int $swap
@ -29,7 +31,6 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property int $egg_id * @property int $egg_id
* @property string $startup * @property string $startup
* @property string $image * @property string $image
* @property int $installed
* @property int $allocation_limit * @property int $allocation_limit
* @property int $database_limit * @property int $database_limit
* @property int $backup_limit * @property int $backup_limit
@ -49,6 +50,7 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property \Pterodactyl\Models\ServerTransfer $transfer * @property \Pterodactyl\Models\ServerTransfer $transfer
* @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups * @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups
* @property \Pterodactyl\Models\Mount[]|\Illuminate\Database\Eloquent\Collection $mounts * @property \Pterodactyl\Models\Mount[]|\Illuminate\Database\Eloquent\Collection $mounts
* @property \Pterodactyl\Models\AuditLog[] $audits
*/ */
class Server extends Model class Server extends Model
{ {
@ -61,9 +63,10 @@ class Server extends Model
*/ */
public const RESOURCE_NAME = 'server'; public const RESOURCE_NAME = 'server';
public const STATUS_INSTALLING = 0; public const STATUS_INSTALLING = 'installing';
public const STATUS_INSTALLED = 1; public const STATUS_INSTALL_FAILED = 'install_failed';
public const STATUS_INSTALL_FAILED = 2; public const STATUS_SUSPENDED = 'suspended';
public const STATUS_RESTORING_BACKUP = 'restoring_backup';
/** /**
* The table associated with the model. * The table associated with the model.
@ -79,6 +82,7 @@ class Server extends Model
* @var array * @var array
*/ */
protected $attributes = [ protected $attributes = [
'status' => self::STATUS_INSTALLING,
'oom_disabled' => true, 'oom_disabled' => true,
]; ];
@ -101,7 +105,7 @@ class Server extends Model
* *
* @var array * @var array
*/ */
protected $guarded = ['id', 'installed', self::CREATED_AT, self::UPDATED_AT, 'deleted_at']; protected $guarded = ['id', self::CREATED_AT, self::UPDATED_AT, 'deleted_at'];
/** /**
* @var array * @var array
@ -112,6 +116,7 @@ class Server extends Model
'name' => 'required|string|min:1|max:191', 'name' => 'required|string|min:1|max:191',
'node_id' => 'required|exists:nodes,id', 'node_id' => 'required|exists:nodes,id',
'description' => 'string', 'description' => 'string',
'status' => 'nullable|string',
'memory' => 'required|numeric|min:0', 'memory' => 'required|numeric|min:0',
'swap' => 'required|numeric|min:-1', 'swap' => 'required|numeric|min:-1',
'io' => 'required|numeric|between:10,1000', 'io' => 'required|numeric|between:10,1000',
@ -125,7 +130,6 @@ class Server extends Model
'startup' => 'required|string', 'startup' => 'required|string',
'skip_scripts' => 'sometimes|boolean', 'skip_scripts' => 'sometimes|boolean',
'image' => 'required|string|max:191', 'image' => 'required|string|max:191',
'installed' => 'in:0,1,2',
'database_limit' => 'present|nullable|integer|min:0', 'database_limit' => 'present|nullable|integer|min:0',
'allocation_limit' => 'sometimes|nullable|integer|min:0', 'allocation_limit' => 'sometimes|nullable|integer|min:0',
'backup_limit' => 'present|nullable|integer|min:0', 'backup_limit' => 'present|nullable|integer|min:0',
@ -139,7 +143,6 @@ class Server extends Model
protected $casts = [ protected $casts = [
'node_id' => 'integer', 'node_id' => 'integer',
'skip_scripts' => 'boolean', 'skip_scripts' => 'boolean',
'suspended' => 'boolean',
'owner_id' => 'integer', 'owner_id' => 'integer',
'memory' => 'integer', 'memory' => 'integer',
'swap' => 'integer', 'swap' => 'integer',
@ -150,7 +153,6 @@ class Server extends Model
'allocation_id' => 'integer', 'allocation_id' => 'integer',
'nest_id' => 'integer', 'nest_id' => 'integer',
'egg_id' => 'integer', 'egg_id' => 'integer',
'installed' => 'integer',
'database_limit' => 'integer', 'database_limit' => 'integer',
'allocation_limit' => 'integer', 'allocation_limit' => 'integer',
'backup_limit' => 'integer', 'backup_limit' => 'integer',
@ -168,7 +170,12 @@ class Server extends Model
public function isInstalled(): bool public function isInstalled(): bool
{ {
return $this->installed === 1; return $this->status !== self::STATUS_INSTALLING && $this->status !== self::STATUS_INSTALL_FAILED;
}
public function isSuspended(): bool
{
return $this->status === self::STATUS_SUSPENDED;
} }
/** /**
@ -320,4 +327,68 @@ class Server extends Model
{ {
return $this->hasManyThrough(Mount::class, MountServer::class, 'server_id', 'id', 'id', 'mount_id'); return $this->hasManyThrough(Mount::class, MountServer::class, 'server_id', 'id', 'id', 'mount_id');
} }
/**
* Returns a fresh AuditLog model for the server. This model is not saved to the
* database when created, so it is up to the caller to correctly store it as needed.
*
* @return \Pterodactyl\Models\AuditLog
*/
public function newAuditEvent(string $action, array $metadata = []): AuditLog
{
return AuditLog::instance($action, $metadata)->fill([
'server_id' => $this->id,
]);
}
/**
* Stores a new audit event for a server by using a transaction. If the transaction
* fails for any reason everything executed within will be rolled back. The callback
* passed in will receive the AuditLog model before it is saved and the second argument
* will be the current server instance. The callback should modify the audit entry as
* needed before finishing, any changes will be persisted.
*
* The response from the callback is returned to the caller.
*
* @return mixed
*
* @throws \Throwable
*/
public function audit(string $action, Closure $callback)
{
return $this->getConnection()->transaction(function () use ($action, $callback) {
$model = $this->newAuditEvent($action);
$response = $callback($model, $this);
$model->save();
return $response;
});
}
/**
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function audits()
{
return $this->hasMany(AuditLog::class);
}
/**
* Checks if the server is currently in a user-accessible state. If not, an
* exception is raised. This should be called whenever something needs to make
* sure the server is not in a weird state that should block user access.
*
* @throws \Pterodactyl\Exceptions\Http\Server\ServerStateConflictException
*/
public function validateCurrentState()
{
if (
$this->isSuspended() ||
!$this->isInstalled() ||
$this->status === self::STATUS_RESTORING_BACKUP ||
!is_null($this->transfer)
) {
throw new ServerStateConflictException($this);
}
}
} }

View file

@ -53,6 +53,31 @@ class DaemonBackupRepository extends DaemonRepository
} }
} }
/**
* Sends a request to Wings to begin restoring a backup for a server.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
*/
public function restore(Backup $backup, string $url = null, bool $truncate = false): ResponseInterface
{
Assert::isInstanceOf($this->server, Server::class);
try {
return $this->getHttpClient()->post(
sprintf('/api/servers/%s/backup/%s/restore', $this->server->uuid, $backup->uuid),
[
'json' => [
'adapter' => $backup->disk,
'truncate_directory' => $truncate,
'download_url' => $url ?? '',
],
]
);
} catch (TransferException $exception) {
throw new DaemonConnectionException($exception);
}
}
/** /**
* Deletes a backup from the daemon. * Deletes a backup from the daemon.
* *

View file

@ -0,0 +1,75 @@
<?php
namespace Pterodactyl\Services\Backups;
use Carbon\CarbonImmutable;
use Pterodactyl\Models\User;
use Pterodactyl\Models\Backup;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Pterodactyl\Extensions\Backups\BackupManager;
class DownloadLinkService
{
/**
* @var \Pterodactyl\Extensions\Backups\BackupManager
*/
private $backupManager;
/**
* @var \Pterodactyl\Services\Nodes\NodeJWTService
*/
private $jwtService;
/**
* DownloadLinkService constructor.
*/
public function __construct(BackupManager $backupManager, NodeJWTService $jwtService)
{
$this->backupManager = $backupManager;
$this->jwtService = $jwtService;
}
/**
* Returns the URL that allows for a backup to be downloaded by an individual
* user, or by the Wings control software.
*/
public function handle(Backup $backup, User $user): string
{
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
return $this->getS3BackupUrl($backup);
}
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setClaims([
'backup_uuid' => $backup->uuid,
'server_uuid' => $backup->server->uuid,
])
->handle($backup->server->node, $user->id . $backup->server->uuid);
return sprintf('%s/download/backup?token=%s', $backup->server->node->getConnectionAddress(), $token->__toString());
}
/**
* Returns a signed URL that allows us to download a file directly out of a non-public
* S3 bucket by using a signed URL.
*
* @return string
*/
protected function getS3BackupUrl(Backup $backup)
{
/** @var \League\Flysystem\AwsS3v3\AwsS3Adapter $adapter */
$adapter = $this->backupManager->adapter(Backup::ADAPTER_AWS_S3);
$request = $adapter->getClient()->createPresignedRequest(
$adapter->getClient()->getCommand('GetObject', [
'Bucket' => $adapter->getBucket(),
'Key' => sprintf('%s/%s.tar.gz', $backup->server->uuid, $backup->uuid),
'ContentType' => 'application/x-gzip',
]),
CarbonImmutable::now()->addMinutes(5)
);
return $request->getUri()->__toString();
}
}

View file

@ -39,7 +39,7 @@ class ReinstallServerService
public function handle(Server $server) public function handle(Server $server)
{ {
return $this->connection->transaction(function () use ($server) { return $this->connection->transaction(function () use ($server) {
$server->forceFill(['installed' => Server::STATUS_INSTALLING])->save(); $server->fill(['status' => Server::STATUS_INSTALLING])->save();
$this->daemonServerRepository->setServer($server)->reinstall(); $this->daemonServerRepository->setServer($server)->reinstall();

View file

@ -47,7 +47,7 @@ class ServerConfigurationStructureService
{ {
return [ return [
'uuid' => $server->uuid, 'uuid' => $server->uuid,
'suspended' => $server->suspended, 'suspended' => $server->isSuspended(),
'environment' => $this->environment->handle($server), 'environment' => $this->environment->handle($server),
'invocation' => $server->startup, 'invocation' => $server->startup,
'skip_egg_scripts' => $server->skip_scripts, 'skip_egg_scripts' => $server->skip_scripts,
@ -118,7 +118,7 @@ class ServerConfigurationStructureService
'skip_scripts' => $server->skip_scripts, 'skip_scripts' => $server->skip_scripts,
], ],
'rebuild' => false, 'rebuild' => false,
'suspended' => (int) $server->suspended, 'suspended' => $server->isSuspended() ? 1 : 0,
]; ];
} }
} }

View file

@ -211,8 +211,8 @@ class ServerCreationService
'node_id' => Arr::get($data, 'node_id'), 'node_id' => Arr::get($data, 'node_id'),
'name' => Arr::get($data, 'name'), 'name' => Arr::get($data, 'name'),
'description' => Arr::get($data, 'description') ?? '', 'description' => Arr::get($data, 'description') ?? '',
'status' => Server::STATUS_INSTALLING,
'skip_scripts' => Arr::get($data, 'skip_scripts') ?? isset($data['skip_scripts']), 'skip_scripts' => Arr::get($data, 'skip_scripts') ?? isset($data['skip_scripts']),
'suspended' => false,
'owner_id' => Arr::get($data, 'owner_id'), 'owner_id' => Arr::get($data, 'owner_id'),
'memory' => Arr::get($data, 'memory'), 'memory' => Arr::get($data, 'memory'),
'swap' => Arr::get($data, 'swap'), 'swap' => Arr::get($data, 'swap'),

View file

@ -6,7 +6,7 @@ use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Illuminate\Database\ConnectionInterface; use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
class SuspensionService class SuspensionService
{ {
@ -49,18 +49,18 @@ class SuspensionService
// Nothing needs to happen if we're suspending the server and it is already // Nothing needs to happen if we're suspending the server and it is already
// suspended in the database. Additionally, nothing needs to happen if the server // suspended in the database. Additionally, nothing needs to happen if the server
// is not suspended and we try to un-suspend the instance. // is not suspended and we try to un-suspend the instance.
if ($isSuspending === $server->suspended) { if ($isSuspending === $server->isSuspended()) {
return; return;
} }
// Check if the server is currently being transferred. // Check if the server is currently being transferred.
if (!is_null($server->transfer)) { if (!is_null($server->transfer)) {
throw new ServerTransferringException(); throw new ConflictHttpException('Cannot toggle suspension status on a server that is currently being transferred.');
} }
$this->connection->transaction(function () use ($action, $server) { $this->connection->transaction(function () use ($action, $server, $isSuspending) {
$server->update([ $server->update([
'suspended' => $action === self::ACTION_SUSPEND, 'status' => $isSuspending ? Server::STATUS_SUSPENDED : null,
]); ]);
// Only send the suspension request to wings if the server is not currently being transferred. // Only send the suspension request to wings if the server is not currently being transferred.

View file

@ -59,7 +59,9 @@ class ServerTransformer extends BaseTransformer
'identifier' => $server->uuidShort, 'identifier' => $server->uuidShort,
'name' => $server->name, 'name' => $server->name,
'description' => $server->description, 'description' => $server->description,
'suspended' => (bool) $server->suspended, 'status' => $server->status,
// This field is deprecated, please use "status".
'suspended' => $server->isSuspended(),
'limits' => [ 'limits' => [
'memory' => $server->memory, 'memory' => $server->memory,
'swap' => $server->swap, 'swap' => $server->swap,
@ -81,7 +83,8 @@ class ServerTransformer extends BaseTransformer
'container' => [ 'container' => [
'startup_command' => $server->startup, 'startup_command' => $server->startup,
'image' => $server->image, 'image' => $server->image,
'installed' => (int) $server->installed === 1, // This field is deprecated, please use "status".
'installed' => $server->isInstalled() ? 1 : 0,
'environment' => $this->environmentService->handle($server), 'environment' => $this->environmentService->handle($server),
], ],
$server->getUpdatedAtColumn() => $this->formatTimestamp($server->updated_at), $server->getUpdatedAtColumn() => $this->formatTimestamp($server->updated_at),

View file

@ -64,8 +64,11 @@ class ServerTransformer extends BaseClientTransformer
'allocations' => $server->allocation_limit, 'allocations' => $server->allocation_limit,
'backups' => $server->backup_limit, 'backups' => $server->backup_limit,
], ],
'is_suspended' => $server->suspended, 'status' => $server->status,
'is_installing' => $server->installed !== 1, // This field is deprecated, please use "status".
'is_suspended' => $server->isSuspended(),
// This field is deprecated, please use "status".
'is_installing' => !$server->isInstalled(),
'is_transferring' => !is_null($server->transfer), 'is_transferring' => !is_null($server->transfer),
]; ];
} }

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateAuditLogsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->char('uuid', 36);
$table->boolean('is_system')->default(false);
$table->unsignedInteger('user_id')->nullable();
$table->unsignedInteger('server_id')->nullable();
$table->string('action');
$table->string('subaction')->nullable();
$table->json('device');
$table->json('metadata');
$table->timestamp('created_at', 0);
$table->foreign('user_id')->references('id')->on('users')->onDelete('set null');
$table->foreign('server_id')->references('id')->on('servers')->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('audit_logs');
}
}

View file

@ -0,0 +1,55 @@
<?php
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddGenericServerStatusColumn extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('servers', function (Blueprint $table) {
$table->string('status')->nullable()->after('description');
});
DB::transaction(function () {
DB::update('UPDATE servers SET `status` = \'suspended\' WHERE `suspended` = 1');
DB::update('UPDATE servers SET `status` = \'installing\' WHERE `installed` = 0');
DB::update('UPDATE servers SET `status` = \'install_failed\' WHERE `installed` = 2');
});
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('suspended');
$table->dropColumn('installed');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('servers', function (Blueprint $table) {
$table->unsignedTinyInteger('suspended')->default(0);
$table->unsignedTinyInteger('installed')->default(0);
});
DB::transaction(function () {
DB::update('UPDATE servers SET `suspended` = 1 WHERE `status` = \'suspended\'');
DB::update('UPDATE servers SET `installed` = 1 WHERE `status` IS NULL');
DB::update('UPDATE servers SET `installed` = 2 WHERE `status` = \'install_failed\'');
});
Schema::table('servers', function (Blueprint $table) {
$table->dropColumn('status');
});
}
}

View file

@ -12,7 +12,7 @@ return [
'no_new_default_allocation' => 'You are attempting to delete the default allocation for this server but there is no fallback allocation to use.', 'no_new_default_allocation' => 'You are attempting to delete the default allocation for this server but there is no fallback allocation to use.',
'marked_as_failed' => 'This server was marked as having failed a previous installation. Current status cannot be toggled in this state.', 'marked_as_failed' => 'This server was marked as having failed a previous installation. Current status cannot be toggled in this state.',
'bad_variable' => 'There was a validation error with the :name variable.', 'bad_variable' => 'There was a validation error with the :name variable.',
'daemon_exception' => 'There was an exception while attempting to communicate with the daemon resulting in a HTTP/:code response code. This exception has been logged.', 'daemon_exception' => 'There was an exception while attempting to communicate with the daemon resulting in a HTTP/:code response code. This exception has been logged. (request id: :request_id)',
'default_allocation_not_found' => 'The requested default allocation was not found in this server\'s allocations.', 'default_allocation_not_found' => 'The requested default allocation was not found in this server\'s allocations.',
], ],
'alerts' => [ 'alerts' => [

View file

@ -0,0 +1,5 @@
import http from '@/api/http';
export const restoreServerBackup = async (uuid: string, backup: string): Promise<void> => {
await http.post(`/api/client/servers/${uuid}/backups/${backup}/restore`);
};

View file

@ -1,6 +1,6 @@
import http, { FractalResponseData, FractalResponseList } from '@/api/http'; import http, { FractalResponseData, FractalResponseList } from '@/api/http';
import { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers'; import { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers';
import { ServerEggVariable } from '@/api/server/types'; import { ServerEggVariable, ServerStatus } from '@/api/server/types';
export interface Allocation { export interface Allocation {
id: number; id: number;
@ -17,6 +17,7 @@ export interface Server {
uuid: string; uuid: string;
name: string; name: string;
node: string; node: string;
status: ServerStatus;
sftpDetails: { sftpDetails: {
ip: string; ip: string;
port: number; port: number;
@ -38,6 +39,10 @@ export interface Server {
allocations: number; allocations: number;
backups: number; backups: number;
}; };
// Only isSuspended got marked as deprecated since the isInstalling is a nice helper
// since you'd have to check multiple potential values for that. isSuspended should
// be replaced with status !== 'suspended'.
/** @deprecated */
isSuspended: boolean; isSuspended: boolean;
isInstalling: boolean; isInstalling: boolean;
isTransferring: boolean; isTransferring: boolean;
@ -51,6 +56,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
uuid: data.uuid, uuid: data.uuid,
name: data.name, name: data.name,
node: data.node, node: data.node,
status: data.status,
invocation: data.invocation, invocation: data.invocation,
dockerImage: data.docker_image, dockerImage: data.docker_image,
sftpDetails: { sftpDetails: {
@ -61,8 +67,8 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
limits: { ...data.limits }, limits: { ...data.limits },
eggFeatures: data.egg_features || [], eggFeatures: data.egg_features || [],
featureLimits: { ...data.feature_limits }, featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended, isSuspended: data.status === 'suspended',
isInstalling: data.is_installing, isInstalling: data.status === 'installing' || data.status === 'install_failed',
isTransferring: data.is_transferring, isTransferring: data.is_transferring,
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable), variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable),
allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation), allocations: ((data.relationships?.allocations as FractalResponseList | undefined)?.data || []).map(rawDataToServerAllocation),

View file

@ -1,3 +1,5 @@
export type ServerStatus = 'installing' | 'install_failed' | 'suspended' | 'restoring_backup' | null;
export interface ServerBackup { export interface ServerBackup {
uuid: string; uuid: string;
isSuccessful: boolean; isSuccessful: boolean;

View file

@ -32,6 +32,7 @@ export const rawDataToFileObject = (data: FractalResponseData): FileObject => ({
'application/x-br', // .tar.br 'application/x-br', // .tar.br
'application/x-bzip2', // .tar.bz2, .bz2 'application/x-bzip2', // .tar.bz2, .bz2
'application/gzip', // .tar.gz, .gz 'application/gzip', // .tar.gz, .gz
'application/x-gzip',
'application/x-lzip', // .tar.lz4, .lz4 (not sure if this mime type is correct) '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-sz', // .tar.sz, .sz (not sure if this mime type is correct)
'application/x-xz', // .tar.xz, .xz 'application/x-xz', // .tar.xz, .xz

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -9,7 +9,7 @@ import ServerRouter from '@/routers/ServerRouter';
import AuthenticationRouter from '@/routers/AuthenticationRouter'; import AuthenticationRouter from '@/routers/AuthenticationRouter';
import { SiteSettings } from '@/state/settings'; import { SiteSettings } from '@/state/settings';
import ProgressBar from '@/components/elements/ProgressBar'; import ProgressBar from '@/components/elements/ProgressBar';
import NotFound from '@/components/screens/NotFound'; import { NotFound } from '@/components/elements/ScreenBlock';
import tw, { GlobalStyles as TailwindGlobalStyles } from 'twin.macro'; import tw, { GlobalStyles as TailwindGlobalStyles } from 'twin.macro';
import GlobalStylesheet from '@/assets/css/GlobalStylesheet'; import GlobalStylesheet from '@/assets/css/GlobalStylesheet';
import { history } from '@/components/history'; import { history } from '@/components/history';

View file

@ -41,7 +41,7 @@ const StatusIndicatorBox = styled(GreyRowBox)<{ $status: ServerPowerState | unde
export default ({ server, className }: { server: Server; className?: string }) => { export default ({ server, className }: { server: Server; className?: string }) => {
const interval = useRef<number>(null); const interval = useRef<number>(null);
const [ isSuspended, setIsSuspended ] = useState(server.isSuspended); const [ isSuspended, setIsSuspended ] = useState(server.status === 'suspended');
const [ stats, setStats ] = useState<ServerStats | null>(null); const [ stats, setStats ] = useState<ServerStats | null>(null);
const getStats = () => getServerResourceUsage(server.uuid) const getStats = () => getServerResourceUsage(server.uuid)
@ -49,8 +49,8 @@ export default ({ server, className }: { server: Server; className?: string }) =
.catch(error => console.error(error)); .catch(error => console.error(error));
useEffect(() => { useEffect(() => {
setIsSuspended(stats?.isSuspended || server.isSuspended); setIsSuspended(stats?.isSuspended || server.status === 'suspended');
}, [ stats?.isSuspended, server.isSuspended ]); }, [ stats?.isSuspended, server.status ]);
useEffect(() => { useEffect(() => {
// Don't waste a HTTP request if there is nothing important to show to the user because // Don't waste a HTTP request if there is nothing important to show to the user because
@ -107,25 +107,27 @@ export default ({ server, className }: { server: Server; className?: string }) =
isSuspended ? isSuspended ?
<div css={tw`flex-1 text-center`}> <div css={tw`flex-1 text-center`}>
<span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}> <span css={tw`bg-red-500 rounded px-2 py-1 text-red-100 text-xs`}>
{server.isSuspended ? 'Suspended' : 'Connection Error'} {server.status === 'suspended' ? 'Suspended' : 'Connection Error'}
</span> </span>
</div> </div>
: :
server.isInstalling ? (server.isTransferring || server.status) ?
<div css={tw`flex-1 text-center`}> <div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}> <span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
Installing {server.isTransferring ?
'Transferring'
:
server.status === 'installing' ? 'Installing' : (
server.status === 'restoring_backup' ?
'Restoring Backup'
:
'Unavailable'
)
}
</span> </span>
</div> </div>
: :
server.isTransferring ? <Spinner size={'small'}/>
<div css={tw`flex-1 text-center`}>
<span css={tw`bg-neutral-500 rounded px-2 py-1 text-neutral-100 text-xs`}>
Transferring
</span>
</div>
:
<Spinner size={'small'}/>
: :
<React.Fragment> <React.Fragment>
<div css={tw`flex-1 flex md:ml-4 sm:flex hidden justify-center`}> <div css={tw`flex-1 flex md:ml-4 sm:flex hidden justify-center`}>

View file

@ -7,18 +7,19 @@ import ModalContext from '@/context/ModalContext';
type Props = { type Props = {
title: string; title: string;
buttonText: string; buttonText: string;
children: string;
onConfirmed: () => void; onConfirmed: () => void;
showSpinnerOverlay?: boolean; showSpinnerOverlay?: boolean;
}; };
const ConfirmationModal = ({ title, children, buttonText, onConfirmed }: Props) => { const ConfirmationModal: React.FC<Props> = ({ title, children, buttonText, onConfirmed }) => {
const { dismiss } = useContext(ModalContext); const { dismiss } = useContext(ModalContext);
return ( return (
<> <>
<h2 css={tw`text-2xl mb-6`}>{title}</h2> <h2 css={tw`text-2xl mb-6`}>{title}</h2>
<p css={tw`text-sm`}>{children}</p> <div css={tw`text-neutral-300`}>
{children}
</div>
<div css={tw`flex flex-wrap items-center justify-end mt-8`}> <div css={tw`flex flex-wrap items-center justify-end mt-8`}>
<Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto border-transparent`}> <Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto border-transparent`}>
Cancel Cancel

View file

@ -5,6 +5,8 @@ import { faArrowLeft, faSyncAlt } from '@fortawesome/free-solid-svg-icons';
import styled, { keyframes } from 'styled-components/macro'; import styled, { keyframes } from 'styled-components/macro';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import NotFoundSvg from '@/assets/images/not_found.svg';
import ServerErrorSvg from '@/assets/images/server_error.svg';
interface BaseProps { interface BaseProps {
title: string; title: string;
@ -16,15 +18,15 @@ interface BaseProps {
interface PropsWithRetry extends BaseProps { interface PropsWithRetry extends BaseProps {
onRetry?: () => void; onRetry?: () => void;
onBack?: never | undefined; onBack?: never;
} }
interface PropsWithBack extends BaseProps { interface PropsWithBack extends BaseProps {
onBack?: () => void; onBack?: () => void;
onRetry?: never | undefined; onRetry?: never;
} }
type Props = PropsWithBack | PropsWithRetry; export type ScreenBlockProps = PropsWithBack | PropsWithRetry;
const spin = keyframes` const spin = keyframes`
to { transform: rotate(360deg) } to { transform: rotate(360deg) }
@ -38,7 +40,7 @@ const ActionButton = styled(Button)`
} }
`; `;
export default ({ title, image, message, onBack, onRetry }: Props) => ( const ScreenBlock = ({ title, image, message, onBack, onRetry }: ScreenBlockProps) => (
<PageContentBlock> <PageContentBlock>
<div css={tw`flex justify-center`}> <div css={tw`flex justify-center`}>
<div css={tw`w-full sm:w-3/4 md:w-1/2 p-12 md:p-20 bg-neutral-100 rounded-lg shadow-lg text-center relative`}> <div css={tw`w-full sm:w-3/4 md:w-1/2 p-12 md:p-20 bg-neutral-100 rounded-lg shadow-lg text-center relative`}>
@ -61,3 +63,23 @@ export default ({ title, image, message, onBack, onRetry }: Props) => (
</div> </div>
</PageContentBlock> </PageContentBlock>
); );
type ServerErrorProps = (Omit<PropsWithBack, 'image' | 'title'> | Omit<PropsWithRetry, 'image' | 'title'>) & {
title?: string;
}
const ServerError = ({ title, ...props }: ServerErrorProps) => (
<ScreenBlock title={title || 'Something went wrong'} image={ServerErrorSvg} {...props}/>
);
const NotFound = ({ title, message, onBack }: Partial<Pick<ScreenBlockProps, 'title' | 'message' | 'onBack'>>) => (
<ScreenBlock
title={title || '404'}
image={NotFoundSvg}
message={message || 'The requested resource was not found.'}
onBack={onBack}
/>
);
export { ServerError, NotFound };
export default ScreenBlock;

View file

@ -1,17 +0,0 @@
import React from 'react';
import ScreenBlock from '@/components/screens/ScreenBlock';
interface Props {
title?: string;
message?: string;
onBack?: () => void;
}
export default ({ title, message, onBack }: Props) => (
<ScreenBlock
title={title || '404'}
image={'/assets/svgs/not_found.svg'}
message={message || 'The requested resource was not found.'}
onBack={onBack}
/>
);

View file

@ -1,20 +0,0 @@
import React from 'react';
import ScreenBlock from '@/components/screens/ScreenBlock';
interface Props {
title?: string;
message: string;
onRetry?: () => void;
onBack?: () => void;
}
export default ({ title, message, onBack, onRetry }: Props) => (
// @ts-ignore
<ScreenBlock
title={title || 'Something went wrong'}
image={'/assets/svgs/server_error.svg'}
message={message}
onBack={onBack}
onRetry={onRetry}
/>
);

View file

@ -13,6 +13,7 @@ import 'xterm/css/xterm.css';
import useEventListener from '@/plugins/useEventListener'; import useEventListener from '@/plugins/useEventListener';
import { debounce } from 'debounce'; import { debounce } from 'debounce';
import { usePersistedState } from '@/plugins/usePersistedState'; import { usePersistedState } from '@/plugins/usePersistedState';
import { SocketEvent, SocketRequest } from '@/components/server/events';
const theme = { const theme = {
background: th`colors.black`.toString(), background: th`colors.black`.toString(),
@ -172,32 +173,35 @@ export default () => {
useEventListener('resize', () => fit()); useEventListener('resize', () => fit());
useEffect(() => { useEffect(() => {
const listeners: Record<string, (s: string) => void> = {
[SocketEvent.STATUS]: handlePowerChangeEvent,
[SocketEvent.CONSOLE_OUTPUT]: handleConsoleOutput,
[SocketEvent.INSTALL_OUTPUT]: handleConsoleOutput,
[SocketEvent.TRANSFER_LOGS]: handleConsoleOutput,
[SocketEvent.TRANSFER_STATUS]: handleTransferStatus,
[SocketEvent.DAEMON_MESSAGE]: line => handleConsoleOutput(line, true),
[SocketEvent.DAEMON_ERROR]: handleDaemonErrorOutput,
};
if (connected && instance) { if (connected && instance) {
// Do not clear the console if the server is being transferred. // Do not clear the console if the server is being transferred.
if (!isTransferring) { if (!isTransferring) {
terminal.clear(); terminal.clear();
} }
instance.addListener('status', handlePowerChangeEvent); Object.keys(listeners).forEach((key: string) => {
instance.addListener('console output', handleConsoleOutput); instance.addListener(key, listeners[key]);
instance.addListener('install output', handleConsoleOutput); });
instance.addListener('transfer logs', handleConsoleOutput); instance.send(SocketRequest.SEND_LOGS);
instance.addListener('transfer status', handleTransferStatus);
instance.addListener('daemon message', line => handleConsoleOutput(line, true));
instance.addListener('daemon error', handleDaemonErrorOutput);
instance.send('send logs');
} }
return () => { return () => {
instance && instance.removeListener('status', handlePowerChangeEvent) if (instance) {
.removeListener('console output', handleConsoleOutput) Object.keys(listeners).forEach((key: string) => {
.removeListener('install output', handleConsoleOutput) instance.removeListener(key, listeners[key]);
.removeListener('transfer logs', handleConsoleOutput) });
.removeListener('transfer status', handleTransferStatus) }
.removeListener('daemon message', line => handleConsoleOutput(line, true))
.removeListener('daemon error', handleDaemonErrorOutput);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ connected, instance ]); }, [ connected, instance ]);
return ( return (

View file

@ -1,22 +1,30 @@
import useWebsocketEvent from '@/plugins/useWebsocketEvent'; import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { SocketEvent } from '@/components/server/events';
import useFileManagerSwr from '@/plugins/useFileManagerSwr';
const InstallListener = () => { const InstallListener = () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const { mutate } = useFileManagerSwr();
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState); const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
useWebsocketEvent(SocketEvent.BACKUP_RESTORE_COMPLETED, () => {
mutate(undefined);
setServerFromState(s => ({ ...s, status: null }));
});
// Listen for the installation completion event and then fire off a request to fetch the updated // Listen for the installation completion event and then fire off a request to fetch the updated
// server information. This allows the server to automatically become available to the user if they // server information. This allows the server to automatically become available to the user if they
// just sit on the page. // just sit on the page.
useWebsocketEvent('install completed', () => { useWebsocketEvent(SocketEvent.INSTALL_COMPLETED, () => {
getServer(uuid).catch(error => console.error(error)); getServer(uuid).catch(error => console.error(error));
}); });
// When we see the install started event immediately update the state to indicate such so that the // When we see the install started event immediately update the state to indicate such so that the
// screens automatically update. // screens automatically update.
useWebsocketEvent('install started', () => { useWebsocketEvent(SocketEvent.INSTALL_STARTED, () => {
setServerFromState(s => ({ ...s, isInstalling: true })); setServerFromState(s => ({ ...s, status: 'installing' }));
}); });
return null; return null;

View file

@ -6,6 +6,7 @@ import { bytesToHuman, megabytesToHuman } from '@/helpers';
import TitledGreyBox from '@/components/elements/TitledGreyBox'; import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import CopyOnClick from '@/components/elements/CopyOnClick'; import CopyOnClick from '@/components/elements/CopyOnClick';
import { SocketEvent, SocketRequest } from '@/components/server/events';
interface Stats { interface Stats {
memory: number; memory: number;
@ -55,11 +56,11 @@ const ServerDetailsBlock = () => {
return; return;
} }
instance.addListener('stats', statsListener); instance.addListener(SocketEvent.STATS, statsListener);
instance.send('send stats'); instance.send(SocketRequest.SEND_STATS);
return () => { return () => {
instance.removeListener('stats', statsListener); instance.removeListener(SocketEvent.STATS, statsListener);
}; };
}, [ instance, connected ]); }, [ instance, connected ]);

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useState } from 'react';
import Chart, { ChartConfiguration } from 'chart.js'; import Chart, { ChartConfiguration } from 'chart.js';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { bytesToMegabytes } from '@/helpers'; import { bytesToMegabytes } from '@/helpers';
@ -6,6 +6,8 @@ import merge from 'deepmerge';
import TitledGreyBox from '@/components/elements/TitledGreyBox'; import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { faMemory, faMicrochip } from '@fortawesome/free-solid-svg-icons'; import { faMemory, faMicrochip } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro'; import tw from 'twin.macro';
import { SocketEvent } from '@/components/server/events';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguration => ({ const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguration => ({
type: 'line', type: 'line',
@ -70,7 +72,6 @@ const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguratio
export default () => { export default () => {
const status = ServerContext.useStoreState(state => state.status.value); const status = ServerContext.useStoreState(state => state.status.value);
const limits = ServerContext.useStoreState(state => state.server.data!.limits); const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ memory, setMemory ] = useState<Chart>(); const [ memory, setMemory ] = useState<Chart>();
const [ cpu, setCpu ] = useState<Chart>(); const [ cpu, setCpu ] = useState<Chart>();
@ -84,7 +85,7 @@ export default () => {
new Chart(node.getContext('2d')!, chartDefaults({ new Chart(node.getContext('2d')!, chartDefaults({
callback: (value) => `${value}Mb `, callback: (value) => `${value}Mb `,
suggestedMax: limits.memory, suggestedMax: limits.memory,
})) })),
); );
}, []); }, []);
@ -100,7 +101,7 @@ export default () => {
); );
}, []); }, []);
const statsListener = (data: string) => { useWebsocketEvent(SocketEvent.STATS, (data: string) => {
let stats: any = {}; let stats: any = {};
try { try {
stats = JSON.parse(data); stats = JSON.parse(data);
@ -125,27 +126,19 @@ export default () => {
cpu.update({ lazy: true }); cpu.update({ lazy: true });
} }
}; });
useEffect(() => {
if (!connected || !instance) {
return;
}
instance.addListener('stats', statsListener);
return () => {
instance.removeListener('stats', statsListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ instance, connected, memory, cpu ]);
return ( return (
<div css={tw`flex flex-wrap mt-4`}> <div css={tw`flex flex-wrap mt-4`}>
<div css={tw`w-full sm:w-1/2`}> <div css={tw`w-full sm:w-1/2`}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`mr-0 sm:mr-4`}> <TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`mr-0 sm:mr-4`}>
{status !== 'offline' ? {status !== 'offline' ?
<canvas id={'memory_chart'} ref={memoryRef} aria-label={'Server Memory Usage Graph'} role={'img'}/> <canvas
id={'memory_chart'}
ref={memoryRef}
aria-label={'Server Memory Usage Graph'}
role={'img'}
/>
: :
<p css={tw`text-xs text-neutral-400 text-center p-3`}> <p css={tw`text-xs text-neutral-400 text-center p-3`}>
Server is offline. Server is offline.

View file

@ -1,5 +1,6 @@
import useWebsocketEvent from '@/plugins/useWebsocketEvent'; import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { SocketEvent } from '@/components/server/events';
const TransferListener = () => { const TransferListener = () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
@ -7,7 +8,7 @@ const TransferListener = () => {
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState); const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
// Listen for the transfer status event so we can update the state of the server. // Listen for the transfer status event so we can update the state of the server.
useWebsocketEvent('transfer status', (status: string) => { useWebsocketEvent(SocketEvent.TRANSFER_STATUS, (status: string) => {
if (status === 'starting') { if (status === 'starting') {
setServerFromState(s => ({ ...s, isTransferring: true })); setServerFromState(s => ({ ...s, isTransferring: true }));
return; return;

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; import { faBoxOpen, faCloudDownloadAlt, faEllipsisH, faLock, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu'; import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl'; import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
@ -13,6 +13,8 @@ import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups'; import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types'; import { ServerBackup } from '@/api/server/types';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import Input from '@/components/elements/Input';
import { restoreServerBackup } from '@/api/server/backups';
interface Props { interface Props {
backup: ServerBackup; backup: ServerBackup;
@ -20,9 +22,9 @@ interface Props {
export default ({ backup }: Props) => { export default ({ backup }: Props) => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
const [ modal, setModal ] = useState('');
const [ loading, setLoading ] = useState(false); const [ loading, setLoading ] = useState(false);
const [ visible, setVisible ] = useState(false);
const [ deleteVisible, setDeleteVisible ] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useFlash(); const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getServerBackups(); const { mutate } = getServerBackups();
@ -45,36 +47,78 @@ export default ({ backup }: Props) => {
setLoading(true); setLoading(true);
clearFlashes('backups'); clearFlashes('backups');
deleteBackup(uuid, backup.uuid) deleteBackup(uuid, backup.uuid)
.then(() => { .then(() => mutate(data => ({
mutate(data => ({ ...data,
...data, items: data.items.filter(b => b.uuid !== backup.uuid),
items: data.items.filter(b => b.uuid !== backup.uuid), }), false))
}), false);
})
.catch(error => { .catch(error => {
console.error(error); console.error(error);
clearAndAddHttpError({ key: 'backups', error }); clearAndAddHttpError({ key: 'backups', error });
setLoading(false); setLoading(false);
setDeleteVisible(false); setModal('');
}); });
}; };
const doRestorationAction = () => {
setLoading(true);
clearFlashes('backups');
restoreServerBackup(uuid, backup.uuid)
.then(() => setServerFromState(s => ({
...s,
status: 'restoring_backup',
})))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'backups', error });
})
.then(() => setLoading(false))
.then(() => setModal(''));
};
return ( return (
<> <>
{visible &&
<ChecksumModal <ChecksumModal
appear appear
visible={visible} visible={modal === 'checksum'}
onDismissed={() => setVisible(false)} onDismissed={() => setModal('')}
checksum={backup.checksum} checksum={backup.checksum}
/> />
}
<ConfirmationModal <ConfirmationModal
visible={deleteVisible} visible={modal === 'restore'}
title={'Restore this backup?'}
buttonText={'Restore backup'}
onConfirmed={() => doRestorationAction()}
onModalDismissed={() => setModal('')}
>
<p css={tw`text-neutral-300`}>
This server will be stopped in order to restore the backup. Once the backup has started you will
not be able to control the server power state, access the file manager, or create additional backups
until it has completed.
</p>
<p css={tw`text-neutral-300 mt-4`}>
Are you sure you want to continue?
</p>
<p css={tw`mt-4 -mb-2 bg-neutral-900 p-3 rounded`}>
<label
htmlFor={'restore_truncate'}
css={tw`text-base text-neutral-200 flex items-center cursor-pointer`}
>
<Input
type={'checkbox'}
css={tw`text-red-500! w-5! h-5! mr-2`}
id={'restore_truncate'}
value={'true'}
/>
Remove all files and folders before restoring this backup.
</label>
</p>
</ConfirmationModal>
<ConfirmationModal
visible={modal === 'delete'}
title={'Delete this backup?'} title={'Delete this backup?'}
buttonText={'Yes, delete backup'} buttonText={'Yes, delete backup'}
onConfirmed={() => doDeletion()} onConfirmed={() => doDeletion()}
onModalDismissed={() => setDeleteVisible(false)} onModalDismissed={() => setModal('')}
> >
Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot
be recovered once deleted. be recovered once deleted.
@ -93,17 +137,23 @@ export default ({ backup }: Props) => {
> >
<div css={tw`text-sm`}> <div css={tw`text-sm`}>
<Can action={'backup.download'}> <Can action={'backup.download'}>
<DropdownButtonRow onClick={() => doDownload()}> <DropdownButtonRow onClick={doDownload}>
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/> <FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Download</span> <span css={tw`ml-2`}>Download</span>
</DropdownButtonRow> </DropdownButtonRow>
</Can> </Can>
<DropdownButtonRow onClick={() => setVisible(true)}> <Can action={'backup.restore'}>
<DropdownButtonRow onClick={() => setModal('restore')}>
<FontAwesomeIcon fixedWidth icon={faBoxOpen} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Restore</span>
</DropdownButtonRow>
</Can>
<DropdownButtonRow onClick={() => setModal('checksum')}>
<FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/> <FontAwesomeIcon fixedWidth icon={faLock} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Checksum</span> <span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow> </DropdownButtonRow>
<Can action={'backup.delete'}> <Can action={'backup.delete'}>
<DropdownButtonRow danger onClick={() => setDeleteVisible(true)}> <DropdownButtonRow danger onClick={() => setModal('delete')}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/> <FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span> <span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow> </DropdownButtonRow>
@ -112,7 +162,7 @@ export default ({ backup }: Props) => {
</DropdownMenu> </DropdownMenu>
: :
<button <button
onClick={() => setDeleteVisible(true)} onClick={() => setModal('delete')}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`} css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
> >
<FontAwesomeIcon icon={faTrashAlt}/> <FontAwesomeIcon icon={faTrashAlt}/>

View file

@ -11,6 +11,7 @@ import tw from 'twin.macro';
import GreyRowBox from '@/components/elements/GreyRowBox'; import GreyRowBox from '@/components/elements/GreyRowBox';
import getServerBackups from '@/api/swr/getServerBackups'; import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types'; import { ServerBackup } from '@/api/server/types';
import { SocketEvent } from '@/components/server/events';
interface Props { interface Props {
backup: ServerBackup; backup: ServerBackup;
@ -20,7 +21,7 @@ interface Props {
export default ({ backup, className }: Props) => { export default ({ backup, className }: Props) => {
const { mutate } = getServerBackups(); const { mutate } = getServerBackups();
useWebsocketEvent(`backup completed:${backup.uuid}`, data => { useWebsocketEvent(`${SocketEvent.BACKUP_COMPLETED}:${backup.uuid}` as SocketEvent, data => {
try { try {
const parsed = JSON.parse(data); const parsed = JSON.parse(data);

View file

@ -1,10 +1,20 @@
export enum SocketEvent { export enum SocketEvent {
DAEMON_MESSAGE = 'daemon message', DAEMON_MESSAGE = 'daemon message',
DAEMON_ERROR = 'daemon error',
INSTALL_OUTPUT = 'install output', INSTALL_OUTPUT = 'install output',
INSTALL_STARTED = 'install started', INSTALL_STARTED = 'install started',
INSTALL_COMPLETED = 'install completed', INSTALL_COMPLETED = 'install completed',
CONSOLE_OUTPUT = 'console output', CONSOLE_OUTPUT = 'console output',
STATUS = 'status', STATUS = 'status',
STATS = 'stats', STATS = 'stats',
TRANSFER_LOGS = 'transfer logs',
TRANSFER_STATUS = 'transfer status',
BACKUP_COMPLETED = 'backup completed', BACKUP_COMPLETED = 'backup completed',
BACKUP_RESTORE_COMPLETED = 'backup restore completed',
}
export enum SocketRequest {
SEND_LOGS = 'send logs',
SEND_STATS = 'send stats',
SET_STATE = 'set state'
} }

View file

@ -6,6 +6,7 @@ import Button from '@/components/elements/Button';
import saveFileContents from '@/api/server/files/saveFileContents'; import saveFileContents from '@/api/server/files/saveFileContents';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash'; import useFlash from '@/plugins/useFlash';
import { SocketEvent, SocketRequest } from '@/components/server/events';
const EulaModalFeature = () => { const EulaModalFeature = () => {
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
@ -25,10 +26,10 @@ const EulaModalFeature = () => {
} }
}; };
instance.addListener('console output', listener); instance.addListener(SocketEvent.CONSOLE_OUTPUT, listener);
return () => { return () => {
instance.removeListener('console output', listener); instance.removeListener(SocketEvent.CONSOLE_OUTPUT, listener);
}; };
}, [ connected, instance, status ]); }, [ connected, instance, status ]);
@ -39,7 +40,7 @@ const EulaModalFeature = () => {
saveFileContents(uuid, 'eula.txt', 'eula=true') saveFileContents(uuid, 'eula.txt', 'eula=true')
.then(() => { .then(() => {
if (status === 'offline' && instance) { if (status === 'offline' && instance) {
instance.send('set state', 'restart'); instance.send(SocketRequest.SET_STATE, 'restart');
} }
setLoading(false); setLoading(false);

View file

@ -9,7 +9,7 @@ import FileNameModal from '@/components/server/files/FileNameModal';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
import PageContentBlock from '@/components/elements/PageContentBlock'; import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError'; import { ServerError } from '@/components/elements/ScreenBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import Select from '@/components/elements/Select'; import Select from '@/components/elements/Select';

View file

@ -8,7 +8,7 @@ import { FileObject } from '@/api/server/files/loadDirectory';
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton'; import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import ServerError from '@/components/screens/ServerError'; import { ServerError } from '@/components/elements/ScreenBlock';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';

View file

@ -5,7 +5,7 @@ import VariableBox from '@/components/server/startup/VariableBox';
import ServerContentBlock from '@/components/elements/ServerContentBlock'; import ServerContentBlock from '@/components/elements/ServerContentBlock';
import getServerStartup from '@/api/swr/getServerStartup'; import getServerStartup from '@/api/swr/getServerStartup';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import ServerError from '@/components/screens/ServerError'; import { ServerError } from '@/components/elements/ScreenBlock';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect'; import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';

3
resources/scripts/globals.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare module '*.jpg';
declare module '*.png';
declare module '*.svg';

View file

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import ScreenBlock from '@/components/screens/ScreenBlock'; import { ServerError } from '@/components/elements/ScreenBlock';
export interface RequireServerPermissionProps { export interface RequireServerPermissionProps {
permissions: string | string[] permissions: string | string[]
} }
@ -10,8 +11,7 @@ const RequireServerPermission: React.FC<RequireServerPermissionProps> = ({ child
<Can <Can
action={permissions} action={permissions}
renderOnError={ renderOnError={
<ScreenBlock <ServerError
image={'/assets/svgs/server_error.svg'}
title={'Access Denied'} title={'Access Denied'}
message={'You do not have permission to access this page.'} message={'You do not have permission to access this page.'}
/> />

View file

@ -1,7 +1,8 @@
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { SocketEvent } from '@/components/server/events';
const useWebsocketEvent = (event: string, callback: (data: string) => void) => { const useWebsocketEvent = (event: SocketEvent, callback: (data: string) => void) => {
const { connected, instance } = ServerContext.useStoreState(state => state.socket); const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const savedCallback = useRef<any>(null); const savedCallback = useRef<any>(null);
@ -10,7 +11,7 @@ const useWebsocketEvent = (event: string, callback: (data: string) => void) => {
}, [ callback ]); }, [ callback ]);
return useEffect(() => { return useEffect(() => {
const eventListener = (event: any) => savedCallback.current(event); const eventListener = (event: SocketEvent) => savedCallback.current(event);
if (connected && instance) { if (connected && instance) {
instance.addListener(event, eventListener); instance.addListener(event, eventListener);
} }

View file

@ -4,7 +4,7 @@ import LoginContainer from '@/components/auth/LoginContainer';
import ForgotPasswordContainer from '@/components/auth/ForgotPasswordContainer'; import ForgotPasswordContainer from '@/components/auth/ForgotPasswordContainer';
import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer'; import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer';
import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer'; import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer';
import NotFound from '@/components/screens/NotFound'; import { NotFound } from '@/components/elements/ScreenBlock';
export default ({ location, history, match }: RouteComponentProps) => ( export default ({ location, history, match }: RouteComponentProps) => (
<div className={'pt-8 xl:pt-32'}> <div className={'pt-8 xl:pt-32'}>

View file

@ -4,7 +4,7 @@ import AccountOverviewContainer from '@/components/dashboard/AccountOverviewCont
import NavigationBar from '@/components/NavigationBar'; import NavigationBar from '@/components/NavigationBar';
import DashboardContainer from '@/components/dashboard/DashboardContainer'; import DashboardContainer from '@/components/dashboard/DashboardContainer';
import AccountApiContainer from '@/components/dashboard/AccountApiContainer'; import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
import NotFound from '@/components/screens/NotFound'; import { NotFound } from '@/components/elements/ScreenBlock';
import TransitionRouter from '@/TransitionRouter'; import TransitionRouter from '@/TransitionRouter';
import SubNavigation from '@/components/elements/SubNavigation'; import SubNavigation from '@/components/elements/SubNavigation';

View file

@ -18,11 +18,9 @@ import UsersContainer from '@/components/server/users/UsersContainer';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import BackupContainer from '@/components/server/backups/BackupContainer'; import BackupContainer from '@/components/server/backups/BackupContainer';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import ServerError from '@/components/screens/ServerError'; import ScreenBlock, { NotFound, ServerError } from '@/components/elements/ScreenBlock';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import NotFound from '@/components/screens/NotFound';
import { useStoreState } from 'easy-peasy'; import { useStoreState } from 'easy-peasy';
import ScreenBlock from '@/components/screens/ScreenBlock';
import SubNavigation from '@/components/elements/SubNavigation'; import SubNavigation from '@/components/elements/SubNavigation';
import NetworkContainer from '@/components/server/network/NetworkContainer'; import NetworkContainer from '@/components/server/network/NetworkContainer';
import InstallListener from '@/components/server/InstallListener'; import InstallListener from '@/components/server/InstallListener';
@ -31,17 +29,36 @@ import ErrorBoundary from '@/components/elements/ErrorBoundary';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import RequireServerPermission from '@/hoc/RequireServerPermission'; import RequireServerPermission from '@/hoc/RequireServerPermission';
import ServerInstallSvg from '@/assets/images/server_installing.svg';
import ServerRestoreSvg from '@/assets/images/server_restore.svg';
const ConflictStateRenderer = () => {
const status = ServerContext.useStoreState(state => state.server.data?.status || null);
const isTransferring = ServerContext.useStoreState(state => state.server.data?.isTransferring || false);
return (
status === 'installing' || status === 'install_failed' ?
<ScreenBlock
title={'Running Installer'}
image={ServerInstallSvg}
message={'Your server should be ready soon, please try again in a few minutes.'}
/>
:
<ScreenBlock
title={isTransferring ? 'Transferring' : 'Restoring from Backup'}
image={ServerRestoreSvg}
message={isTransferring ? 'Your server is being transfered to a new node, please check back later.' : 'Your server is currently being restored from a backup, please check back in a few minutes.'}
/>
);
};
const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => { const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>) => {
const rootAdmin = useStoreState(state => state.user.data!.rootAdmin); const rootAdmin = useStoreState(state => state.user.data!.rootAdmin);
const [ error, setError ] = useState(''); const [ error, setError ] = useState('');
const [ installing, setInstalling ] = useState(false);
const [ transferring, setTransferring ] = useState(false);
const id = ServerContext.useStoreState(state => state.server.data?.id); const id = ServerContext.useStoreState(state => state.server.data?.id);
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid); const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
const isInstalling = ServerContext.useStoreState(state => state.server.data?.isInstalling); const inConflictState = ServerContext.useStoreState(state => state.server.inConflictState);
const isTransferring = ServerContext.useStoreState(state => state.server.data?.isTransferring);
const serverId = ServerContext.useStoreState(state => state.server.data?.internalId); const serverId = ServerContext.useStoreState(state => state.server.data?.internalId);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer); const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState); const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState);
@ -50,31 +67,13 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
clearServerState(); clearServerState();
}, []); }, []);
useEffect(() => {
setInstalling(!!isInstalling);
}, [ isInstalling ]);
useEffect(() => {
setTransferring(!!isTransferring);
}, [ isTransferring ]);
useEffect(() => { useEffect(() => {
setError(''); setError('');
setInstalling(false);
setTransferring(false);
getServer(match.params.id) getServer(match.params.id)
.catch(error => { .catch(error => {
if (error.response?.status === 409) { console.error(error);
if (error.response.data?.errors[0]?.code === 'ServerTransferringException') { setError(httpErrorToHuman(error));
setTransferring(true);
} else {
setInstalling(true);
}
} else {
console.error(error);
setError(httpErrorToHuman(error));
}
}); });
return () => { return () => {
@ -131,12 +130,8 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<InstallListener/> <InstallListener/>
<TransferListener/> <TransferListener/>
<WebsocketHandler/> <WebsocketHandler/>
{((installing || transferring) && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ? {(inConflictState && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ?
<ScreenBlock <ConflictStateRenderer/>
title={installing ? 'Your server is installing.' : 'Your server is currently being transferred.'}
image={'/assets/svgs/server_installing.svg'}
message={'Please check back in a few minutes.'}
/>
: :
<ErrorBoundary> <ErrorBoundary>
<TransitionRouter> <TransitionRouter>
@ -144,22 +139,22 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Route path={`${match.path}`} component={ServerConsole} exact/> <Route path={`${match.path}`} component={ServerConsole} exact/>
<Route path={`${match.path}/files`} exact> <Route path={`${match.path}/files`} exact>
<RequireServerPermission permissions={'file.*'}> <RequireServerPermission permissions={'file.*'}>
<FileManagerContainer /> <FileManagerContainer/>
</RequireServerPermission> </RequireServerPermission>
</Route> </Route>
<Route path={`${match.path}/files/:action(edit|new)`} exact> <Route path={`${match.path}/files/:action(edit|new)`} exact>
<SuspenseSpinner> <SuspenseSpinner>
<FileEditContainer /> <FileEditContainer/>
</SuspenseSpinner> </SuspenseSpinner>
</Route> </Route>
<Route path={`${match.path}/databases`} exact> <Route path={`${match.path}/databases`} exact>
<RequireServerPermission permissions={'database.*'}> <RequireServerPermission permissions={'database.*'}>
<DatabasesContainer /> <DatabasesContainer/>
</RequireServerPermission> </RequireServerPermission>
</Route> </Route>
<Route path={`${match.path}/schedules`} exact> <Route path={`${match.path}/schedules`} exact>
<RequireServerPermission permissions={'schedule.*'}> <RequireServerPermission permissions={'schedule.*'}>
<ScheduleContainer /> <ScheduleContainer/>
</RequireServerPermission> </RequireServerPermission>
</Route> </Route>
<Route path={`${match.path}/schedules/:id`} exact> <Route path={`${match.path}/schedules/:id`} exact>
@ -167,17 +162,17 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
</Route> </Route>
<Route path={`${match.path}/users`} exact> <Route path={`${match.path}/users`} exact>
<RequireServerPermission permissions={'user.*'}> <RequireServerPermission permissions={'user.*'}>
<UsersContainer /> <UsersContainer/>
</RequireServerPermission> </RequireServerPermission>
</Route> </Route>
<Route path={`${match.path}/backups`} exact> <Route path={`${match.path}/backups`} exact>
<RequireServerPermission permissions={'backup.*'}> <RequireServerPermission permissions={'backup.*'}>
<BackupContainer /> <BackupContainer/>
</RequireServerPermission> </RequireServerPermission>
</Route> </Route>
<Route path={`${match.path}/network`} exact> <Route path={`${match.path}/network`} exact>
<RequireServerPermission permissions={'allocation.*'}> <RequireServerPermission permissions={'allocation.*'}>
<NetworkContainer /> <NetworkContainer/>
</RequireServerPermission> </RequireServerPermission>
</Route> </Route>
<Route path={`${match.path}/startup`} component={StartupContainer} exact/> <Route path={`${match.path}/startup`} component={StartupContainer} exact/>

View file

@ -1,5 +1,5 @@
import getServer, { Server } from '@/api/server/getServer'; import getServer, { Server } from '@/api/server/getServer';
import { action, Action, createContextStore, thunk, Thunk } from 'easy-peasy'; import { action, Action, computed, Computed, createContextStore, thunk, Thunk } from 'easy-peasy';
import socket, { SocketStore } from './socket'; import socket, { SocketStore } from './socket';
import files, { ServerFileStore } from '@/state/server/files'; import files, { ServerFileStore } from '@/state/server/files';
import subusers, { ServerSubuserStore } from '@/state/server/subusers'; import subusers, { ServerSubuserStore } from '@/state/server/subusers';
@ -12,6 +12,7 @@ export type ServerStatus = 'offline' | 'starting' | 'stopping' | 'running' | nul
interface ServerDataStore { interface ServerDataStore {
data?: Server; data?: Server;
inConflictState: Computed<ServerDataStore, boolean>;
permissions: string[]; permissions: string[];
getServer: Thunk<ServerDataStore, string, Record<string, unknown>, ServerStore, Promise<void>>; getServer: Thunk<ServerDataStore, string, Record<string, unknown>, ServerStore, Promise<void>>;
@ -23,6 +24,14 @@ interface ServerDataStore {
const server: ServerDataStore = { const server: ServerDataStore = {
permissions: [], permissions: [],
inConflictState: computed(state => {
if (!state.data) {
return false;
}
return state.data.status !== null || state.data.isTransferring;
}),
getServer: thunk(async (actions, payload) => { getServer: thunk(async (actions, payload) => {
const [ server, permissions ] = await getServer(payload); const [ server, permissions ] = await getServer(payload);

View file

@ -57,9 +57,9 @@
<code>{{ $server->allocation->alias }}:{{ $server->allocation->port }}</code> <code>{{ $server->allocation->alias }}:{{ $server->allocation->port }}</code>
</td> </td>
<td class="text-center"> <td class="text-center">
@if($server->suspended) @if($server->isSuspended())
<span class="label bg-maroon">Suspended</span> <span class="label bg-maroon">Suspended</span>
@elseif(! $server->installed) @elseif(! $server->isInstalled())
<span class="label label-warning">Installing</span> <span class="label label-warning">Installing</span>
@else @else
<span class="label label-success">Active</span> <span class="label label-success">Active</span>

View file

@ -8,7 +8,7 @@
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li class="{{ $router->currentRouteNamed('admin.servers.view') ? 'active' : '' }}"> <li class="{{ $router->currentRouteNamed('admin.servers.view') ? 'active' : '' }}">
<a href="{{ route('admin.servers.view', $server->id) }}">About</a></li> <a href="{{ route('admin.servers.view', $server->id) }}">About</a></li>
@if($server->installed === 1) @if($server->isInstalled())
<li class="{{ $router->currentRouteNamed('admin.servers.view.details') ? 'active' : '' }}"> <li class="{{ $router->currentRouteNamed('admin.servers.view.details') ? 'active' : '' }}">
<a href="{{ route('admin.servers.view.details', $server->id) }}">Details</a> <a href="{{ route('admin.servers.view.details', $server->id) }}">Details</a>
</li> </li>

View file

@ -133,7 +133,7 @@
<div class="box box-primary"> <div class="box box-primary">
<div class="box-body" style="padding-bottom: 0px;"> <div class="box-body" style="padding-bottom: 0px;">
<div class="row"> <div class="row">
@if($server->suspended) @if($server->isSuspended())
<div class="col-sm-12"> <div class="col-sm-12">
<div class="small-box bg-yellow"> <div class="small-box bg-yellow">
<div class="inner"> <div class="inner">
@ -142,11 +142,11 @@
</div> </div>
</div> </div>
@endif @endif
@if($server->installed !== 1) @if(!$server->isInstalled())
<div class="col-sm-12"> <div class="col-sm-12">
<div class="small-box {{ (! $server->installed) ? 'bg-blue' : 'bg-maroon' }}"> <div class="small-box {{ (! $server->isInstalled()) ? 'bg-blue' : 'bg-maroon' }}">
<div class="inner"> <div class="inner">
<h3 class="no-margin">{{ (! $server->installed) ? 'Installing' : 'Install Failed' }}</h3> <h3 class="no-margin">{{ (! $server->isInstalled()) ? 'Installing' : 'Install Failed' }}</h3>
</div> </div>
</div> </div>
</div> </div>

View file

@ -31,7 +31,7 @@
<p>This will reinstall the server with the assigned service scripts. <strong>Danger!</strong> This could overwrite server data.</p> <p>This will reinstall the server with the assigned service scripts. <strong>Danger!</strong> This could overwrite server data.</p>
</div> </div>
<div class="box-footer"> <div class="box-footer">
@if($server->installed === 1) @if($server->isInstalled())
<form action="{{ route('admin.servers.view.manage.reinstall', $server->id) }}" method="POST"> <form action="{{ route('admin.servers.view.manage.reinstall', $server->id) }}" method="POST">
{!! csrf_field() !!} {!! csrf_field() !!}
<button type="submit" class="btn btn-danger">Reinstall Server</button> <button type="submit" class="btn btn-danger">Reinstall Server</button>
@ -59,7 +59,7 @@
</div> </div>
</div> </div>
@if(! $server->suspended) @if(! $server->isSuspended())
<div class="col-sm-4"> <div class="col-sm-4">
<div class="box box-warning"> <div class="box box-warning">
<div class="box-header with-border"> <div class="box-header with-border">

View file

@ -102,7 +102,8 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::get('/', 'Servers\BackupController@index'); Route::get('/', 'Servers\BackupController@index');
Route::post('/', 'Servers\BackupController@store'); Route::post('/', 'Servers\BackupController@store');
Route::get('/{backup}', 'Servers\BackupController@view'); Route::get('/{backup}', 'Servers\BackupController@view');
Route::get('/{backup}/download', 'Servers\DownloadBackupController'); Route::get('/{backup}/download', 'Servers\BackupController@download');
Route::post('/{backup}/restore', 'Servers\BackupController@restore');
Route::delete('/{backup}', 'Servers\BackupController@delete'); Route::delete('/{backup}', 'Servers\BackupController@delete');
}); });

View file

@ -19,5 +19,6 @@ Route::group(['prefix' => '/servers/{uuid}'], function () {
Route::group(['prefix' => '/backups'], function () { Route::group(['prefix' => '/backups'], function () {
Route::get('/{backup}', 'Backups\BackupRemoteUploadController'); Route::get('/{backup}', 'Backups\BackupRemoteUploadController');
Route::post('/{backup}', 'Backups\BackupStatusController'); Route::post('/{backup}', 'Backups\BackupStatusController@index');
Route::post('/{backup}/restore', 'Backups\BackupStatusController@restore');
}); });

View file

@ -73,7 +73,7 @@ class SettingsControllerTest extends ClientApiIntegrationTestCase
{ {
/** @var \Pterodactyl\Models\Server $server */ /** @var \Pterodactyl\Models\Server $server */
[$user, $server] = $this->generateTestAccount($permissions); [$user, $server] = $this->generateTestAccount($permissions);
$this->assertSame(Server::STATUS_INSTALLED, $server->installed); $this->assertTrue($server->isInstalled());
$service = Mockery::mock(DaemonServerRepository::class); $service = Mockery::mock(DaemonServerRepository::class);
$this->app->instance(DaemonServerRepository::class, $service); $this->app->instance(DaemonServerRepository::class, $service);
@ -91,7 +91,7 @@ class SettingsControllerTest extends ClientApiIntegrationTestCase
->assertStatus(Response::HTTP_ACCEPTED); ->assertStatus(Response::HTTP_ACCEPTED);
$server = $server->refresh(); $server = $server->refresh();
$this->assertSame(Server::STATUS_INSTALLING, $server->installed); $this->assertSame(Server::STATUS_INSTALLING, $server->status);
} }
/** /**
@ -107,7 +107,7 @@ class SettingsControllerTest extends ClientApiIntegrationTestCase
->assertStatus(Response::HTTP_FORBIDDEN); ->assertStatus(Response::HTTP_FORBIDDEN);
$server = $server->refresh(); $server = $server->refresh();
$this->assertSame(Server::STATUS_INSTALLED, $server->installed); $this->assertTrue($server->isInstalled());
} }
public function renamePermissionsDataProvider(): array public function renamePermissionsDataProvider(): array

View file

@ -145,7 +145,7 @@ class ServerCreationServiceTest extends IntegrationTestCase
$this->assertSame($allocations[0]->id, $response->allocations[0]->id); $this->assertSame($allocations[0]->id, $response->allocations[0]->id);
$this->assertSame($allocations[4]->id, $response->allocations[1]->id); $this->assertSame($allocations[4]->id, $response->allocations[1]->id);
$this->assertFalse($response->suspended); $this->assertFalse($response->isSuspended());
$this->assertTrue($response->oom_disabled); $this->assertTrue($response->oom_disabled);
$this->assertSame(0, $response->database_limit); $this->assertSame(0, $response->database_limit);
$this->assertSame(0, $response->allocation_limit); $this->assertSame(0, $response->allocation_limit);

View file

@ -97,7 +97,7 @@ class StartupModificationServiceTest extends IntegrationTestCase
$this->assertTrue($response->skip_scripts); $this->assertTrue($response->skip_scripts);
// Make sure we don't revert back to a lurking bug that causes servers to get marked // Make sure we don't revert back to a lurking bug that causes servers to get marked
// as not installed when you modify the startup... // as not installed when you modify the startup...
$this->assertSame(1, $response->installed); $this->assertTrue($response->isInstalled());
} }
/** /**

View file

@ -4,6 +4,7 @@ namespace Pterodactyl\Tests\Integration\Services\Servers;
use Mockery; use Mockery;
use InvalidArgumentException; use InvalidArgumentException;
use Pterodactyl\Models\Server;
use Pterodactyl\Services\Servers\SuspensionService; use Pterodactyl\Services\Servers\SuspensionService;
use Pterodactyl\Tests\Integration\IntegrationTestCase; use Pterodactyl\Tests\Integration\IntegrationTestCase;
use Pterodactyl\Repositories\Wings\DaemonServerRepository; use Pterodactyl\Repositories\Wings\DaemonServerRepository;
@ -26,7 +27,7 @@ class SuspensionServiceTest extends IntegrationTestCase
public function testServerIsSuspendedAndUnsuspended() public function testServerIsSuspendedAndUnsuspended()
{ {
$server = $this->createServerModel(['suspended' => false]); $server = $this->createServerModel();
$this->repository->expects('setServer')->twice()->andReturnSelf(); $this->repository->expects('setServer')->twice()->andReturnSelf();
$this->repository->expects('suspend')->with(false)->andReturnUndefined(); $this->repository->expects('suspend')->with(false)->andReturnUndefined();
@ -34,30 +35,30 @@ class SuspensionServiceTest extends IntegrationTestCase
$this->getService()->toggle($server, SuspensionService::ACTION_SUSPEND); $this->getService()->toggle($server, SuspensionService::ACTION_SUSPEND);
$server->refresh(); $server->refresh();
$this->assertTrue($server->suspended); $this->assertTrue($server->isSuspended());
$this->repository->expects('suspend')->with(true)->andReturnUndefined(); $this->repository->expects('suspend')->with(true)->andReturnUndefined();
$this->getService()->toggle($server, SuspensionService::ACTION_UNSUSPEND); $this->getService()->toggle($server, SuspensionService::ACTION_UNSUSPEND);
$server->refresh(); $server->refresh();
$this->assertFalse($server->suspended); $this->assertFalse($server->isSuspended());
} }
public function testNoActionIsTakenIfSuspensionStatusIsUnchanged() public function testNoActionIsTakenIfSuspensionStatusIsUnchanged()
{ {
$server = $this->createServerModel(['suspended' => false]); $server = $this->createServerModel();
$this->getService()->toggle($server, SuspensionService::ACTION_UNSUSPEND); $this->getService()->toggle($server, SuspensionService::ACTION_UNSUSPEND);
$server->refresh(); $server->refresh();
$this->assertFalse($server->suspended); $this->assertFalse($server->isSuspended());
$server->update(['suspended' => true]); $server->update(['status' => Server::STATUS_SUSPENDED]);
$this->getService()->toggle($server, SuspensionService::ACTION_SUSPEND); $this->getService()->toggle($server, SuspensionService::ACTION_SUSPEND);
$server->refresh(); $server->refresh();
$this->assertTrue($server->suspended); $this->assertTrue($server->isSuspended());
} }
public function testExceptionIsThrownIfInvalidActionsArePassed() public function testExceptionIsThrownIfInvalidActionsArePassed()

View file

@ -1,140 +0,0 @@
<?php
namespace Pterodactyl\Tests\Unit\Http\Middleware\Server;
use Mockery as m;
use Pterodactyl\Models\Server;
use Illuminate\Contracts\Config\Repository;
use Illuminate\Contracts\Routing\ResponseFactory;
use Pterodactyl\Http\Middleware\Server\AccessingValidServer;
use Pterodactyl\Tests\Unit\Http\Middleware\MiddlewareTestCase;
use Pterodactyl\Contracts\Repository\ServerRepositoryInterface;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
class AccessingValidServerTest extends MiddlewareTestCase
{
/**
* @var \Illuminate\Contracts\Config\Repository|\Mockery\Mock
*/
private $config;
/**
* @var \Pterodactyl\Contracts\Repository\ServerRepositoryInterface|\Mockery\Mock
*/
private $repository;
/**
* @var \Illuminate\Contracts\Routing\ResponseFactory|\Mockery\Mock
*/
private $response;
/**
* Setup tests.
*/
public function setUp(): void
{
parent::setUp();
$this->config = m::mock(Repository::class);
$this->repository = m::mock(ServerRepositoryInterface::class);
$this->response = m::mock(ResponseFactory::class);
}
/**
* Test that an exception is thrown if the request is an API request and the server is suspended.
*/
public function testExceptionIsThrownIfServerIsSuspended()
{
$this->expectException(AccessDeniedHttpException::class);
$this->expectExceptionMessage('Server is suspended and cannot be accessed.');
$model = Server::factory()->make(['suspended' => 1]);
$this->request->shouldReceive('route->parameter')->with('server')->once()->andReturn('123456');
$this->request->shouldReceive('expectsJson')->withNoArgs()->once()->andReturn(true);
$this->repository->shouldReceive('getByUuid')->with('123456')->once()->andReturn($model);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
}
/**
* Test that an exception is thrown if the request is an API request and the server is not installed.
*/
public function testExceptionIsThrownIfServerIsNotInstalled()
{
$this->expectException(ConflictHttpException::class);
$this->expectExceptionMessage('Server is still completing the installation process.');
$model = Server::factory()->make(['installed' => 0]);
$this->request->shouldReceive('route->parameter')->with('server')->once()->andReturn('123456');
$this->request->shouldReceive('expectsJson')->withNoArgs()->once()->andReturn(true);
$this->repository->shouldReceive('getByUuid')->with('123456')->once()->andReturn($model);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
}
/**
* Test that the correct error pages are rendered depending on the status of the server.
*
* @dataProvider viewDataProvider
*/
public function testCorrectErrorPagesAreRendered(Server $model, string $page, int $httpCode)
{
$this->request->shouldReceive('route->parameter')->with('server')->once()->andReturn('123456');
$this->request->shouldReceive('expectsJson')->withNoArgs()->once()->andReturn(false);
$this->config->shouldReceive('get')->with('pterodactyl.json_routes', [])->once()->andReturn([]);
$this->request->shouldReceive('is')->with(...[])->once()->andReturn(false);
$this->repository->shouldReceive('getByUuid')->with('123456')->once()->andReturn($model);
$this->response->shouldReceive('view')->with($page, [], $httpCode)->once()->andReturn(true);
$response = $this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
$this->assertTrue($response);
}
/**
* Test that the full middleware works correctly.
*/
public function testValidServerProcess()
{
$model = Server::factory()->make();
$this->request->shouldReceive('route->parameter')->with('server')->once()->andReturn('123456');
$this->request->shouldReceive('expectsJson')->withNoArgs()->once()->andReturn(false);
$this->config->shouldReceive('get')->with('pterodactyl.json_routes', [])->once()->andReturn([]);
$this->request->shouldReceive('is')->with(...[])->once()->andReturn(false);
$this->repository->shouldReceive('getByUuid')->with('123456')->once()->andReturn($model);
$this->getMiddleware()->handle($this->request, $this->getClosureAssertions());
$this->assertRequestHasAttribute('server');
$this->assertRequestAttributeEquals($model, 'server');
}
/**
* Provide test data that checks that the correct view is returned for each model type.
*/
public function viewDataProvider(): array
{
// Without this we are unable to instantiate the factory builders for some reason.
$this->refreshApplication();
return [
[Server::factory()->make(['suspended' => 1]), 'errors.suspended', 403],
[Server::factory()->make(['installed' => 0]), 'errors.installing', 409],
[Server::factory()->make(['installed' => 2]), 'errors.installing', 409],
];
}
/**
* Return an instance of the middleware using mocked dependencies.
*/
private function getMiddleware(): AccessingValidServer
{
return new AccessingValidServer($this->config, $this->response, $this->repository);
}
}