Skip to content

Commit be05d2d

Browse files
committed
Add support for generating a signed URL for downloading a file from the daemon
1 parent 15b436d commit be05d2d

File tree

7 files changed

+230
-17
lines changed

7 files changed

+230
-17
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
4+
5+
use Lcobucci\JWT\Builder;
6+
use Carbon\CarbonImmutable;
7+
use Illuminate\Support\Str;
8+
use Lcobucci\JWT\Signer\Key;
9+
use Pterodactyl\Models\Backup;
10+
use Pterodactyl\Models\Server;
11+
use Lcobucci\JWT\Signer\Hmac\Sha256;
12+
use Illuminate\Http\RedirectResponse;
13+
use Illuminate\Contracts\Routing\ResponseFactory;
14+
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
15+
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
16+
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest;
17+
18+
class DownloadBackupController extends ClientApiController
19+
{
20+
/**
21+
* @var \Pterodactyl\Repositories\Wings\DaemonBackupRepository
22+
*/
23+
private $daemonBackupRepository;
24+
25+
/**
26+
* @var \Illuminate\Contracts\Routing\ResponseFactory
27+
*/
28+
private $responseFactory;
29+
30+
/**
31+
* DownloadBackupController constructor.
32+
*
33+
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
34+
* @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory
35+
*/
36+
public function __construct(
37+
DaemonBackupRepository $daemonBackupRepository,
38+
ResponseFactory $responseFactory
39+
) {
40+
parent::__construct();
41+
42+
$this->daemonBackupRepository = $daemonBackupRepository;
43+
$this->responseFactory = $responseFactory;
44+
}
45+
46+
/**
47+
* Download the backup for a given server instance. For daemon local files, the file
48+
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
49+
* which the user is redirected to.
50+
*
51+
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest $request
52+
* @param \Pterodactyl\Models\Server $server
53+
* @param \Pterodactyl\Models\Backup $backup
54+
* @return \Illuminate\Http\RedirectResponse
55+
*/
56+
public function __invoke(DownloadBackupRequest $request, Server $server, Backup $backup)
57+
{
58+
$signer = new Sha256;
59+
$now = CarbonImmutable::now();
60+
61+
$token = (new Builder)->issuedBy(config('app.url'))
62+
->permittedFor($server->node->getConnectionAddress())
63+
->identifiedBy(hash('sha256', $request->user()->id . $server->uuid), true)
64+
->issuedAt($now->getTimestamp())
65+
->canOnlyBeUsedAfter($now->subMinutes(5)->getTimestamp())
66+
->expiresAt($now->addMinutes(15)->getTimestamp())
67+
->withClaim('unique_id', Str::random(16))
68+
->withClaim('backup_uuid', $backup->uuid)
69+
->withClaim('server_uuid', $server->uuid)
70+
->getToken($signer, new Key($server->node->daemonSecret));
71+
72+
$location = sprintf(
73+
'%s/download/backup?token=%s',
74+
$server->node->getConnectionAddress(),
75+
$token->__toString()
76+
);
77+
78+
return RedirectResponse::create($location);
79+
}
80+
}

app/Http/Middleware/Api/Client/SubstituteClientApiBindings.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Pterodactyl\Http\Middleware\Api\Client;
44

55
use Closure;
6+
use Pterodactyl\Models\Backup;
67
use Illuminate\Container\Container;
78
use Pterodactyl\Contracts\Extensions\HashidsInterface;
89
use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings;
@@ -55,6 +56,10 @@ public function handle($request, Closure $next)
5556
}
5657
});
5758

59+
$this->router->model('backup', Backup::class, function ($value) {
60+
return Backup::query()->where('uuid', $value)->firstOrFail();
61+
});
62+
5863
return parent::handle($request, $next);
5964
}
6065
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Backups;
4+
5+
use Pterodactyl\Models\Backup;
6+
use Pterodactyl\Models\Server;
7+
use Pterodactyl\Models\Permission;
8+
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
9+
10+
class DownloadBackupRequest extends ClientApiRequest
11+
{
12+
/**
13+
* @return string
14+
*/
15+
public function permission()
16+
{
17+
return Permission::ACTION_BACKUP_DOWNLOAD;
18+
}
19+
20+
/**
21+
* Ensure that this backup belongs to the server that is also present in the
22+
* request.
23+
*
24+
* @return bool
25+
*/
26+
public function resourceExists(): bool
27+
{
28+
/** @var \Pterodactyl\Models\Server|mixed $server */
29+
$server = $this->route()->parameter('server');
30+
/** @var \Pterodactyl\Models\Backup|mixed $backup */
31+
$backup = $this->route()->parameter('backup');
32+
33+
if ($server instanceof Server && $backup instanceof Backup) {
34+
if ($server->exists && $backup->exists && $server->id === $backup->server_id) {
35+
return true;
36+
}
37+
}
38+
39+
return false;
40+
}
41+
}

app/Models/Backup.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ class Backup extends Model
2626

2727
const RESOURCE_NAME = 'backup';
2828

29+
const DISK_LOCAL = 'local';
30+
const DISK_AWS_S3 = 's3';
31+
2932
/**
3033
* @var string
3134
*/
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace Pterodactyl\Repositories\Wings;
4+
5+
use Webmozart\Assert\Assert;
6+
use Pterodactyl\Models\Server;
7+
use Psr\Http\Message\ResponseInterface;
8+
use GuzzleHttp\Exception\TransferException;
9+
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
10+
11+
class DaemonBackupRepository extends DaemonRepository
12+
{
13+
/**
14+
* Returns a stream of a backup's contents from the Wings instance so that we
15+
* do not need to send the user directly to the Daemon.
16+
*
17+
* @param string $backup
18+
* @return \Psr\Http\Message\ResponseInterface
19+
*
20+
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
21+
*/
22+
public function getBackup(string $backup): ResponseInterface
23+
{
24+
Assert::isInstanceOf($this->server, Server::class);
25+
26+
try {
27+
return $this->getHttpClient()->get(
28+
sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup),
29+
['stream' => true]
30+
);
31+
} catch (TransferException $exception) {
32+
throw new DaemonConnectionException($exception);
33+
}
34+
}
35+
}
Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,67 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
22
import { ServerBackup } from '@/api/server/backups/getServerBackups';
33
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
44
import { faArchive } from '@fortawesome/free-solid-svg-icons/faArchive';
55
import format from 'date-fns/format';
6-
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'
6+
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
77
import Spinner from '@/components/elements/Spinner';
88
import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDownloadAlt';
9+
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
10+
import { bytesToHuman } from '@/helpers';
11+
import Can from '@/components/elements/Can';
12+
import { join } from "path";
13+
import useServer from '@/plugins/useServer';
914

1015
interface Props {
1116
backup: ServerBackup;
1217
className?: string;
1318
}
1419

20+
const DownloadModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => (
21+
<Modal {...props}>
22+
<h3 className={'mb-6'}>Verify file checksum</h3>
23+
<p className={'text-sm'}>
24+
The SHA256 checksum of this file is:
25+
</p>
26+
<pre className={'mt-2 text-sm p-2 bg-neutral-900 rounded'}>
27+
<code className={'block font-mono'}>{checksum}</code>
28+
</pre>
29+
</Modal>
30+
);
31+
1532
export default ({ backup, className }: Props) => {
33+
const { uuid } = useServer();
34+
const [ visible, setVisible ] = useState(false);
35+
1636
return (
1737
<div className={`grey-row-box flex items-center ${className}`}>
38+
{visible &&
39+
<DownloadModal
40+
visible={visible}
41+
appear={true}
42+
onDismissed={() => setVisible(false)}
43+
checksum={backup.sha256Hash}
44+
/>
45+
}
1846
<div className={'mr-4'}>
19-
<FontAwesomeIcon icon={faArchive} className={'text-neutral-300'}/>
47+
{backup.completedAt ?
48+
<FontAwesomeIcon icon={faArchive} className={'text-neutral-300'}/>
49+
:
50+
<Spinner size={'tiny'}/>
51+
}
2052
</div>
2153
<div className={'flex-1'}>
22-
<p className={'text-sm mb-1'}>{backup.name}</p>
23-
<p className={'text-xs text-neutral-400 font-mono'}>{backup.uuid}</p>
54+
<p className={'text-sm mb-1'}>
55+
{backup.name}
56+
{backup.completedAt &&
57+
<span className={'ml-3 text-neutral-300 text-xs font-thin'}>{bytesToHuman(backup.bytes)}</span>
58+
}
59+
</p>
60+
<p className={'text-xs text-neutral-400 font-mono'}>
61+
{backup.uuid}
62+
</p>
2463
</div>
25-
<div className={'ml-4 text-center'}>
64+
<div className={'ml-8 text-center'}>
2665
<p
2766
title={format(backup.createdAt, 'ddd, MMMM Do, YYYY HH:mm:ss Z')}
2867
className={'text-sm'}
@@ -31,17 +70,26 @@ export default ({ backup, className }: Props) => {
3170
</p>
3271
<p className={'text-2xs text-neutral-500 uppercase mt-1'}>Created</p>
3372
</div>
34-
<div className={'ml-6'} style={{ marginRight: '-0.5rem' }}>
35-
{!backup.completedAt ?
36-
<div title={'Backup is in progress'} className={'p-2'}>
37-
<Spinner size={'tiny'}/>
38-
</div>
39-
:
40-
<a href={'#'} className={'text-sm text-neutral-300 p-2 transition-colors duration-250 hover:text-cyan-400'}>
41-
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
42-
</a>
43-
}
44-
</div>
73+
<Can action={'backup.download'}>
74+
<div className={'ml-6'} style={{ marginRight: '-0.5rem' }}>
75+
{!backup.completedAt ?
76+
<div className={'p-2 invisible'}>
77+
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
78+
</div>
79+
:
80+
<a
81+
href={`/api/client/servers/${uuid}/backups/${backup.uuid}/download`}
82+
target={'_blank'}
83+
onClick={() => {
84+
setVisible(true);
85+
}}
86+
className={'text-sm text-neutral-300 p-2 transition-colors duration-250 hover:text-cyan-400'}
87+
>
88+
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
89+
</a>
90+
}
91+
</div>
92+
</Can>
4593
</div>
4694
);
4795
};

routes/api-client.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
Route::get('/', 'Servers\BackupController@index');
9292
Route::post('/', 'Servers\BackupController@store');
9393
Route::get('/{backup}', 'Servers\BackupController@view');
94+
Route::get('/{backup}/download', 'Servers\DownloadBackupController');
9495
Route::post('/{backup}', 'Servers\BackupController@update');
9596
Route::delete('/{backup}', 'Servers\BackupController@delete');
9697
});

0 commit comments

Comments
 (0)