Skip to content

Commit a924eb5

Browse files
committed
Fix file and backup downloading to use URL returned by server
1 parent 4b19e65 commit a924eb5

File tree

7 files changed

+199
-60
lines changed

7 files changed

+199
-60
lines changed

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

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,10 @@
22

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

5-
use Lcobucci\JWT\Builder;
65
use Carbon\CarbonImmutable;
7-
use Illuminate\Support\Str;
8-
use Lcobucci\JWT\Signer\Key;
96
use Pterodactyl\Models\Backup;
107
use Pterodactyl\Models\Server;
11-
use Lcobucci\JWT\Signer\Hmac\Sha256;
12-
use Illuminate\Http\RedirectResponse;
8+
use Pterodactyl\Services\Nodes\NodeJWTService;
139
use Illuminate\Contracts\Routing\ResponseFactory;
1410
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
1511
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
@@ -27,20 +23,28 @@ class DownloadBackupController extends ClientApiController
2723
*/
2824
private $responseFactory;
2925

26+
/**
27+
* @var \Pterodactyl\Services\Nodes\NodeJWTService
28+
*/
29+
private $jwtService;
30+
3031
/**
3132
* DownloadBackupController constructor.
3233
*
3334
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
35+
* @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService
3436
* @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory
3537
*/
3638
public function __construct(
3739
DaemonBackupRepository $daemonBackupRepository,
40+
NodeJWTService $jwtService,
3841
ResponseFactory $responseFactory
3942
) {
4043
parent::__construct();
4144

4245
$this->daemonBackupRepository = $daemonBackupRepository;
4346
$this->responseFactory = $responseFactory;
47+
$this->jwtService = $jwtService;
4448
}
4549

4650
/**
@@ -51,30 +55,27 @@ public function __construct(
5155
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest $request
5256
* @param \Pterodactyl\Models\Server $server
5357
* @param \Pterodactyl\Models\Backup $backup
54-
* @return \Illuminate\Http\RedirectResponse
58+
* @return array
5559
*/
5660
public function __invoke(DownloadBackupRequest $request, Server $server, Backup $backup)
5761
{
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-
);
62+
$token = $this->jwtService
63+
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
64+
->setClaims([
65+
'backup_uuid' => $backup->uuid,
66+
'server_uuid' => $server->uuid,
67+
])
68+
->handle($server->node, $request->user()->id . $server->uuid);
7769

78-
return RedirectResponse::create($location);
70+
return [
71+
'object' => 'signed_url',
72+
'attributes' => [
73+
'url' => sprintf(
74+
'%s/download/backup?token=%s',
75+
$server->node->getConnectionAddress(),
76+
$token->__toString()
77+
),
78+
],
79+
];
7980
}
8081
}

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

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22

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

5+
use Carbon\CarbonImmutable;
56
use Illuminate\Http\Response;
67
use Pterodactyl\Models\Server;
78
use GuzzleHttp\Exception\TransferException;
9+
use Pterodactyl\Services\Nodes\NodeJWTService;
810
use Illuminate\Contracts\Routing\ResponseFactory;
911
use Pterodactyl\Repositories\Wings\DaemonFileRepository;
1012
use Pterodactyl\Transformers\Daemon\FileObjectTransformer;
@@ -30,20 +32,28 @@ class FileController extends ClientApiController
3032
*/
3133
private $responseFactory;
3234

35+
/**
36+
* @var \Pterodactyl\Services\Nodes\NodeJWTService
37+
*/
38+
private $jwtService;
39+
3340
/**
3441
* FileController constructor.
3542
*
3643
* @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory
44+
* @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService
3745
* @param \Pterodactyl\Repositories\Wings\DaemonFileRepository $fileRepository
3846
*/
3947
public function __construct(
4048
ResponseFactory $responseFactory,
49+
NodeJWTService $jwtService,
4150
DaemonFileRepository $fileRepository
4251
) {
4352
parent::__construct();
4453

4554
$this->fileRepository = $fileRepository;
4655
$this->responseFactory = $responseFactory;
56+
$this->jwtService = $jwtService;
4757
}
4858

4959
/**
@@ -90,36 +100,35 @@ public function getFileContents(GetFileContentsRequest $request, Server $server)
90100
}
91101

92102
/**
103+
* Generates a one-time token with a link that the user can use to
104+
* download a given file.
105+
*
93106
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest $request
94107
* @param \Pterodactyl\Models\Server $server
95-
* @return \Symfony\Component\HttpFoundation\StreamedResponse
108+
* @return array
96109
*
97110
* @throws \Exception
98111
*/
99112
public function download(GetFileContentsRequest $request, Server $server)
100113
{
101-
set_time_limit(0);
102-
103-
$request = $this->fileRepository->setServer($server)->streamContent(
104-
$request->get('file')
105-
);
106-
107-
$body = $request->getBody();
108-
109-
preg_match('/filename=(?<name>.*)$/', $request->getHeaderLine('Content-Disposition'), $matches);
110-
111-
return $this->responseFactory->streamDownload(
112-
function () use ($body) {
113-
while (! $body->eof()) {
114-
echo $body->read(128);
115-
}
116-
},
117-
$matches['name'] ?? 'download',
118-
[
119-
'Content-Type' => $request->getHeaderLine('Content-Type'),
120-
'Content-Length' => $request->getHeaderLine('Content-Length'),
121-
]
122-
);
114+
$token = $this->jwtService
115+
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
116+
->setClaims([
117+
'file_path' => $request->get('file'),
118+
'server_uuid' => $server->uuid,
119+
])
120+
->handle($server->node, $request->user()->id . $server->uuid);
121+
122+
return [
123+
'object' => 'signed_url',
124+
'attributes' => [
125+
'url' => sprintf(
126+
'%s/download/file?token=%s',
127+
$server->node->getConnectionAddress(),
128+
$token->__toString()
129+
),
130+
],
131+
];
123132
}
124133

125134
/**
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace Pterodactyl\Services\Nodes;
4+
5+
use DateTimeInterface;
6+
use Lcobucci\JWT\Builder;
7+
use Carbon\CarbonImmutable;
8+
use Illuminate\Support\Str;
9+
use Lcobucci\JWT\Signer\Key;
10+
use Pterodactyl\Models\Node;
11+
use Lcobucci\JWT\Signer\Hmac\Sha256;
12+
13+
class NodeJWTService
14+
{
15+
/**
16+
* @var array
17+
*/
18+
private $claims = [];
19+
20+
/**
21+
* @var int|null
22+
*/
23+
private $expiresAt;
24+
25+
/**
26+
* Set the claims to include in this JWT.
27+
*
28+
* @param array $claims
29+
* @return $this
30+
*/
31+
public function setClaims(array $claims)
32+
{
33+
$this->claims = $claims;
34+
35+
return $this;
36+
}
37+
38+
public function setExpiresAt(DateTimeInterface $date)
39+
{
40+
$this->expiresAt = $date->getTimestamp();
41+
42+
return $this;
43+
}
44+
45+
/**
46+
* Generate a new JWT for a given node.
47+
*
48+
* @param \Pterodactyl\Models\Node $node
49+
* @param string|null $identifiedBy
50+
* @return \Lcobucci\JWT\Token
51+
*/
52+
public function handle(Node $node, string $identifiedBy)
53+
{
54+
$signer = new Sha256;
55+
56+
$builder = (new Builder)->issuedBy(config('app.url'))
57+
->permittedFor($node->getConnectionAddress())
58+
->identifiedBy(hash('sha256', $identifiedBy), true)
59+
->issuedAt(CarbonImmutable::now()->getTimestamp())
60+
->canOnlyBeUsedAfter(CarbonImmutable::now()->subMinutes(5)->getTimestamp());
61+
62+
if ($this->expiresAt) {
63+
$builder = $builder->expiresAt($this->expiresAt);
64+
}
65+
66+
foreach ($this->claims as $key => $value) {
67+
$builder = $builder->withClaim($key, $value);
68+
}
69+
70+
return $builder
71+
->withClaim('unique_id', Str::random(16))
72+
->getToken($signer, new Key($node->daemonSecret));
73+
}
74+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import http from '@/api/http';
2+
3+
export default (uuid: string, backup: string): Promise<string> => {
4+
return new Promise((resolve, reject) => {
5+
http.get(`/api/client/servers/${uuid}/backups/${backup}/download`)
6+
.then(({ data }) => resolve(data.attributes.url))
7+
.catch(reject);
8+
});
9+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import http from '@/api/http';
2+
3+
export default (uuid: string, file: string): Promise<string> => {
4+
return new Promise((resolve, reject) => {
5+
http.get(`/api/client/servers/${uuid}/files/download`, { params: { file } })
6+
.then(({ data }) => resolve(data.attributes.url))
7+
.catch(reject);
8+
});
9+
};

resources/scripts/components/server/backups/BackupRow.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDow
99
import Modal, { RequiredModalProps } from '@/components/elements/Modal';
1010
import { bytesToHuman } from '@/helpers';
1111
import Can from '@/components/elements/Can';
12-
import { join } from "path";
1312
import useServer from '@/plugins/useServer';
13+
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
14+
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
15+
import useFlash from '@/plugins/useFlash';
16+
import { httpErrorToHuman } from '@/api/http';
1417

1518
interface Props {
1619
backup: ServerBackup;
@@ -31,10 +34,29 @@ const DownloadModal = ({ checksum, ...props }: RequiredModalProps & { checksum:
3134

3235
export default ({ backup, className }: Props) => {
3336
const { uuid } = useServer();
37+
const { addError, clearFlashes } = useFlash();
38+
const [ loading, setLoading ] = useState(false);
3439
const [ visible, setVisible ] = useState(false);
3540

41+
const getBackupLink = () => {
42+
setLoading(true);
43+
clearFlashes('backups');
44+
getBackupDownloadUrl(uuid, backup.uuid)
45+
.then(url => {
46+
// @ts-ignore
47+
window.location = url;
48+
setVisible(true);
49+
})
50+
.catch(error => {
51+
console.error(error);
52+
addError({ key: 'backups', message: httpErrorToHuman(error) });
53+
})
54+
.then(() => setLoading(false));
55+
};
56+
3657
return (
3758
<div className={`grey-row-box flex items-center ${className}`}>
59+
<SpinnerOverlay visible={loading} fixed={true}/>
3860
{visible &&
3961
<DownloadModal
4062
visible={visible}
@@ -77,16 +99,12 @@ export default ({ backup, className }: Props) => {
7799
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
78100
</div>
79101
:
80-
<a
81-
href={`/api/client/servers/${uuid}/backups/${backup.uuid}/download`}
82-
target={'_blank'}
83-
onClick={() => {
84-
setVisible(true);
85-
}}
102+
<button
103+
onClick={() => getBackupLink()}
86104
className={'text-sm text-neutral-300 p-2 transition-colors duration-250 hover:text-cyan-400'}
87105
>
88106
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
89-
</a>
107+
</button>
90108
}
91109
</div>
92110
</Can>

0 commit comments

Comments
 (0)