Merge branch 'develop' into feature/react-admin

This commit is contained in:
Matthew Penner 2021-02-07 16:16:22 -07:00
commit a87fef37ec
77 changed files with 1082 additions and 839 deletions

View File

@ -51,7 +51,7 @@ jobs:
- name: install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
- 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
- name: execute unit tests
run: vendor/bin/phpunit --bootstrap bootstrap/app.php tests/Unit

View File

@ -89,9 +89,7 @@ class BulkPowerActionCommand extends Command
*/
protected function getQueryBuilder(array $servers, array $nodes)
{
$instance = Server::query()
->where('suspended', false)
->where('installed', Server::STATUS_INSTALLED);
$instance = Server::query()->whereNull('status');
if (!empty($nodes) && !empty($servers)) {
$instance->whereIn('id', $servers)->orWhereIn('node_id', $nodes);

View File

@ -66,11 +66,6 @@ interface ServerRepositoryInterface extends RepositoryInterface
*/
public function isUniqueUuidCombo(string $uuid, string $short): bool;
/**
* Get the amount of servers that are suspended.
*/
public function getSuspendedServersCount(): int;
/**
* Returns all of the servers that exist for a given node in a paginated response.
*/

View File

@ -2,8 +2,8 @@
namespace Pterodactyl\Exceptions\Http\Connection;
use Illuminate\Support\Arr;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use GuzzleHttp\Exception\GuzzleException;
use Pterodactyl\Exceptions\DisplayException;
@ -17,6 +17,16 @@ class DaemonConnectionException extends DisplayException
*/
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.
*/
@ -24,23 +34,23 @@ class DaemonConnectionException extends DisplayException
{
/** @var \GuzzleHttp\Psr7\Response|null $response */
$response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null;
$this->requestId = $response ? $response->getHeaderLine('X-Request-Id') : null;
if ($useStatusCode) {
$this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode();
}
$message = trans('admin/server.exceptions.daemon_exception', [
'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(),
]);
if (is_null($response)) {
$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
// a 500 level error.
if ($this->statusCode < 500 && !is_null($response)) {
$body = $response->getBody();
if (is_string($body) || (is_object($body) && method_exists($body, '__toString'))) {
$body = json_decode(is_string($body) ? $body : $body->__toString(), true);
$message = '[Wings Error]: ' . Arr::get($body, 'error', $message);
}
$body = json_decode($response->getBody()->__toString(), true);
$message = sprintf('An error occurred on the remote host: %s. (request id: %s)', $body['error'] ?? $message, $this->requestId ?? '<nil>');
}
$level = $this->statusCode >= 500 && $this->statusCode !== 504
@ -50,6 +60,19 @@ class DaemonConnectionException extends DisplayException
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.
*
@ -59,4 +82,12 @@ class DaemonConnectionException extends DisplayException
{
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

@ -2,17 +2,21 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\AuditLog;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Models\Permission;
use Illuminate\Auth\Access\AuthorizationException;
use Pterodactyl\Services\Backups\DeleteBackupService;
use Pterodactyl\Repositories\Eloquent\BackupRepository;
use Pterodactyl\Services\Backups\DownloadLinkService;
use Pterodactyl\Services\Backups\InitiateBackupService;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
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\DeleteBackupRequest;
class BackupController extends ClientApiController
{
@ -27,7 +31,12 @@ class BackupController extends ClientApiController
private $deleteBackupService;
/**
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
* @var \Pterodactyl\Services\Backups\DownloadLinkService
*/
private $downloadLinkService;
/**
* @var \Pterodactyl\Repositories\Wings\DaemonBackupRepository
*/
private $repository;
@ -35,25 +44,33 @@ class BackupController extends ClientApiController
* BackupController constructor.
*/
public function __construct(
BackupRepository $repository,
DaemonBackupRepository $repository,
DeleteBackupService $deleteBackupService,
InitiateBackupService $initiateBackupService
InitiateBackupService $initiateBackupService,
DownloadLinkService $downloadLinkService
) {
parent::__construct();
$this->repository = $repository;
$this->initiateBackupService = $initiateBackupService;
$this->deleteBackupService = $deleteBackupService;
$this->repository = $repository;
$this->downloadLinkService = $downloadLinkService;
}
/**
* Returns all of the backups for a given server instance in a paginated
* result set.
*
* @return array
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
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 AuthorizationException();
}
$limit = min($request->query('per_page') ?? 20, 50);
return $this->fractal->collection($server->backups()->paginate($limit))
@ -64,17 +81,24 @@ class BackupController extends ClientApiController
/**
* Starts the backup process for a server.
*
* @return array
*
* @throws \Exception|\Throwable
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
* @throws \Throwable
*/
public function store(StoreBackupRequest $request, Server $server)
public function store(StoreBackupRequest $request, Server $server): array
{
$backup = $this->initiateBackupService
->setIgnoredFiles(
explode(PHP_EOL, $request->input('ignored') ?? '')
)
->handle($server, $request->input('name'));
/** @var \Pterodactyl\Models\Backup $backup */
$backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) {
$backup = $this->initiateBackupService
->setIgnoredFiles(
explode(PHP_EOL, $request->input('ignored') ?? '')
)
->handle($server, $request->input('name'));
$model->metadata = ['backup_uuid' => $backup->uuid];
return $backup;
});
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
@ -84,10 +108,16 @@ class BackupController extends ClientApiController
/**
* Returns information about a single backup.
*
* @return array
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
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 AuthorizationException();
}
return $this->fractal->item($backup)
->transformWith($this->getTransformer(BackupTransformer::class))
->toArray();
@ -97,14 +127,91 @@ class BackupController extends ClientApiController
* Deletes a backup from the panel as well as the remote source where it is currently
* 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 AuthorizationException();
}
$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.
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function download(Request $request, Server $server, Backup $backup): JsonResponse
{
if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) {
throw new AuthorizationException();
}
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
*/
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 AuthorizationException();
}
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)->toDateTimeImmutable())
->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 Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\AuditLog;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Illuminate\Contracts\Routing\ResponseFactory;
@ -74,19 +75,16 @@ class FileController extends ClientApiController
/**
* Return the contents of a specified file for the user.
*
* @throws \Pterodactyl\Exceptions\Http\Server\FileSizeTooLargeException
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
* @throws \Throwable
*/
public function contents(GetFileContentsRequest $request, Server $server): Response
{
return new Response(
$this->fileRepository->setServer($server)->getContent(
$request->get('file'),
config('pterodactyl.files.max_edit_size')
),
Response::HTTP_OK,
['Content-Type' => 'text/plain']
$response = $this->fileRepository->setServer($server)->getContent(
$request->get('file'),
config('pterodactyl.files.max_edit_size')
);
return new Response($response, Response::HTTP_OK, ['Content-Type' => 'text/plain']);
}
/**
@ -95,17 +93,21 @@ class FileController extends ClientApiController
*
* @return array
*
* @throws \Exception
* @throws \Throwable
*/
public function download(GetFileContentsRequest $request, Server $server)
{
$token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15)->toDateTimeImmutable())
->setClaims([
'file_path' => rawurldecode($request->get('file')),
'server_uuid' => $server->uuid,
])
->handle($server->node, $request->user()->id . $server->uuid);
$token = $server->audit(AuditLog::SERVER__FILESYSTEM_DOWNLOAD, function (AuditLog $audit, Server $server) use ($request) {
$audit->metadata = ['file' => $request->get('file')];
return $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setClaims([
'file_path' => rawurldecode($request->get('file')),
'server_uuid' => $server->uuid,
])
->handle($server->node, $request->user()->id . $server->uuid);
});
return [
'object' => 'signed_url',
@ -126,7 +128,14 @@ class FileController extends ClientApiController
*/
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);
}
@ -134,13 +143,18 @@ class FileController extends ClientApiController
/**
* Creates a new folder on the server.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
* @throws \Throwable
*/
public function create(CreateFolderRequest $request, Server $server): JsonResponse
{
$this->fileRepository
->setServer($server)
->createDirectory($request->input('name'), $request->input('root', '/'));
$server->audit(AuditLog::SERVER__FILESYSTEM_WRITE, function (AuditLog $audit, Server $server) use ($request) {
$audit->subaction = 'create_folder';
$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);
}
@ -148,13 +162,17 @@ class FileController extends ClientApiController
/**
* Renames a file on the remote machine.
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
* @throws \Throwable
*/
public function rename(RenameFileRequest $request, Server $server): JsonResponse
{
$this->fileRepository
->setServer($server)
->renameFiles($request->input('root'), $request->input('files'));
$server->audit(AuditLog::SERVER__FILESYSTEM_RENAME, function (AuditLog $audit, Server $server) use ($request) {
$audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('files')];
$this->fileRepository
->setServer($server)
->renameFiles($request->input('root'), $request->input('files'));
});
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
@ -166,9 +184,14 @@ class FileController extends ClientApiController
*/
public function copy(CopyFileRequest $request, Server $server): JsonResponse
{
$this->fileRepository
->setServer($server)
->copyFile($request->input('location'));
$server->audit(AuditLog::SERVER__FILESYSTEM_WRITE, function (AuditLog $audit, Server $server) use ($request) {
$audit->subaction = 'copy_file';
$audit->metadata = ['file' => $request->input('location')];
$this->fileRepository
->setServer($server)
->copyFile($request->input('location'));
});
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
@ -178,14 +201,18 @@ class FileController extends ClientApiController
*/
public function compress(CompressFilesRequest $request, Server $server): array
{
// Allow up to five minutes for this request to process before timing out.
set_time_limit(300);
$file = $server->audit(AuditLog::SERVER__FILESYSTEM_COMPRESS, function (AuditLog $audit, Server $server) use ($request) {
// Allow up to five minutes for this request to process before timing out.
set_time_limit(300);
$file = $this->fileRepository->setServer($server)
->compressFiles(
$request->input('root'),
$request->input('files')
);
$audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('files')];
return $this->fileRepository->setServer($server)
->compressFiles(
$request->input('root'),
$request->input('files')
);
});
return $this->fractal->item($file)
->transformWith($this->getTransformer(FileObjectTransformer::class))
@ -197,11 +224,15 @@ class FileController extends ClientApiController
*/
public function decompress(DecompressFilesRequest $request, Server $server): JsonResponse
{
// Allow up to five minutes for this request to process before timing out.
set_time_limit(300);
$file = $server->audit(AuditLog::SERVER__FILESYSTEM_DECOMPRESS, function (AuditLog $audit, Server $server) use ($request) {
// Allow up to five minutes for this request to process before timing out.
set_time_limit(300);
$this->fileRepository->setServer($server)
->decompressFile($request->input('root'), $request->input('file'));
$audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('file')];
$this->fileRepository->setServer($server)
->decompressFile($request->input('root'), $request->input('file'));
});
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
@ -213,11 +244,15 @@ class FileController extends ClientApiController
*/
public function delete(DeleteFileRequest $request, Server $server): JsonResponse
{
$this->fileRepository->setServer($server)
->deleteFiles(
$request->input('root'),
$request->input('files')
);
$server->audit(AuditLog::SERVER__FILESYSTEM_DELETE, function (AuditLog $audit, Server $server) use ($request) {
$audit->metadata = ['root' => $request->input('root'), 'files' => $request->input('files')];
$this->fileRepository->setServer($server)
->deleteFiles(
$request->input('root'),
$request->input('files')
);
});
return new JsonResponse([], Response::HTTP_NO_CONTENT);
}
@ -243,11 +278,15 @@ class FileController extends ClientApiController
*
* @param $request
*
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
* @throws \Throwable
*/
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);
}

View File

@ -3,7 +3,10 @@
namespace Pterodactyl\Http\Controllers\Api\Remote\Backups;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server;
use Pterodactyl\Models\AuditLog;
use Illuminate\Http\JsonResponse;
use League\Flysystem\AwsS3v3\AwsS3Adapter;
use Pterodactyl\Exceptions\DisplayException;
@ -39,9 +42,9 @@ class BackupStatusController extends Controller
*
* @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 */
$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.');
}
$successful = $request->input('successful') ? true : false;
$action = $request->input('successful')
? AuditLog::SERVER__BACKUP_COMPELTED
: AuditLog::SERVER__BACKUP_FAILED;
$model->fill([
'is_successful' => $successful,
'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null,
'bytes' => $successful ? $request->input('size') : 0,
'completed_at' => CarbonImmutable::now(),
])->save();
$model->server->audit($action, function (AuditLog $audit) use ($model, $request) {
$audit->is_system = true;
$audit->metadata = ['backup_uuid' => $model->uuid];
// 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);
}
$successful = $request->input('successful') ? true : false;
$model->fill([
'is_successful' => $successful,
'checksum' => $successful ? ($request->input('checksum_type') . ':' . $request->input('checksum')) : null,
'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);
}

View File

@ -4,6 +4,7 @@ namespace Pterodactyl\Http\Controllers\Api\Remote\Servers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Pterodactyl\Models\Server;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Http\Controllers\Controller;
use Pterodactyl\Repositories\Eloquent\ServerRepository;
@ -55,10 +56,13 @@ class ServerInstallController extends Controller
{
$server = $this->repository->getByUuid($uuid);
$this->repository->update($server->id, [
'installed' => (string) $request->input('successful') === '1' ? 1 : 2,
], true, true);
$status = $request->input('successful') === '1' ? null : Server::STATUS_INSTALL_FAILED;
if ($server->status === Server::STATUS_SUSPENDED) {
$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\Services\Servers\GetUserPermissionsService;
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 Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
@ -98,16 +96,7 @@ class SftpAuthenticationController extends Controller
}
}
// Prevent SFTP access to servers that are being transferred.
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.');
}
$server->validateCurrentState();
return new JsonResponse([
'server' => $server->uuid,

View File

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

View File

@ -25,7 +25,7 @@ class ServerInstalled
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.');
}

View File

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

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_CREATE = 'backup.create';
public const ACTION_BACKUP_UPDATE = 'backup.update';
public const ACTION_BACKUP_DELETE = 'backup.delete';
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_CREATE = 'allocation.create';
@ -155,9 +155,9 @@ class Permission extends Model
'keys' => [
'create' => 'Allows a user to create new backups 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.',
'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;
use Closure;
use Illuminate\Notifications\Notifiable;
use Illuminate\Database\Query\JoinClause;
use Znck\Eloquent\Traits\BelongsToThrough;
use Pterodactyl\Exceptions\Http\Server\ServerStateConflictException;
/**
* @property int $id
@ -14,8 +16,8 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property int $node_id
* @property string $name
* @property string $description
* @property string|null $status
* @property bool $skip_scripts
* @property bool $suspended
* @property int $owner_id
* @property int $memory
* @property int $swap
@ -29,7 +31,6 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property int $egg_id
* @property string $startup
* @property string $image
* @property int $installed
* @property int $allocation_limit
* @property int $database_limit
* @property int $backup_limit
@ -49,6 +50,7 @@ use Znck\Eloquent\Traits\BelongsToThrough;
* @property \Pterodactyl\Models\ServerTransfer $transfer
* @property \Pterodactyl\Models\Backup[]|\Illuminate\Database\Eloquent\Collection $backups
* @property \Pterodactyl\Models\Mount[]|\Illuminate\Database\Eloquent\Collection $mounts
* @property \Pterodactyl\Models\AuditLog[] $audits
*/
class Server extends Model
{
@ -61,9 +63,10 @@ class Server extends Model
*/
public const RESOURCE_NAME = 'server';
public const STATUS_INSTALLING = 0;
public const STATUS_INSTALLED = 1;
public const STATUS_INSTALL_FAILED = 2;
public const STATUS_INSTALLING = 'installing';
public const STATUS_INSTALL_FAILED = 'install_failed';
public const STATUS_SUSPENDED = 'suspended';
public const STATUS_RESTORING_BACKUP = 'restoring_backup';
/**
* The table associated with the model.
@ -79,6 +82,7 @@ class Server extends Model
* @var array
*/
protected $attributes = [
'status' => self::STATUS_INSTALLING,
'oom_disabled' => true,
];
@ -101,7 +105,7 @@ class Server extends Model
*
* @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
@ -112,6 +116,7 @@ class Server extends Model
'name' => 'required|string|min:1|max:191',
'node_id' => 'required|exists:nodes,id',
'description' => 'string',
'status' => 'nullable|string',
'memory' => 'required|numeric|min:0',
'swap' => 'required|numeric|min:-1',
'io' => 'required|numeric|between:10,1000',
@ -125,7 +130,6 @@ class Server extends Model
'startup' => 'required|string',
'skip_scripts' => 'sometimes|boolean',
'image' => 'required|string|max:191',
'installed' => 'in:0,1,2',
'database_limit' => 'present|nullable|integer|min:0',
'allocation_limit' => 'sometimes|nullable|integer|min:0',
'backup_limit' => 'present|nullable|integer|min:0',
@ -139,7 +143,6 @@ class Server extends Model
protected $casts = [
'node_id' => 'integer',
'skip_scripts' => 'boolean',
'suspended' => 'boolean',
'owner_id' => 'integer',
'memory' => 'integer',
'swap' => 'integer',
@ -150,7 +153,6 @@ class Server extends Model
'allocation_id' => 'integer',
'nest_id' => 'integer',
'egg_id' => 'integer',
'installed' => 'integer',
'database_limit' => 'integer',
'allocation_limit' => 'integer',
'backup_limit' => 'integer',
@ -168,7 +170,12 @@ class Server extends Model
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');
}
/**
* 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

@ -168,14 +168,6 @@ class ServerRepository extends EloquentRepository implements ServerRepositoryInt
return !$this->getBuilder()->where('uuid', '=', $uuid)->orWhere('uuidShort', '=', $short)->exists();
}
/**
* Get the amount of servers that are suspended.
*/
public function getSuspendedServersCount(): int
{
return $this->getBuilder()->where('suspended', true)->count();
}
/**
* Returns all of the servers that exist for a given node in a paginated response.
*/

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.
*

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)
{
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();

View File

@ -47,7 +47,7 @@ class ServerConfigurationStructureService
{
return [
'uuid' => $server->uuid,
'suspended' => $server->suspended,
'suspended' => $server->isSuspended(),
'environment' => $this->environment->handle($server),
'invocation' => $server->startup,
'skip_egg_scripts' => $server->skip_scripts,
@ -118,7 +118,7 @@ class ServerConfigurationStructureService
'skip_scripts' => $server->skip_scripts,
],
'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'),
'name' => Arr::get($data, 'name'),
'description' => Arr::get($data, 'description') ?? '',
'status' => Server::STATUS_INSTALLING,
'skip_scripts' => Arr::get($data, 'skip_scripts') ?? isset($data['skip_scripts']),
'suspended' => false,
'owner_id' => Arr::get($data, 'owner_id'),
'memory' => Arr::get($data, 'memory'),
'swap' => Arr::get($data, 'swap'),

View File

@ -6,7 +6,7 @@ use Webmozart\Assert\Assert;
use Pterodactyl\Models\Server;
use Illuminate\Database\ConnectionInterface;
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
use Pterodactyl\Exceptions\Http\Server\ServerTransferringException;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
class SuspensionService
{
@ -49,18 +49,18 @@ class SuspensionService
// 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
// is not suspended and we try to un-suspend the instance.
if ($isSuspending === $server->suspended) {
if ($isSuspending === $server->isSuspended()) {
return;
}
// Check if the server is currently being transferred.
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([
'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.

View File

@ -59,17 +59,9 @@ class ServerTransformer extends BaseTransformer
'identifier' => $model->uuidShort,
'name' => $model->name,
'description' => $model->description,
'is_suspended' => $model->suspended,
'is_installing' => $model->installed !== 1,
'is_transferring' => ! is_null($model->transfer),
'user' => $model->owner_id,
'node' => $model->node_id,
'allocation' => $model->allocation_id,
'nest' => $model->nest_id,
'egg' => $model->egg_id,
'status' => $model->status,
// This field is deprecated, please use "status".
'suspended' => $model->isSuspended(),
'limits' => [
'memory' => $model->memory,
'swap' => $model->swap,
@ -78,20 +70,23 @@ class ServerTransformer extends BaseTransformer
'cpu' => $model->cpu,
'threads' => $model->threads,
],
'feature_limits' => [
'databases' => $model->database_limit,
'allocations' => $model->allocation_limit,
'backups' => $model->backup_limit,
],
'user' => $model->owner_id,
'node' => $model->node_id,
'allocation' => $model->allocation_id,
'nest' => $model->nest_id,
'egg' => $model->egg_id,
'container' => [
'startup_command' => $model->startup,
'image' => $model->image,
'installed' => (int) $model->installed === 1,
// This field is deprecated, please use "status".
'installed' => $model->isInstalled() ? 1 : 0,
'environment' => $this->environmentService->handle($model),
],
$model->getUpdatedAtColumn() => $this->formatTimestamp($model->updated_at),
$model->getCreatedAtColumn() => $this->formatTimestamp($model->created_at),
];

View File

@ -64,8 +64,11 @@ class ServerTransformer extends BaseClientTransformer
'allocations' => $server->allocation_limit,
'backups' => $server->backup_limit,
],
'is_suspended' => $server->suspended,
'is_installing' => $server->installed !== 1,
'status' => $server->status,
// 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),
];
}

View File

@ -30,7 +30,7 @@ class ServerFactory extends Factory
'name' => $this->faker->firstName,
'description' => implode(' ', $this->faker->sentences()),
'skip_scripts' => 0,
'suspended' => 0,
'status' => null,
'memory' => 512,
'swap' => 0,
'disk' => 512,
@ -38,7 +38,6 @@ class ServerFactory extends Factory
'cpu' => 0,
'threads' => null,
'oom_disabled' => 0,
'installed' => 1,
'allocation_limit' => null,
'database_limit' => null,
'created_at' => Carbon::now(),

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.',
'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.',
'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.',
],
'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 { rawDataToServerAllocation, rawDataToServerEggVariable } from '@/api/transformers';
import { ServerEggVariable } from '@/api/server/types';
import { ServerEggVariable, ServerStatus } from '@/api/server/types';
export interface Allocation {
id: number;
@ -17,6 +17,7 @@ export interface Server {
uuid: string;
name: string;
node: string;
status: ServerStatus;
sftpDetails: {
ip: string;
port: number;
@ -38,7 +39,6 @@ export interface Server {
allocations: number;
backups: number;
};
isSuspended: boolean;
isInstalling: boolean;
isTransferring: boolean;
variables: ServerEggVariable[];
@ -51,6 +51,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
uuid: data.uuid,
name: data.name,
node: data.node,
status: data.status,
invocation: data.invocation,
dockerImage: data.docker_image,
sftpDetails: {
@ -61,8 +62,7 @@ export const rawDataToServerObject = ({ attributes: data }: FractalResponseData)
limits: { ...data.limits },
eggFeatures: data.egg_features || [],
featureLimits: { ...data.feature_limits },
isSuspended: data.is_suspended,
isInstalling: data.is_installing,
isInstalling: data.status === 'installing' || data.status === 'install_failed',
isTransferring: data.is_transferring,
variables: ((data.relationships?.variables as FractalResponseList | undefined)?.data || []).map(rawDataToServerEggVariable),
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 {
uuid: string;
isSuccessful: boolean;

View File

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

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 { SiteSettings } from '@/state/settings';
import ProgressBar from '@/components/elements/ProgressBar';
import NotFound from '@/components/screens/NotFound';
import { NotFound } from '@/components/elements/ScreenBlock';
import tw from 'twin.macro';
import GlobalStylesheet from '@/assets/css/GlobalStylesheet';
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 }) => {
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 getStats = () => getServerResourceUsage(server.uuid)
@ -49,8 +49,8 @@ export default ({ server, className }: { server: Server; className?: string }) =
.catch(error => console.error(error));
useEffect(() => {
setIsSuspended(stats?.isSuspended || server.isSuspended);
}, [ stats?.isSuspended, server.isSuspended ]);
setIsSuspended(stats?.isSuspended || server.status === 'suspended');
}, [ stats?.isSuspended, server.status ]);
useEffect(() => {
// 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 ?
<div css={tw`flex-1 text-center`}>
<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>
</div>
:
server.isInstalling ?
(server.isTransferring || server.status) ?
<div css={tw`flex-1 text-center`}>
<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>
</div>
:
server.isTransferring ?
<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'}/>
<Spinner size={'small'}/>
:
<React.Fragment>
<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 = {
title: string;
buttonText: string;
children: string;
onConfirmed: () => void;
showSpinnerOverlay?: boolean;
};
const ConfirmationModal = ({ title, children, buttonText, onConfirmed }: Props) => {
const ConfirmationModal: React.FC<Props> = ({ title, children, buttonText, onConfirmed }) => {
const { dismiss } = useContext(ModalContext);
return (
<>
<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`}>
<Button isSecondary onClick={() => dismiss()} css={tw`w-full sm:w-auto border-transparent`}>
Cancel

View File

@ -5,6 +5,8 @@ import { faArrowLeft, faSyncAlt } from '@fortawesome/free-solid-svg-icons';
import styled, { keyframes } from 'styled-components/macro';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
import NotFoundSvg from '@/assets/images/not_found.svg';
import ServerErrorSvg from '@/assets/images/server_error.svg';
interface BaseProps {
title: string;
@ -16,15 +18,15 @@ interface BaseProps {
interface PropsWithRetry extends BaseProps {
onRetry?: () => void;
onBack?: never | undefined;
onBack?: never;
}
interface PropsWithBack extends BaseProps {
onBack?: () => void;
onRetry?: never | undefined;
onRetry?: never;
}
type Props = PropsWithBack | PropsWithRetry;
export type ScreenBlockProps = PropsWithBack | PropsWithRetry;
const spin = keyframes`
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>
<div css={tw`flex justify-center`}>
<div css={tw`w-full sm:w-3/4 md:w-1/2 flex flex-col items-center 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>
</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 { debounce } from 'debounce';
import { usePersistedState } from '@/plugins/usePersistedState';
import { SocketEvent, SocketRequest } from '@/components/server/events';
const theme = {
background: th`colors.black`.toString(),
@ -173,32 +174,35 @@ export default () => {
useEventListener('resize', () => fit());
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) {
// Do not clear the console if the server is being transferred.
if (!isTransferring) {
terminal.clear();
}
instance.addListener('status', handlePowerChangeEvent);
instance.addListener('console output', handleConsoleOutput);
instance.addListener('install output', handleConsoleOutput);
instance.addListener('transfer logs', handleConsoleOutput);
instance.addListener('transfer status', handleTransferStatus);
instance.addListener('daemon message', line => handleConsoleOutput(line, true));
instance.addListener('daemon error', handleDaemonErrorOutput);
instance.send('send logs');
Object.keys(listeners).forEach((key: string) => {
instance.addListener(key, listeners[key]);
});
instance.send(SocketRequest.SEND_LOGS);
}
return () => {
instance && instance.removeListener('status', handlePowerChangeEvent)
.removeListener('console output', handleConsoleOutput)
.removeListener('install output', handleConsoleOutput)
.removeListener('transfer logs', handleConsoleOutput)
.removeListener('transfer status', handleTransferStatus)
.removeListener('daemon message', line => handleConsoleOutput(line, true))
.removeListener('daemon error', handleDaemonErrorOutput);
if (instance) {
Object.keys(listeners).forEach((key: string) => {
instance.removeListener(key, listeners[key]);
});
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ connected, instance ]);
return (

View File

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

View File

@ -6,6 +6,7 @@ import { bytesToHuman, megabytesToHuman } from '@/helpers';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { ServerContext } from '@/state/server';
import CopyOnClick from '@/components/elements/CopyOnClick';
import { SocketEvent, SocketRequest } from '@/components/server/events';
interface Stats {
memory: number;
@ -55,11 +56,11 @@ const ServerDetailsBlock = () => {
return;
}
instance.addListener('stats', statsListener);
instance.send('send stats');
instance.addListener(SocketEvent.STATS, statsListener);
instance.send(SocketRequest.SEND_STATS);
return () => {
instance.removeListener('stats', statsListener);
instance.removeListener(SocketEvent.STATS, statsListener);
};
}, [ 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 { ServerContext } from '@/state/server';
import { bytesToMegabytes } from '@/helpers';
@ -6,6 +6,8 @@ import merge from 'deepmerge';
import TitledGreyBox from '@/components/elements/TitledGreyBox';
import { faMemory, faMicrochip } from '@fortawesome/free-solid-svg-icons';
import tw from 'twin.macro';
import { SocketEvent } from '@/components/server/events';
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguration => ({
type: 'line',
@ -70,7 +72,6 @@ const chartDefaults = (ticks?: Chart.TickOptions | undefined): ChartConfiguratio
export default () => {
const status = ServerContext.useStoreState(state => state.status.value);
const limits = ServerContext.useStoreState(state => state.server.data!.limits);
const { connected, instance } = ServerContext.useStoreState(state => state.socket);
const [ memory, setMemory ] = useState<Chart>();
const [ cpu, setCpu ] = useState<Chart>();
@ -84,7 +85,7 @@ export default () => {
new Chart(node.getContext('2d')!, chartDefaults({
callback: (value) => `${value}Mb `,
suggestedMax: limits.memory,
}))
})),
);
}, []);
@ -100,7 +101,7 @@ export default () => {
);
}, []);
const statsListener = (data: string) => {
useWebsocketEvent(SocketEvent.STATS, (data: string) => {
let stats: any = {};
try {
stats = JSON.parse(data);
@ -125,27 +126,19 @@ export default () => {
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 (
<div css={tw`flex flex-wrap mt-4`}>
<div css={tw`w-full sm:w-1/2`}>
<TitledGreyBox title={'Memory usage'} icon={faMemory} css={tw`mr-0 sm:mr-4`}>
{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`}>
Server is offline.

View File

@ -1,5 +1,6 @@
import useWebsocketEvent from '@/plugins/useWebsocketEvent';
import { ServerContext } from '@/state/server';
import { SocketEvent } from '@/components/server/events';
const TransferListener = () => {
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
@ -7,7 +8,7 @@ const TransferListener = () => {
const setServerFromState = ServerContext.useStoreActions(actions => actions.server.setServerFromState);
// 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') {
setServerFromState(s => ({ ...s, isTransferring: true }));
return;

View File

@ -1,5 +1,5 @@
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 DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
@ -13,6 +13,8 @@ import tw from 'twin.macro';
import getServerBackups from '@/api/swr/getServerBackups';
import { ServerBackup } from '@/api/server/types';
import { ServerContext } from '@/state/server';
import Input from '@/components/elements/Input';
import { restoreServerBackup } from '@/api/server/backups';
interface Props {
backup: ServerBackup;
@ -20,9 +22,9 @@ interface Props {
export default ({ backup }: Props) => {
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 [ visible, setVisible ] = useState(false);
const [ deleteVisible, setDeleteVisible ] = useState(false);
const { clearFlashes, clearAndAddHttpError } = useFlash();
const { mutate } = getServerBackups();
@ -45,36 +47,78 @@ export default ({ backup }: Props) => {
setLoading(true);
clearFlashes('backups');
deleteBackup(uuid, backup.uuid)
.then(() => {
mutate(data => ({
...data,
items: data.items.filter(b => b.uuid !== backup.uuid),
}), false);
})
.then(() => mutate(data => ({
...data,
items: data.items.filter(b => b.uuid !== backup.uuid),
}), false))
.catch(error => {
console.error(error);
clearAndAddHttpError({ key: 'backups', error });
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 (
<>
{visible &&
<ChecksumModal
appear
visible={visible}
onDismissed={() => setVisible(false)}
visible={modal === 'checksum'}
onDismissed={() => setModal('')}
checksum={backup.checksum}
/>
}
<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?'}
buttonText={'Yes, delete backup'}
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
be recovered once deleted.
@ -93,17 +137,23 @@ export default ({ backup }: Props) => {
>
<div css={tw`text-sm`}>
<Can action={'backup.download'}>
<DropdownButtonRow onClick={() => doDownload()}>
<DropdownButtonRow onClick={doDownload}>
<FontAwesomeIcon fixedWidth icon={faCloudDownloadAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Download</span>
</DropdownButtonRow>
</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`}/>
<span css={tw`ml-2`}>Checksum</span>
</DropdownButtonRow>
<Can action={'backup.delete'}>
<DropdownButtonRow danger onClick={() => setDeleteVisible(true)}>
<DropdownButtonRow danger onClick={() => setModal('delete')}>
<FontAwesomeIcon fixedWidth icon={faTrashAlt} css={tw`text-xs`}/>
<span css={tw`ml-2`}>Delete</span>
</DropdownButtonRow>
@ -112,7 +162,7 @@ export default ({ backup }: Props) => {
</DropdownMenu>
:
<button
onClick={() => setDeleteVisible(true)}
onClick={() => setModal('delete')}
css={tw`text-neutral-200 transition-colors duration-150 hover:text-neutral-100 p-2`}
>
<FontAwesomeIcon icon={faTrashAlt}/>

View File

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

View File

@ -1,10 +1,20 @@
export enum SocketEvent {
DAEMON_MESSAGE = 'daemon message',
DAEMON_ERROR = 'daemon error',
INSTALL_OUTPUT = 'install output',
INSTALL_STARTED = 'install started',
INSTALL_COMPLETED = 'install completed',
CONSOLE_OUTPUT = 'console output',
STATUS = 'status',
STATS = 'stats',
TRANSFER_LOGS = 'transfer logs',
TRANSFER_STATUS = 'transfer status',
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 FlashMessageRender from '@/components/FlashMessageRender';
import useFlash from '@/plugins/useFlash';
import { SocketEvent, SocketRequest } from '@/components/server/events';
const EulaModalFeature = () => {
const [ visible, setVisible ] = useState(false);
@ -25,10 +26,10 @@ const EulaModalFeature = () => {
}
};
instance.addListener('console output', listener);
instance.addListener(SocketEvent.CONSOLE_OUTPUT, listener);
return () => {
instance.removeListener('console output', listener);
instance.removeListener(SocketEvent.CONSOLE_OUTPUT, listener);
};
}, [ connected, instance, status ]);
@ -39,7 +40,7 @@ const EulaModalFeature = () => {
saveFileContents(uuid, 'eula.txt', 'eula=true')
.then(() => {
if (status === 'offline' && instance) {
instance.send('set state', 'restart');
instance.send(SocketRequest.SET_STATE, 'restart');
}
setLoading(false);

View File

@ -9,7 +9,7 @@ import FileNameModal from '@/components/server/files/FileNameModal';
import Can from '@/components/elements/Can';
import FlashMessageRender from '@/components/FlashMessageRender';
import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError';
import { ServerError } from '@/components/elements/ScreenBlock';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
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 { NavLink, useLocation } from 'react-router-dom';
import Can from '@/components/elements/Can';
import ServerError from '@/components/screens/ServerError';
import { ServerError } from '@/components/elements/ScreenBlock';
import tw from 'twin.macro';
import Button from '@/components/elements/Button';
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 getServerStartup from '@/api/swr/getServerStartup';
import Spinner from '@/components/elements/Spinner';
import ServerError from '@/components/screens/ServerError';
import { ServerError } from '@/components/elements/ScreenBlock';
import { httpErrorToHuman } from '@/api/http';
import { ServerContext } from '@/state/server';
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,6 @@
import React from 'react';
import Can from '@/components/elements/Can';
import ScreenBlock from '@/components/screens/ScreenBlock';
import { ServerError } from '@/components/elements/ScreenBlock';
export interface RequireServerPermissionProps {
permissions: string | string[]
@ -11,8 +11,7 @@ const RequireServerPermission: React.FC<RequireServerPermissionProps> = ({ child
<Can
action={permissions}
renderOnError={
<ScreenBlock
image={'/assets/svgs/server_error.svg'}
<ServerError
title={'Access Denied'}
message={'You do not have permission to access this page.'}
/>

View File

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

View File

@ -4,7 +4,7 @@ import LoginContainer from '@/components/auth/LoginContainer';
import ForgotPasswordContainer from '@/components/auth/ForgotPasswordContainer';
import ResetPasswordContainer from '@/components/auth/ResetPasswordContainer';
import LoginCheckpointContainer from '@/components/auth/LoginCheckpointContainer';
import NotFound from '@/components/screens/NotFound';
import { NotFound } from '@/components/elements/ScreenBlock';
export default ({ location, history, match }: RouteComponentProps) => (
<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 DashboardContainer from '@/components/dashboard/DashboardContainer';
import AccountApiContainer from '@/components/dashboard/AccountApiContainer';
import NotFound from '@/components/screens/NotFound';
import { NotFound } from '@/components/elements/ScreenBlock';
import TransitionRouter from '@/TransitionRouter';
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 BackupContainer from '@/components/server/backups/BackupContainer';
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 NotFound from '@/components/screens/NotFound';
import { useStoreState } from 'easy-peasy';
import ScreenBlock from '@/components/screens/ScreenBlock';
import SubNavigation from '@/components/elements/SubNavigation';
import NetworkContainer from '@/components/server/network/NetworkContainer';
import InstallListener from '@/components/server/InstallListener';
@ -31,17 +29,36 @@ import ErrorBoundary from '@/components/elements/ErrorBoundary';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
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 rootAdmin = useStoreState(state => state.user.data!.rootAdmin);
const [ error, setError ] = useState('');
const [ installing, setInstalling ] = useState(false);
const [ transferring, setTransferring ] = useState(false);
const id = ServerContext.useStoreState(state => state.server.data?.id);
const uuid = ServerContext.useStoreState(state => state.server.data?.uuid);
const isInstalling = ServerContext.useStoreState(state => state.server.data?.isInstalling);
const isTransferring = ServerContext.useStoreState(state => state.server.data?.isTransferring);
const inConflictState = ServerContext.useStoreState(state => state.server.inConflictState);
const serverId = ServerContext.useStoreState(state => state.server.data?.internalId);
const getServer = ServerContext.useStoreActions(actions => actions.server.getServer);
const clearServerState = ServerContext.useStoreActions(actions => actions.clearServerState);
@ -50,31 +67,13 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
clearServerState();
}, []);
useEffect(() => {
setInstalling(!!isInstalling);
}, [ isInstalling ]);
useEffect(() => {
setTransferring(!!isTransferring);
}, [ isTransferring ]);
useEffect(() => {
setError('');
setInstalling(false);
setTransferring(false);
getServer(match.params.id)
.catch(error => {
if (error.response?.status === 409) {
if (error.response.data?.errors[0]?.code === 'ServerTransferringException') {
setTransferring(true);
} else {
setInstalling(true);
}
} else {
console.error(error);
setError(httpErrorToHuman(error));
}
console.error(error);
setError(httpErrorToHuman(error));
});
return () => {
@ -131,12 +130,8 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<InstallListener/>
<TransferListener/>
<WebsocketHandler/>
{((installing || transferring) && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ?
<ScreenBlock
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.'}
/>
{(inConflictState && (!rootAdmin || (rootAdmin && !location.pathname.endsWith(`/server/${id}`)))) ?
<ConflictStateRenderer/>
:
<ErrorBoundary>
<TransitionRouter>
@ -144,22 +139,22 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
<Route path={`${match.path}`} component={ServerConsole} exact/>
<Route path={`${match.path}/files`} exact>
<RequireServerPermission permissions={'file.*'}>
<FileManagerContainer />
<FileManagerContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/files/:action(edit|new)`} exact>
<SuspenseSpinner>
<FileEditContainer />
<FileEditContainer/>
</SuspenseSpinner>
</Route>
<Route path={`${match.path}/databases`} exact>
<RequireServerPermission permissions={'database.*'}>
<DatabasesContainer />
<DatabasesContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/schedules`} exact>
<RequireServerPermission permissions={'schedule.*'}>
<ScheduleContainer />
<ScheduleContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/schedules/:id`} exact>
@ -167,17 +162,17 @@ const ServerRouter = ({ match, location }: RouteComponentProps<{ id: string }>)
</Route>
<Route path={`${match.path}/users`} exact>
<RequireServerPermission permissions={'user.*'}>
<UsersContainer />
<UsersContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/backups`} exact>
<RequireServerPermission permissions={'backup.*'}>
<BackupContainer />
<BackupContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/network`} exact>
<RequireServerPermission permissions={'allocation.*'}>
<NetworkContainer />
<NetworkContainer/>
</RequireServerPermission>
</Route>
<Route path={`${match.path}/startup`} component={StartupContainer} exact/>

View File

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

View File

@ -102,7 +102,8 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
Route::get('/', 'Servers\BackupController@index');
Route::post('/', 'Servers\BackupController@store');
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');
});

View File

@ -26,5 +26,6 @@ Route::group(['prefix' => '/servers/{uuid}'], function () {
Route::group(['prefix' => '/backups'], function () {
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 */
[$user, $server] = $this->generateTestAccount($permissions);
$this->assertSame(Server::STATUS_INSTALLED, $server->installed);
$this->assertTrue($server->isInstalled());
$service = Mockery::mock(DaemonServerRepository::class);
$this->app->instance(DaemonServerRepository::class, $service);
@ -91,7 +91,7 @@ class SettingsControllerTest extends ClientApiIntegrationTestCase
->assertStatus(Response::HTTP_ACCEPTED);
$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);
$server = $server->refresh();
$this->assertSame(Server::STATUS_INSTALLED, $server->installed);
$this->assertTrue($server->isInstalled());
}
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[4]->id, $response->allocations[1]->id);
$this->assertFalse($response->suspended);
$this->assertFalse($response->isSuspended());
$this->assertTrue($response->oom_disabled);
$this->assertSame(0, $response->database_limit);
$this->assertSame(0, $response->allocation_limit);

View File

@ -97,7 +97,7 @@ class StartupModificationServiceTest extends IntegrationTestCase
$this->assertTrue($response->skip_scripts);
// 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...
$this->assertSame(1, $response->installed);
$this->assertTrue($response->isInstalled());
}
/**

View File

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

View File

@ -2,6 +2,7 @@
namespace Pterodactyl\Tests\Integration;
use Illuminate\Http\Response;
use Illuminate\Testing\Assert as PHPUnit;
use Pterodactyl\Exceptions\DisplayException;
use Illuminate\Validation\ValidationException;
@ -35,4 +36,12 @@ class TestResponse extends IlluminateTestResponse
return $this;
}
/**
* @return $this
*/
public function assertForbidden()
{
return self::assertStatus(Response::HTTP_FORBIDDEN);
}
}

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);
}
}