Skip to content

Commit 2f08456

Browse files
committed
Merge branch 'dane/restore-backups' into develop
2 parents 8c2d1cd + c8ecbe6 commit 2f08456

File tree

78 files changed

+1080
-819
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

78 files changed

+1080
-819
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ jobs:
5151
- name: install dependencies
5252
run: composer install --prefer-dist --no-interaction --no-progress
5353
- name: run cs-fixer
54-
run: vendor/bin/php-cs-fixer fix --dry-run --diff --diff-format=udiff --rules=psr_autoloading
54+
run: vendor/bin/php-cs-fixer fix --dry-run --diff --diff-format=udiff --config .php_cs.dist
5555
continue-on-error: true
5656
- name: execute unit tests
5757
run: vendor/bin/phpunit --bootstrap bootstrap/app.php tests/Unit

app/Exceptions/Http/Connection/DaemonConnectionException.php

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
namespace Pterodactyl\Exceptions\Http\Connection;
44

5-
use Illuminate\Support\Arr;
65
use Illuminate\Http\Response;
6+
use Illuminate\Support\Facades\Log;
77
use GuzzleHttp\Exception\GuzzleException;
88
use Pterodactyl\Exceptions\DisplayException;
99

@@ -17,30 +17,40 @@ class DaemonConnectionException extends DisplayException
1717
*/
1818
private $statusCode = Response::HTTP_GATEWAY_TIMEOUT;
1919

20+
/**
21+
* Every request to the Wings instance will return a unique X-Request-Id header
22+
* which allows for all errors to be efficiently tied to a specific request that
23+
* triggered them, and gives users a more direct method of informing hosts when
24+
* something goes wrong.
25+
*
26+
* @var string|null
27+
*/
28+
private $requestId;
29+
2030
/**
2131
* Throw a displayable exception caused by a daemon connection error.
2232
*/
2333
public function __construct(GuzzleException $previous, bool $useStatusCode = true)
2434
{
2535
/** @var \GuzzleHttp\Psr7\Response|null $response */
2636
$response = method_exists($previous, 'getResponse') ? $previous->getResponse() : null;
37+
$this->requestId = $response ? $response->getHeaderLine('X-Request-Id') : null;
2738

2839
if ($useStatusCode) {
2940
$this->statusCode = is_null($response) ? $this->statusCode : $response->getStatusCode();
3041
}
3142

32-
$message = trans('admin/server.exceptions.daemon_exception', [
33-
'code' => is_null($response) ? 'E_CONN_REFUSED' : $response->getStatusCode(),
34-
]);
43+
if (is_null($response)) {
44+
$message = 'Could not establish a connection to the machine running this server. Please try again.';
45+
} else {
46+
$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>');
47+
}
3548

3649
// Attempt to pull the actual error message off the response and return that if it is not
3750
// a 500 level error.
3851
if ($this->statusCode < 500 && !is_null($response)) {
39-
$body = $response->getBody();
40-
if (is_string($body) || (is_object($body) && method_exists($body, '__toString'))) {
41-
$body = json_decode(is_string($body) ? $body : $body->__toString(), true);
42-
$message = '[Wings Error]: ' . Arr::get($body, 'error', $message);
43-
}
52+
$body = json_decode($response->getBody()->__toString(), true);
53+
$message = sprintf('An error occurred on the remote host: %s. (request id: %s)', $body['error'] ?? $message, $this->requestId ?? '<nil>');
4454
}
4555

4656
$level = $this->statusCode >= 500 && $this->statusCode !== 504
@@ -50,6 +60,19 @@ public function __construct(GuzzleException $previous, bool $useStatusCode = tru
5060
parent::__construct($message, $previous, $level);
5161
}
5262

63+
/**
64+
* Override the default reporting method for DisplayException by just logging immediately
65+
* here and including the specific X-Request-Id header that was returned by the call.
66+
*
67+
* @return void
68+
*/
69+
public function report()
70+
{
71+
Log::{$this->getErrorLevel()}($this->getPrevious(), [
72+
'request_id' => $this->requestId,
73+
]);
74+
}
75+
5376
/**
5477
* Return the HTTP status code for this exception.
5578
*
@@ -59,4 +82,12 @@ public function getStatusCode()
5982
{
6083
return $this->statusCode;
6184
}
85+
86+
/**
87+
* @return string|null
88+
*/
89+
public function getRequestId()
90+
{
91+
return $this->requestId;
92+
}
6293
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace Pterodactyl\Exceptions\Http\Server;
4+
5+
use Throwable;
6+
use Pterodactyl\Models\Server;
7+
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
8+
9+
class ServerStateConflictException extends ConflictHttpException
10+
{
11+
/**
12+
* Exception thrown when the server is in an unsupported state for API access or
13+
* certain operations within the codebase.
14+
*/
15+
public function __construct(Server $server, Throwable $previous = null)
16+
{
17+
$message = 'This server is currently in an unsupported state, please try again later.';
18+
if ($server->isSuspended()) {
19+
$message = 'This server is currently suspended and the functionality requested is unavailable.';
20+
} elseif (!$server->isInstalled()) {
21+
$message = 'This server has not yet completed its installation process, please try again later.';
22+
} elseif ($server->status === Server::STATUS_RESTORING_BACKUP) {
23+
$message = 'This server is currently restoring from a backup, please try again later.';
24+
} elseif (!is_null($server->transfer)) {
25+
$message = 'This server is currently being transferred to a new machine, please try again later.';
26+
}
27+
28+
parent::__construct($message, $previous);
29+
}
30+
}

app/Exceptions/Http/Server/ServerTransferringException.php

Lines changed: 0 additions & 17 deletions
This file was deleted.

app/Http/Controllers/Admin/Servers/ServerViewController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ public function mounts(Request $request, Server $server)
184184
*/
185185
public function manage(Request $request, Server $server)
186186
{
187-
if ($server->installed > 1) {
187+
if ($server->status === Server::STATUS_INSTALL_FAILED) {
188188
throw new DisplayException('This server is in a failed install state and cannot be recovered. Please delete and re-create the server.');
189189
}
190190

app/Http/Controllers/Admin/ServersController.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,12 +203,12 @@ public function setDetails(Request $request, Server $server)
203203
*/
204204
public function toggleInstall(Server $server)
205205
{
206-
if ($server->installed > 1) {
206+
if ($server->status === Server::STATUS_INSTALL_FAILED) {
207207
throw new DisplayException(trans('admin/server.exceptions.marked_as_failed'));
208208
}
209209

210210
$this->repository->update($server->id, [
211-
'installed' => !$server->installed,
211+
'status' => $server->isInstalled() ? Server::STATUS_INSTALLING : null,
212212
], true, true);
213213

214214
$this->alert->success(trans('admin/server.alerts.install_toggled'))->flash();

app/Http/Controllers/Api/Client/Servers/BackupController.php

Lines changed: 127 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@
22

33
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
44

5+
use Illuminate\Http\Request;
56
use Pterodactyl\Models\Backup;
67
use Pterodactyl\Models\Server;
8+
use Pterodactyl\Models\AuditLog;
79
use Illuminate\Http\JsonResponse;
10+
use Pterodactyl\Models\Permission;
11+
use Illuminate\Validation\UnauthorizedException;
812
use Pterodactyl\Services\Backups\DeleteBackupService;
9-
use Pterodactyl\Repositories\Eloquent\BackupRepository;
13+
use Pterodactyl\Services\Backups\DownloadLinkService;
1014
use Pterodactyl\Services\Backups\InitiateBackupService;
15+
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
1116
use Pterodactyl\Transformers\Api\Client\BackupTransformer;
1217
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
13-
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\GetBackupsRequest;
18+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
1419
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest;
15-
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DeleteBackupRequest;
1620

1721
class BackupController extends ClientApiController
1822
{
@@ -27,33 +31,45 @@ class BackupController extends ClientApiController
2731
private $deleteBackupService;
2832

2933
/**
30-
* @var \Pterodactyl\Repositories\Eloquent\BackupRepository
34+
* @var \Pterodactyl\Services\Backups\DownloadLinkService
35+
*/
36+
private $downloadLinkService;
37+
38+
/**
39+
* @var \Pterodactyl\Repositories\Wings\DaemonBackupRepository
3140
*/
3241
private $repository;
3342

3443
/**
3544
* BackupController constructor.
3645
*/
3746
public function __construct(
38-
BackupRepository $repository,
47+
DaemonBackupRepository $repository,
3948
DeleteBackupService $deleteBackupService,
40-
InitiateBackupService $initiateBackupService
49+
InitiateBackupService $initiateBackupService,
50+
DownloadLinkService $downloadLinkService
4151
) {
4252
parent::__construct();
4353

54+
$this->repository = $repository;
4455
$this->initiateBackupService = $initiateBackupService;
4556
$this->deleteBackupService = $deleteBackupService;
46-
$this->repository = $repository;
57+
$this->downloadLinkService = $downloadLinkService;
4758
}
4859

4960
/**
5061
* Returns all of the backups for a given server instance in a paginated
5162
* result set.
5263
*
53-
* @return array
64+
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
65+
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
5466
*/
55-
public function index(GetBackupsRequest $request, Server $server)
67+
public function index(Request $request, Server $server): array
5668
{
69+
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
70+
throw new UnauthorizedException();
71+
}
72+
5773
$limit = min($request->query('per_page') ?? 20, 50);
5874

5975
return $this->fractal->collection($server->backups()->paginate($limit))
@@ -64,17 +80,24 @@ public function index(GetBackupsRequest $request, Server $server)
6480
/**
6581
* Starts the backup process for a server.
6682
*
67-
* @return array
68-
*
69-
* @throws \Exception|\Throwable
83+
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
84+
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
85+
* @throws \Throwable
7086
*/
71-
public function store(StoreBackupRequest $request, Server $server)
87+
public function store(StoreBackupRequest $request, Server $server): array
7288
{
73-
$backup = $this->initiateBackupService
74-
->setIgnoredFiles(
75-
explode(PHP_EOL, $request->input('ignored') ?? '')
76-
)
77-
->handle($server, $request->input('name'));
89+
/** @var \Pterodactyl\Models\Backup $backup */
90+
$backup = $server->audit(AuditLog::SERVER__BACKUP_STARTED, function (AuditLog $model, Server $server) use ($request) {
91+
$backup = $this->initiateBackupService
92+
->setIgnoredFiles(
93+
explode(PHP_EOL, $request->input('ignored') ?? '')
94+
)
95+
->handle($server, $request->input('name'));
96+
97+
$model->metadata = ['backup_uuid' => $backup->uuid];
98+
99+
return $backup;
100+
});
78101

79102
return $this->fractal->item($backup)
80103
->transformWith($this->getTransformer(BackupTransformer::class))
@@ -84,10 +107,15 @@ public function store(StoreBackupRequest $request, Server $server)
84107
/**
85108
* Returns information about a single backup.
86109
*
87-
* @return array
110+
* @throws \Spatie\Fractalistic\Exceptions\InvalidTransformation
111+
* @throws \Spatie\Fractalistic\Exceptions\NoTransformerSpecified
88112
*/
89-
public function view(GetBackupsRequest $request, Server $server, Backup $backup)
113+
public function view(Request $request, Server $server, Backup $backup): array
90114
{
115+
if (!$request->user()->can(Permission::ACTION_BACKUP_READ, $server)) {
116+
throw new UnauthorizedException();
117+
}
118+
91119
return $this->fractal->item($backup)
92120
->transformWith($this->getTransformer(BackupTransformer::class))
93121
->toArray();
@@ -97,14 +125,89 @@ public function view(GetBackupsRequest $request, Server $server, Backup $backup)
97125
* Deletes a backup from the panel as well as the remote source where it is currently
98126
* being stored.
99127
*
100-
* @return \Illuminate\Http\JsonResponse
128+
* @throws \Throwable
129+
*/
130+
public function delete(Request $request, Server $server, Backup $backup): JsonResponse
131+
{
132+
if (!$request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) {
133+
throw new UnauthorizedException();
134+
}
135+
136+
$server->audit(AuditLog::SERVER__BACKUP_DELETED, function (AuditLog $audit) use ($backup) {
137+
$audit->metadata = ['backup_uuid' => $backup->uuid];
138+
139+
$this->deleteBackupService->handle($backup);
140+
});
141+
142+
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
143+
}
144+
145+
/**
146+
* Download the backup for a given server instance. For daemon local files, the file
147+
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
148+
* which the user is redirected to.
149+
*/
150+
public function download(Request $request, Server $server, Backup $backup): JsonResponse
151+
{
152+
if (!$request->user()->can(Permission::ACTION_BACKUP_DOWNLOAD, $server)) {
153+
throw new UnauthorizedException();
154+
}
155+
156+
switch ($backup->disk) {
157+
case Backup::ADAPTER_WINGS:
158+
case Backup::ADAPTER_AWS_S3:
159+
return new JsonResponse([
160+
'object' => 'signed_url',
161+
'attributes' => ['url' => ''],
162+
]);
163+
default:
164+
throw new BadRequestHttpException();
165+
}
166+
}
167+
168+
/**
169+
* Handles restoring a backup by making a request to the Wings instance telling it
170+
* to begin the process of finding (or downloading) the backup and unpacking it
171+
* over the server files.
172+
*
173+
* If the "truncate" flag is passed through in this request then all of the
174+
* files that currently exist on the server will be deleted before restoring.
175+
* Otherwise the archive will simply be unpacked over the existing files.
101176
*
102177
* @throws \Throwable
103178
*/
104-
public function delete(DeleteBackupRequest $request, Server $server, Backup $backup)
179+
public function restore(Request $request, Server $server, Backup $backup): JsonResponse
105180
{
106-
$this->deleteBackupService->handle($backup);
181+
if (!$request->user()->can(Permission::ACTION_BACKUP_RESTORE, $server)) {
182+
throw new UnauthorizedException();
183+
}
184+
185+
// Cannot restore a backup unless a server is fully installed and not currently
186+
// processing a different backup restoration request.
187+
if (!is_null($server->status)) {
188+
throw new BadRequestHttpException('This server is not currently in a state that allows for a backup to be restored.');
189+
}
190+
191+
if (!$backup->is_successful && !$backup->completed_at) {
192+
throw new BadRequestHttpException('This backup cannot be restored at this time: not completed or failed.');
193+
}
194+
195+
$server->audit(AuditLog::SERVER__BACKUP_RESTORE_STARTED, function (AuditLog $audit, Server $server) use ($backup, $request) {
196+
$audit->metadata = ['backup_uuid' => $backup->uuid];
197+
198+
// If the backup is for an S3 file we need to generate a unique Download link for
199+
// it that will allow Wings to actually access the file.
200+
if ($backup->disk === Backup::ADAPTER_AWS_S3) {
201+
$url = $this->downloadLinkService->handle($backup, $request->user());
202+
}
203+
204+
// Update the status right away for the server so that we know not to allow certain
205+
// actions against it via the Panel API.
206+
$server->update(['status' => Server::STATUS_RESTORING_BACKUP]);
207+
208+
$this->repository->setServer($server)->restore($backup, $url ?? null, $request->input('truncate') === 'true');
209+
});
107210

108-
return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT);
211+
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
109212
}
110213
}

0 commit comments

Comments
 (0)