Fix file and backup downloading to use URL returned by server

This commit is contained in:
Dane Everitt 2020-04-06 20:28:14 -07:00
parent 4b19e65eb8
commit a924eb56cc
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
7 changed files with 198 additions and 59 deletions

View file

@ -2,14 +2,10 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers; namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Lcobucci\JWT\Builder;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
use Lcobucci\JWT\Signer\Key;
use Pterodactyl\Models\Backup; use Pterodactyl\Models\Backup;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use Lcobucci\JWT\Signer\Hmac\Sha256; use Pterodactyl\Services\Nodes\NodeJWTService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Contracts\Routing\ResponseFactory;
use Pterodactyl\Repositories\Wings\DaemonBackupRepository; use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
@ -27,20 +23,28 @@ class DownloadBackupController extends ClientApiController
*/ */
private $responseFactory; private $responseFactory;
/**
* @var \Pterodactyl\Services\Nodes\NodeJWTService
*/
private $jwtService;
/** /**
* DownloadBackupController constructor. * DownloadBackupController constructor.
* *
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository * @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
* @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService
* @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory * @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory
*/ */
public function __construct( public function __construct(
DaemonBackupRepository $daemonBackupRepository, DaemonBackupRepository $daemonBackupRepository,
NodeJWTService $jwtService,
ResponseFactory $responseFactory ResponseFactory $responseFactory
) { ) {
parent::__construct(); parent::__construct();
$this->daemonBackupRepository = $daemonBackupRepository; $this->daemonBackupRepository = $daemonBackupRepository;
$this->responseFactory = $responseFactory; $this->responseFactory = $responseFactory;
$this->jwtService = $jwtService;
} }
/** /**
@ -51,30 +55,27 @@ class DownloadBackupController extends ClientApiController
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @param \Pterodactyl\Models\Backup $backup * @param \Pterodactyl\Models\Backup $backup
* @return \Illuminate\Http\RedirectResponse * @return array
*/ */
public function __invoke(DownloadBackupRequest $request, Server $server, Backup $backup) public function __invoke(DownloadBackupRequest $request, Server $server, Backup $backup)
{ {
$signer = new Sha256; $token = $this->jwtService
$now = CarbonImmutable::now(); ->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setClaims([
'backup_uuid' => $backup->uuid,
'server_uuid' => $server->uuid,
])
->handle($server->node, $request->user()->id . $server->uuid);
$token = (new Builder)->issuedBy(config('app.url')) return [
->permittedFor($server->node->getConnectionAddress()) 'object' => 'signed_url',
->identifiedBy(hash('sha256', $request->user()->id . $server->uuid), true) 'attributes' => [
->issuedAt($now->getTimestamp()) 'url' => sprintf(
->canOnlyBeUsedAfter($now->subMinutes(5)->getTimestamp()) '%s/download/backup?token=%s',
->expiresAt($now->addMinutes(15)->getTimestamp()) $server->node->getConnectionAddress(),
->withClaim('unique_id', Str::random(16)) $token->__toString()
->withClaim('backup_uuid', $backup->uuid) ),
->withClaim('server_uuid', $server->uuid) ],
->getToken($signer, new Key($server->node->daemonSecret)); ];
$location = sprintf(
'%s/download/backup?token=%s',
$server->node->getConnectionAddress(),
$token->__toString()
);
return RedirectResponse::create($location);
} }
} }

View file

@ -2,9 +2,11 @@
namespace Pterodactyl\Http\Controllers\Api\Client\Servers; namespace Pterodactyl\Http\Controllers\Api\Client\Servers;
use Carbon\CarbonImmutable;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Pterodactyl\Models\Server; use Pterodactyl\Models\Server;
use GuzzleHttp\Exception\TransferException; use GuzzleHttp\Exception\TransferException;
use Pterodactyl\Services\Nodes\NodeJWTService;
use Illuminate\Contracts\Routing\ResponseFactory; use Illuminate\Contracts\Routing\ResponseFactory;
use Pterodactyl\Repositories\Wings\DaemonFileRepository; use Pterodactyl\Repositories\Wings\DaemonFileRepository;
use Pterodactyl\Transformers\Daemon\FileObjectTransformer; use Pterodactyl\Transformers\Daemon\FileObjectTransformer;
@ -30,20 +32,28 @@ class FileController extends ClientApiController
*/ */
private $responseFactory; private $responseFactory;
/**
* @var \Pterodactyl\Services\Nodes\NodeJWTService
*/
private $jwtService;
/** /**
* FileController constructor. * FileController constructor.
* *
* @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory * @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory
* @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService
* @param \Pterodactyl\Repositories\Wings\DaemonFileRepository $fileRepository * @param \Pterodactyl\Repositories\Wings\DaemonFileRepository $fileRepository
*/ */
public function __construct( public function __construct(
ResponseFactory $responseFactory, ResponseFactory $responseFactory,
NodeJWTService $jwtService,
DaemonFileRepository $fileRepository DaemonFileRepository $fileRepository
) { ) {
parent::__construct(); parent::__construct();
$this->fileRepository = $fileRepository; $this->fileRepository = $fileRepository;
$this->responseFactory = $responseFactory; $this->responseFactory = $responseFactory;
$this->jwtService = $jwtService;
} }
/** /**
@ -90,36 +100,35 @@ class FileController extends ClientApiController
} }
/** /**
* Generates a one-time token with a link that the user can use to
* download a given file.
*
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest $request * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\GetFileContentsRequest $request
* @param \Pterodactyl\Models\Server $server * @param \Pterodactyl\Models\Server $server
* @return \Symfony\Component\HttpFoundation\StreamedResponse * @return array
* *
* @throws \Exception * @throws \Exception
*/ */
public function download(GetFileContentsRequest $request, Server $server) public function download(GetFileContentsRequest $request, Server $server)
{ {
set_time_limit(0); $token = $this->jwtService
->setExpiresAt(CarbonImmutable::now()->addMinutes(15))
->setClaims([
'file_path' => $request->get('file'),
'server_uuid' => $server->uuid,
])
->handle($server->node, $request->user()->id . $server->uuid);
$request = $this->fileRepository->setServer($server)->streamContent( return [
$request->get('file') 'object' => 'signed_url',
); 'attributes' => [
'url' => sprintf(
$body = $request->getBody(); '%s/download/file?token=%s',
$server->node->getConnectionAddress(),
preg_match('/filename=(?<name>.*)$/', $request->getHeaderLine('Content-Disposition'), $matches); $token->__toString()
),
return $this->responseFactory->streamDownload( ],
function () use ($body) { ];
while (! $body->eof()) {
echo $body->read(128);
}
},
$matches['name'] ?? 'download',
[
'Content-Type' => $request->getHeaderLine('Content-Type'),
'Content-Length' => $request->getHeaderLine('Content-Length'),
]
);
} }
/** /**

View file

@ -0,0 +1,74 @@
<?php
namespace Pterodactyl\Services\Nodes;
use DateTimeInterface;
use Lcobucci\JWT\Builder;
use Carbon\CarbonImmutable;
use Illuminate\Support\Str;
use Lcobucci\JWT\Signer\Key;
use Pterodactyl\Models\Node;
use Lcobucci\JWT\Signer\Hmac\Sha256;
class NodeJWTService
{
/**
* @var array
*/
private $claims = [];
/**
* @var int|null
*/
private $expiresAt;
/**
* Set the claims to include in this JWT.
*
* @param array $claims
* @return $this
*/
public function setClaims(array $claims)
{
$this->claims = $claims;
return $this;
}
public function setExpiresAt(DateTimeInterface $date)
{
$this->expiresAt = $date->getTimestamp();
return $this;
}
/**
* Generate a new JWT for a given node.
*
* @param \Pterodactyl\Models\Node $node
* @param string|null $identifiedBy
* @return \Lcobucci\JWT\Token
*/
public function handle(Node $node, string $identifiedBy)
{
$signer = new Sha256;
$builder = (new Builder)->issuedBy(config('app.url'))
->permittedFor($node->getConnectionAddress())
->identifiedBy(hash('sha256', $identifiedBy), true)
->issuedAt(CarbonImmutable::now()->getTimestamp())
->canOnlyBeUsedAfter(CarbonImmutable::now()->subMinutes(5)->getTimestamp());
if ($this->expiresAt) {
$builder = $builder->expiresAt($this->expiresAt);
}
foreach ($this->claims as $key => $value) {
$builder = $builder->withClaim($key, $value);
}
return $builder
->withClaim('unique_id', Str::random(16))
->getToken($signer, new Key($node->daemonSecret));
}
}

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (uuid: string, backup: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/backups/${backup}/download`)
.then(({ data }) => resolve(data.attributes.url))
.catch(reject);
});
};

View file

@ -0,0 +1,9 @@
import http from '@/api/http';
export default (uuid: string, file: string): Promise<string> => {
return new Promise((resolve, reject) => {
http.get(`/api/client/servers/${uuid}/files/download`, { params: { file } })
.then(({ data }) => resolve(data.attributes.url))
.catch(reject);
});
};

View file

@ -9,8 +9,11 @@ import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDow
import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import Modal, { RequiredModalProps } from '@/components/elements/Modal';
import { bytesToHuman } from '@/helpers'; import { bytesToHuman } from '@/helpers';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import { join } from "path";
import useServer from '@/plugins/useServer'; import useServer from '@/plugins/useServer';
import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl';
import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import useFlash from '@/plugins/useFlash';
import { httpErrorToHuman } from '@/api/http';
interface Props { interface Props {
backup: ServerBackup; backup: ServerBackup;
@ -31,10 +34,29 @@ const DownloadModal = ({ checksum, ...props }: RequiredModalProps & { checksum:
export default ({ backup, className }: Props) => { export default ({ backup, className }: Props) => {
const { uuid } = useServer(); const { uuid } = useServer();
const { addError, clearFlashes } = useFlash();
const [ loading, setLoading ] = useState(false);
const [ visible, setVisible ] = useState(false); const [ visible, setVisible ] = useState(false);
const getBackupLink = () => {
setLoading(true);
clearFlashes('backups');
getBackupDownloadUrl(uuid, backup.uuid)
.then(url => {
// @ts-ignore
window.location = url;
setVisible(true);
})
.catch(error => {
console.error(error);
addError({ key: 'backups', message: httpErrorToHuman(error) });
})
.then(() => setLoading(false));
};
return ( return (
<div className={`grey-row-box flex items-center ${className}`}> <div className={`grey-row-box flex items-center ${className}`}>
<SpinnerOverlay visible={loading} fixed={true}/>
{visible && {visible &&
<DownloadModal <DownloadModal
visible={visible} visible={visible}
@ -77,16 +99,12 @@ export default ({ backup, className }: Props) => {
<FontAwesomeIcon icon={faCloudDownloadAlt}/> <FontAwesomeIcon icon={faCloudDownloadAlt}/>
</div> </div>
: :
<a <button
href={`/api/client/servers/${uuid}/backups/${backup.uuid}/download`} onClick={() => getBackupLink()}
target={'_blank'}
onClick={() => {
setVisible(true);
}}
className={'text-sm text-neutral-300 p-2 transition-colors duration-250 hover:text-cyan-400'} className={'text-sm text-neutral-300 p-2 transition-colors duration-250 hover:text-cyan-400'}
> >
<FontAwesomeIcon icon={faCloudDownloadAlt}/> <FontAwesomeIcon icon={faCloudDownloadAlt}/>
</a> </button>
} }
</div> </div>
</Can> </Can>

View file

@ -15,6 +15,9 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay';
import copyFile from '@/api/server/files/copyFile'; import copyFile from '@/api/server/files/copyFile';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import getFileDownloadUrl from '@/api/server/files/getFileDownloadUrl';
import useServer from '@/plugins/useServer';
import useFlash from '@/plugins/useFlash';
type ModalType = 'rename' | 'move'; type ModalType = 'rename' | 'move';
@ -26,7 +29,9 @@ export default ({ uuid }: { uuid: string }) => {
const [ modal, setModal ] = useState<ModalType | null>(null); const [ modal, setModal ] = useState<ModalType | null>(null);
const [ posX, setPosX ] = useState(0); const [ posX, setPosX ] = useState(0);
const server = ServerContext.useStoreState(state => state.server.data!); const server = useServer();
const { addError, clearFlashes } = useFlash();
const file = ServerContext.useStoreState(state => state.files.contents.find(file => file.uuid === uuid)); const file = ServerContext.useStoreState(state => state.files.contents.find(file => file.uuid === uuid));
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);
const { removeFile, getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files); const { removeFile, getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files);
@ -51,27 +56,41 @@ export default ({ uuid }: { uuid: string }) => {
const doDeletion = () => { const doDeletion = () => {
setShowSpinner(true); setShowSpinner(true);
clearFlashes('files');
deleteFile(server.uuid, join(directory, file.name)) deleteFile(server.uuid, join(directory, file.name))
.then(() => removeFile(uuid)) .then(() => removeFile(uuid))
.catch(error => { .catch(error => {
console.error('Error while attempting to delete a file.', error); console.error('Error while attempting to delete a file.', error);
addError({ key: 'files', message: httpErrorToHuman(error) });
setShowSpinner(false); setShowSpinner(false);
}); });
}; };
const doCopy = () => { const doCopy = () => {
setShowSpinner(true); setShowSpinner(true);
clearFlashes('files');
copyFile(server.uuid, join(directory, file.name)) copyFile(server.uuid, join(directory, file.name))
.then(() => getDirectoryContents(directory)) .then(() => getDirectoryContents(directory))
.catch(error => { .catch(error => {
console.error('Error while attempting to copy file.', error); console.error('Error while attempting to copy file.', error);
alert(httpErrorToHuman(error)); addError({ key: 'files', message: httpErrorToHuman(error) });
setShowSpinner(false); setShowSpinner(false);
}); });
}; };
const doDownload = () => { const doDownload = () => {
window.location = `/api/client/servers/${server.uuid}/files/download?file=${join(directory, file.name)}` as unknown as Location; setShowSpinner(true);
clearFlashes('files');
getFileDownloadUrl(server.uuid, join(directory, file.name))
.then(url => {
// @ts-ignore
window.location = url;
})
.catch(error => {
console.error(error);
addError({ key: 'files', message: httpErrorToHuman(error) });
})
.then(() => setShowSpinner(false));
}; };
useEffect(() => { useEffect(() => {