Add support for generating a signed URL for downloading a file from the daemon
This commit is contained in:
parent
15b436d26e
commit
be05d2df81
7 changed files with 230 additions and 17 deletions
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
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 Illuminate\Contracts\Routing\ResponseFactory;
|
||||
use Pterodactyl\Repositories\Wings\DaemonBackupRepository;
|
||||
use Pterodactyl\Http\Controllers\Api\Client\ClientApiController;
|
||||
use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest;
|
||||
|
||||
class DownloadBackupController extends ClientApiController
|
||||
{
|
||||
/**
|
||||
* @var \Pterodactyl\Repositories\Wings\DaemonBackupRepository
|
||||
*/
|
||||
private $daemonBackupRepository;
|
||||
|
||||
/**
|
||||
* @var \Illuminate\Contracts\Routing\ResponseFactory
|
||||
*/
|
||||
private $responseFactory;
|
||||
|
||||
/**
|
||||
* DownloadBackupController constructor.
|
||||
*
|
||||
* @param \Pterodactyl\Repositories\Wings\DaemonBackupRepository $daemonBackupRepository
|
||||
* @param \Illuminate\Contracts\Routing\ResponseFactory $responseFactory
|
||||
*/
|
||||
public function __construct(
|
||||
DaemonBackupRepository $daemonBackupRepository,
|
||||
ResponseFactory $responseFactory
|
||||
) {
|
||||
parent::__construct();
|
||||
|
||||
$this->daemonBackupRepository = $daemonBackupRepository;
|
||||
$this->responseFactory = $responseFactory;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the backup for a given server instance. For daemon local files, the file
|
||||
* will be streamed back through the Panel. For AWS S3 files, a signed URL will be generated
|
||||
* which the user is redirected to.
|
||||
*
|
||||
* @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DownloadBackupRequest $request
|
||||
* @param \Pterodactyl\Models\Server $server
|
||||
* @param \Pterodactyl\Models\Backup $backup
|
||||
* @return \Illuminate\Http\RedirectResponse
|
||||
*/
|
||||
public function __invoke(DownloadBackupRequest $request, Server $server, Backup $backup)
|
||||
{
|
||||
$signer = new Sha256;
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
namespace Pterodactyl\Http\Middleware\Api\Client;
|
||||
|
||||
use Closure;
|
||||
use Pterodactyl\Models\Backup;
|
||||
use Illuminate\Container\Container;
|
||||
use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
||||
use Pterodactyl\Http\Middleware\Api\ApiSubstituteBindings;
|
||||
|
@ -55,6 +56,10 @@ class SubstituteClientApiBindings extends ApiSubstituteBindings
|
|||
}
|
||||
});
|
||||
|
||||
$this->router->model('backup', Backup::class, function ($value) {
|
||||
return Backup::query()->where('uuid', $value)->firstOrFail();
|
||||
});
|
||||
|
||||
return parent::handle($request, $next);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Http\Requests\Api\Client\Servers\Backups;
|
||||
|
||||
use Pterodactyl\Models\Backup;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Pterodactyl\Models\Permission;
|
||||
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
|
||||
|
||||
class DownloadBackupRequest extends ClientApiRequest
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function permission()
|
||||
{
|
||||
return Permission::ACTION_BACKUP_DOWNLOAD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that this backup belongs to the server that is also present in the
|
||||
* request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function resourceExists(): bool
|
||||
{
|
||||
/** @var \Pterodactyl\Models\Server|mixed $server */
|
||||
$server = $this->route()->parameter('server');
|
||||
/** @var \Pterodactyl\Models\Backup|mixed $backup */
|
||||
$backup = $this->route()->parameter('backup');
|
||||
|
||||
if ($server instanceof Server && $backup instanceof Backup) {
|
||||
if ($server->exists && $backup->exists && $server->id === $backup->server_id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -26,6 +26,9 @@ class Backup extends Model
|
|||
|
||||
const RESOURCE_NAME = 'backup';
|
||||
|
||||
const DISK_LOCAL = 'local';
|
||||
const DISK_AWS_S3 = 's3';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
|
|
35
app/Repositories/Wings/DaemonBackupRepository.php
Normal file
35
app/Repositories/Wings/DaemonBackupRepository.php
Normal file
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace Pterodactyl\Repositories\Wings;
|
||||
|
||||
use Webmozart\Assert\Assert;
|
||||
use Pterodactyl\Models\Server;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use GuzzleHttp\Exception\TransferException;
|
||||
use Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException;
|
||||
|
||||
class DaemonBackupRepository extends DaemonRepository
|
||||
{
|
||||
/**
|
||||
* Returns a stream of a backup's contents from the Wings instance so that we
|
||||
* do not need to send the user directly to the Daemon.
|
||||
*
|
||||
* @param string $backup
|
||||
* @return \Psr\Http\Message\ResponseInterface
|
||||
*
|
||||
* @throws \Pterodactyl\Exceptions\Http\Connection\DaemonConnectionException
|
||||
*/
|
||||
public function getBackup(string $backup): ResponseInterface
|
||||
{
|
||||
Assert::isInstanceOf($this->server, Server::class);
|
||||
|
||||
try {
|
||||
return $this->getHttpClient()->get(
|
||||
sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup),
|
||||
['stream' => true]
|
||||
);
|
||||
} catch (TransferException $exception) {
|
||||
throw new DaemonConnectionException($exception);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,28 +1,67 @@
|
|||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { ServerBackup } from '@/api/server/backups/getServerBackups';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArchive } from '@fortawesome/free-solid-svg-icons/faArchive';
|
||||
import format from 'date-fns/format';
|
||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'
|
||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
||||
import Spinner from '@/components/elements/Spinner';
|
||||
import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDownloadAlt';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
backup: ServerBackup;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DownloadModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => (
|
||||
<Modal {...props}>
|
||||
<h3 className={'mb-6'}>Verify file checksum</h3>
|
||||
<p className={'text-sm'}>
|
||||
The SHA256 checksum of this file is:
|
||||
</p>
|
||||
<pre className={'mt-2 text-sm p-2 bg-neutral-900 rounded'}>
|
||||
<code className={'block font-mono'}>{checksum}</code>
|
||||
</pre>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
export default ({ backup, className }: Props) => {
|
||||
const { uuid } = useServer();
|
||||
const [ visible, setVisible ] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`grey-row-box flex items-center ${className}`}>
|
||||
{visible &&
|
||||
<DownloadModal
|
||||
visible={visible}
|
||||
appear={true}
|
||||
onDismissed={() => setVisible(false)}
|
||||
checksum={backup.sha256Hash}
|
||||
/>
|
||||
}
|
||||
<div className={'mr-4'}>
|
||||
<FontAwesomeIcon icon={faArchive} className={'text-neutral-300'}/>
|
||||
{backup.completedAt ?
|
||||
<FontAwesomeIcon icon={faArchive} className={'text-neutral-300'}/>
|
||||
:
|
||||
<Spinner size={'tiny'}/>
|
||||
}
|
||||
</div>
|
||||
<div className={'flex-1'}>
|
||||
<p className={'text-sm mb-1'}>{backup.name}</p>
|
||||
<p className={'text-xs text-neutral-400 font-mono'}>{backup.uuid}</p>
|
||||
<p className={'text-sm mb-1'}>
|
||||
{backup.name}
|
||||
{backup.completedAt &&
|
||||
<span className={'ml-3 text-neutral-300 text-xs font-thin'}>{bytesToHuman(backup.bytes)}</span>
|
||||
}
|
||||
</p>
|
||||
<p className={'text-xs text-neutral-400 font-mono'}>
|
||||
{backup.uuid}
|
||||
</p>
|
||||
</div>
|
||||
<div className={'ml-4 text-center'}>
|
||||
<div className={'ml-8 text-center'}>
|
||||
<p
|
||||
title={format(backup.createdAt, 'ddd, MMMM Do, YYYY HH:mm:ss Z')}
|
||||
className={'text-sm'}
|
||||
|
@ -31,17 +70,26 @@ export default ({ backup, className }: Props) => {
|
|||
</p>
|
||||
<p className={'text-2xs text-neutral-500 uppercase mt-1'}>Created</p>
|
||||
</div>
|
||||
<div className={'ml-6'} style={{ marginRight: '-0.5rem' }}>
|
||||
{!backup.completedAt ?
|
||||
<div title={'Backup is in progress'} className={'p-2'}>
|
||||
<Spinner size={'tiny'}/>
|
||||
</div>
|
||||
:
|
||||
<a href={'#'} className={'text-sm text-neutral-300 p-2 transition-colors duration-250 hover:text-cyan-400'}>
|
||||
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<Can action={'backup.download'}>
|
||||
<div className={'ml-6'} style={{ marginRight: '-0.5rem' }}>
|
||||
{!backup.completedAt ?
|
||||
<div className={'p-2 invisible'}>
|
||||
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
|
||||
</div>
|
||||
:
|
||||
<a
|
||||
href={`/api/client/servers/${uuid}/backups/${backup.uuid}/download`}
|
||||
target={'_blank'}
|
||||
onClick={() => {
|
||||
setVisible(true);
|
||||
}}
|
||||
className={'text-sm text-neutral-300 p-2 transition-colors duration-250 hover:text-cyan-400'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCloudDownloadAlt}/>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</Can>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -91,6 +91,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
|
|||
Route::get('/', 'Servers\BackupController@index');
|
||||
Route::post('/', 'Servers\BackupController@store');
|
||||
Route::get('/{backup}', 'Servers\BackupController@view');
|
||||
Route::get('/{backup}/download', 'Servers\DownloadBackupController');
|
||||
Route::post('/{backup}', 'Servers\BackupController@update');
|
||||
Route::delete('/{backup}', 'Servers\BackupController@delete');
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue