From a924eb56cc242e097fc62885406f2397f0473a27 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Mon, 6 Apr 2020 20:28:14 -0700 Subject: [PATCH] Fix file and backup downloading to use URL returned by server --- .../Servers/DownloadBackupController.php | 53 ++++++------- .../Api/Client/Servers/FileController.php | 53 +++++++------ app/Services/Nodes/NodeJWTService.php | 74 +++++++++++++++++++ .../server/backups/getBackupDownloadUrl.ts | 9 +++ .../api/server/files/getFileDownloadUrl.ts | 9 +++ .../components/server/backups/BackupRow.tsx | 34 +++++++-- .../server/files/FileDropdownMenu.tsx | 25 ++++++- 7 files changed, 198 insertions(+), 59 deletions(-) create mode 100644 app/Services/Nodes/NodeJWTService.php create mode 100644 resources/scripts/api/server/backups/getBackupDownloadUrl.ts create mode 100644 resources/scripts/api/server/files/getFileDownloadUrl.ts diff --git a/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php b/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php index 22c7ea6fb..4acb0c9c4 100644 --- a/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/DownloadBackupController.php @@ -2,14 +2,10 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; -use Lcobucci\JWT\Builder; use Carbon\CarbonImmutable; -use Illuminate\Support\Str; -use Lcobucci\JWT\Signer\Key; use Pterodactyl\Models\Backup; use Pterodactyl\Models\Server; -use Lcobucci\JWT\Signer\Hmac\Sha256; -use Illuminate\Http\RedirectResponse; +use Pterodactyl\Services\Nodes\NodeJWTService; use Illuminate\Contracts\Routing\ResponseFactory; use Pterodactyl\Repositories\Wings\DaemonBackupRepository; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; @@ -27,20 +23,28 @@ class DownloadBackupController extends ClientApiController */ private $responseFactory; + /** + * @var \Pterodactyl\Services\Nodes\NodeJWTService + */ + private $jwtService; + /** * DownloadBackupController constructor. * * @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository + * @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService * @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory */ public function __construct( DaemonBackupRepository $daemonBackupRepository, + NodeJWTService $jwtService, ResponseFactory $responseFactory ) { parent::__construct(); $this->daemonBackupRepository = $daemonBackupRepository; $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\Models\Server $server * @param \Pterodactyl\Models\Backup $backup - * @return \Illuminate\Http\RedirectResponse + * @return array */ public function __invoke(DownloadBackupRequest $request, Server $server, Backup $backup) { - $signer = new Sha256; - $now = CarbonImmutable::now(); + $token = $this->jwtService + ->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')) - ->permittedFor($server->node->getConnectionAddress()) - ->identifiedBy(hash('sha256', $request->user()->id . $server->uuid), true) - ->issuedAt($now->getTimestamp()) - ->canOnlyBeUsedAfter($now->subMinutes(5)->getTimestamp()) - ->expiresAt($now->addMinutes(15)->getTimestamp()) - ->withClaim('unique_id', Str::random(16)) - ->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); + return [ + 'object' => 'signed_url', + 'attributes' => [ + 'url' => sprintf( + '%s/download/backup?token=%s', + $server->node->getConnectionAddress(), + $token->__toString() + ), + ], + ]; } } diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 54638f2e1..4e916974d 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -2,9 +2,11 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; +use Carbon\CarbonImmutable; use Illuminate\Http\Response; use Pterodactyl\Models\Server; use GuzzleHttp\Exception\TransferException; +use Pterodactyl\Services\Nodes\NodeJWTService; use Illuminate\Contracts\Routing\ResponseFactory; use Pterodactyl\Repositories\Wings\DaemonFileRepository; use Pterodactyl\Transformers\Daemon\FileObjectTransformer; @@ -30,20 +32,28 @@ class FileController extends ClientApiController */ private $responseFactory; + /** + * @var \Pterodactyl\Services\Nodes\NodeJWTService + */ + private $jwtService; + /** * FileController constructor. * * @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory + * @param \Pterodactyl\Services\Nodes\NodeJWTService $jwtService * @param \Pterodactyl\Repositories\Wings\DaemonFileRepository $fileRepository */ public function __construct( ResponseFactory $responseFactory, + NodeJWTService $jwtService, DaemonFileRepository $fileRepository ) { parent::__construct(); $this->fileRepository = $fileRepository; $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\Models\Server $server - * @return \Symfony\Component\HttpFoundation\StreamedResponse + * @return array * * @throws \Exception */ 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( - $request->get('file') - ); - - $body = $request->getBody(); - - preg_match('/filename=(?.*)$/', $request->getHeaderLine('Content-Disposition'), $matches); - - 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'), - ] - ); + return [ + 'object' => 'signed_url', + 'attributes' => [ + 'url' => sprintf( + '%s/download/file?token=%s', + $server->node->getConnectionAddress(), + $token->__toString() + ), + ], + ]; } /** diff --git a/app/Services/Nodes/NodeJWTService.php b/app/Services/Nodes/NodeJWTService.php new file mode 100644 index 000000000..bb26527fd --- /dev/null +++ b/app/Services/Nodes/NodeJWTService.php @@ -0,0 +1,74 @@ +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)); + } +} diff --git a/resources/scripts/api/server/backups/getBackupDownloadUrl.ts b/resources/scripts/api/server/backups/getBackupDownloadUrl.ts new file mode 100644 index 000000000..70a3ae5e4 --- /dev/null +++ b/resources/scripts/api/server/backups/getBackupDownloadUrl.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (uuid: string, backup: string): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/client/servers/${uuid}/backups/${backup}/download`) + .then(({ data }) => resolve(data.attributes.url)) + .catch(reject); + }); +}; diff --git a/resources/scripts/api/server/files/getFileDownloadUrl.ts b/resources/scripts/api/server/files/getFileDownloadUrl.ts new file mode 100644 index 000000000..39db97290 --- /dev/null +++ b/resources/scripts/api/server/files/getFileDownloadUrl.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (uuid: string, file: string): Promise => { + return new Promise((resolve, reject) => { + http.get(`/api/client/servers/${uuid}/files/download`, { params: { file } }) + .then(({ data }) => resolve(data.attributes.url)) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx index 406372e12..d28e487e6 100644 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -9,8 +9,11 @@ import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDow import Modal, { RequiredModalProps } from '@/components/elements/Modal'; import { bytesToHuman } from '@/helpers'; import Can from '@/components/elements/Can'; -import { join } from "path"; 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 { backup: ServerBackup; @@ -31,10 +34,29 @@ const DownloadModal = ({ checksum, ...props }: RequiredModalProps & { checksum: export default ({ backup, className }: Props) => { const { uuid } = useServer(); + const { addError, clearFlashes } = useFlash(); + const [ loading, setLoading ] = 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 (
+ {visible && {
: - { - setVisible(true); - }} + } diff --git a/resources/scripts/components/server/files/FileDropdownMenu.tsx b/resources/scripts/components/server/files/FileDropdownMenu.tsx index 5db5d90e6..5c1b19d06 100644 --- a/resources/scripts/components/server/files/FileDropdownMenu.tsx +++ b/resources/scripts/components/server/files/FileDropdownMenu.tsx @@ -15,6 +15,9 @@ import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import copyFile from '@/api/server/files/copyFile'; import { httpErrorToHuman } from '@/api/http'; 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'; @@ -26,7 +29,9 @@ export default ({ uuid }: { uuid: string }) => { const [ modal, setModal ] = useState(null); 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 directory = ServerContext.useStoreState(state => state.files.directory); const { removeFile, getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files); @@ -51,27 +56,41 @@ export default ({ uuid }: { uuid: string }) => { const doDeletion = () => { setShowSpinner(true); + clearFlashes('files'); deleteFile(server.uuid, join(directory, file.name)) .then(() => removeFile(uuid)) .catch(error => { console.error('Error while attempting to delete a file.', error); + addError({ key: 'files', message: httpErrorToHuman(error) }); setShowSpinner(false); }); }; const doCopy = () => { setShowSpinner(true); + clearFlashes('files'); copyFile(server.uuid, join(directory, file.name)) .then(() => getDirectoryContents(directory)) .catch(error => { console.error('Error while attempting to copy file.', error); - alert(httpErrorToHuman(error)); + addError({ key: 'files', message: httpErrorToHuman(error) }); setShowSpinner(false); }); }; 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(() => {