From 9ba6aaebdef8e7647b387199b491bde1efca2de9 Mon Sep 17 00:00:00 2001 From: Dane Everitt Date: Thu, 9 Apr 2020 22:08:09 -0700 Subject: [PATCH] Move actions into context menu, add support for deleting a backup --- .../Api/Client/Servers/BackupController.php | 46 ++++++-- .../Servers/Backups/DeleteBackupRequest.php | 17 +++ .../Wings/DaemonBackupRepository.php | 13 +-- app/Services/Backups/DeleteBackupService.php | 58 ++++++++++ .../api/server/backups/deleteBackup.ts | 9 ++ .../components/elements/DropdownMenu.tsx | 87 ++++++++++++++ .../server/backups/BackupContextMenu.tsx | 109 ++++++++++++++++++ .../components/server/backups/BackupRow.tsx | 54 +-------- .../server/backups/ChecksumModal.tsx | 16 +++ routes/api-client.php | 1 - 10 files changed, 344 insertions(+), 66 deletions(-) create mode 100644 app/Http/Requests/Api/Client/Servers/Backups/DeleteBackupRequest.php create mode 100644 app/Services/Backups/DeleteBackupService.php create mode 100644 resources/scripts/api/server/backups/deleteBackup.ts create mode 100644 resources/scripts/components/elements/DropdownMenu.tsx create mode 100644 resources/scripts/components/server/backups/BackupContextMenu.tsx create mode 100644 resources/scripts/components/server/backups/ChecksumModal.tsx diff --git a/app/Http/Controllers/Api/Client/Servers/BackupController.php b/app/Http/Controllers/Api/Client/Servers/BackupController.php index 64703dd85..f908f81f8 100644 --- a/app/Http/Controllers/Api/Client/Servers/BackupController.php +++ b/app/Http/Controllers/Api/Client/Servers/BackupController.php @@ -2,12 +2,16 @@ namespace Pterodactyl\Http\Controllers\Api\Client\Servers; +use Pterodactyl\Models\Backup; use Pterodactyl\Models\Server; +use Illuminate\Http\JsonResponse; +use Pterodactyl\Services\Backups\DeleteBackupService; use Pterodactyl\Services\Backups\InitiateBackupService; use Pterodactyl\Transformers\Api\Client\BackupTransformer; use Pterodactyl\Http\Controllers\Api\Client\ClientApiController; use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\GetBackupsRequest; use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\StoreBackupRequest; +use Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DeleteBackupRequest; class BackupController extends ClientApiController { @@ -16,16 +20,23 @@ class BackupController extends ClientApiController */ private $initiateBackupService; + /** + * @var \Pterodactyl\Services\Backups\DeleteBackupService + */ + private $deleteBackupService; + /** * BackupController constructor. * + * @param \Pterodactyl\Services\Backups\DeleteBackupService $deleteBackupService * @param \Pterodactyl\Services\Backups\InitiateBackupService $initiateBackupService */ - public function __construct(InitiateBackupService $initiateBackupService) + public function __construct(DeleteBackupService $deleteBackupService, InitiateBackupService $initiateBackupService) { parent::__construct(); $this->initiateBackupService = $initiateBackupService; + $this->deleteBackupService = $deleteBackupService; } /** @@ -50,7 +61,7 @@ class BackupController extends ClientApiController * @param \Pterodactyl\Models\Server $server * @return array * - * @throws \Exception + * @throws \Exception|\Throwable */ public function store(StoreBackupRequest $request, Server $server) { @@ -63,15 +74,36 @@ class BackupController extends ClientApiController ->toArray(); } - public function view() + /** + * Returns information about a single backup. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\GetBackupsRequest $request + * @param \Pterodactyl\Models\Server $server + * @param \Pterodactyl\Models\Backup $backup + * @return array + */ + public function view(GetBackupsRequest $request, Server $server, Backup $backup) { + return $this->fractal->item($backup) + ->transformWith($this->getTransformer(BackupTransformer::class)) + ->toArray(); } - public function update() + /** + * Deletes a backup from the panel as well as the remote source where it is currently + * being stored. + * + * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Backups\DeleteBackupRequest $request + * @param \Pterodactyl\Models\Server $server + * @param \Pterodactyl\Models\Backup $backup + * @return \Illuminate\Http\JsonResponse + * + * @throws \Throwable + */ + public function delete(DeleteBackupRequest $request, Server $server, Backup $backup) { - } + $this->deleteBackupService->handle($backup); - public function delete() - { + return JsonResponse::create([], JsonResponse::HTTP_NO_CONTENT); } } diff --git a/app/Http/Requests/Api/Client/Servers/Backups/DeleteBackupRequest.php b/app/Http/Requests/Api/Client/Servers/Backups/DeleteBackupRequest.php new file mode 100644 index 000000000..33b68aabd --- /dev/null +++ b/app/Http/Requests/Api/Client/Servers/Backups/DeleteBackupRequest.php @@ -0,0 +1,17 @@ +server, Server::class); try { - return $this->getHttpClient()->get( - sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup), - ['stream' => true] + return $this->getHttpClient()->delete( + sprintf('/api/servers/%s/backup/%s', $this->server->uuid, $backup->uuid) ); } catch (TransferException $exception) { throw new DaemonConnectionException($exception); diff --git a/app/Services/Backups/DeleteBackupService.php b/app/Services/Backups/DeleteBackupService.php new file mode 100644 index 000000000..b0318e852 --- /dev/null +++ b/app/Services/Backups/DeleteBackupService.php @@ -0,0 +1,58 @@ +repository = $repository; + $this->daemonBackupRepository = $daemonBackupRepository; + $this->connection = $connection; + } + + /** + * Deletes a backup from the system. + * + * @param \Pterodactyl\Models\Backup $backup + * @throws \Throwable + */ + public function handle(Backup $backup) + { + $this->connection->transaction(function () use ($backup) { + $this->daemonBackupRepository->setServer($backup->server)->delete($backup); + + $this->repository->delete($backup->id); + }); + } +} diff --git a/resources/scripts/api/server/backups/deleteBackup.ts b/resources/scripts/api/server/backups/deleteBackup.ts new file mode 100644 index 000000000..01f48d23f --- /dev/null +++ b/resources/scripts/api/server/backups/deleteBackup.ts @@ -0,0 +1,9 @@ +import http from '@/api/http'; + +export default (uuid: string, backup: string): Promise => { + return new Promise((resolve, reject) => { + http.delete(`/api/client/servers/${uuid}/backups/${backup}`) + .then(() => resolve()) + .catch(reject); + }); +}; diff --git a/resources/scripts/components/elements/DropdownMenu.tsx b/resources/scripts/components/elements/DropdownMenu.tsx new file mode 100644 index 000000000..e9a326f77 --- /dev/null +++ b/resources/scripts/components/elements/DropdownMenu.tsx @@ -0,0 +1,87 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { CSSTransition } from 'react-transition-group'; +import styled from 'styled-components'; + +interface Props { + children: React.ReactNode; + renderToggle: (onClick: (e: React.MouseEvent) => void) => React.ReactChild; +} + +export const DropdownButtonRow = styled.button<{ danger?: boolean }>` + ${tw`p-2 flex items-center rounded w-full text-neutral-500`}; + transition: 150ms all ease; + + &:hover { + ${props => props.danger + ? tw`text-red-700 bg-red-100` + : tw`text-neutral-700 bg-neutral-100` + }; + } +`; + +const DropdownMenu = ({ renderToggle, children }: Props) => { + const menu = useRef(null); + const [ posX, setPosX ] = useState(0); + const [ visible, setVisible ] = useState(false); + + const onClickHandler = (e: React.MouseEvent) => { + e.preventDefault(); + + !visible && setPosX(e.clientX); + setVisible(s => !s); + }; + + const windowListener = (e: MouseEvent) => { + if (e.button === 2 || !visible || !menu.current) { + return; + } + + if (e.target === menu.current || menu.current.contains(e.target as Node)) { + return; + } + + if (e.target !== menu.current && !menu.current.contains(e.target as Node)) { + setVisible(false); + } + }; + + useEffect(() => { + if (!visible || !menu.current) { + return; + } + + document.addEventListener('click', windowListener); + menu.current.setAttribute( + 'style', `left: ${Math.round(posX - menu.current.clientWidth)}px`, + ); + + return () => { + document.removeEventListener('click', windowListener); + } + }, [ visible ]); + + return ( +
+ {renderToggle(onClickHandler)} + +
{ + e.stopPropagation(); + setVisible(false); + }} + className={'absolute bg-white p-2 rounded border border-neutral-700 shadow-lg text-neutral-500 min-w-48'} + > + {children} +
+
+
+ ); +}; + +export default DropdownMenu; diff --git a/resources/scripts/components/server/backups/BackupContextMenu.tsx b/resources/scripts/components/server/backups/BackupContextMenu.tsx new file mode 100644 index 000000000..31ce9f9af --- /dev/null +++ b/resources/scripts/components/server/backups/BackupContextMenu.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import { ServerBackup } from '@/api/server/backups/getServerBackups'; +import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import DropdownMenu, { DropdownButtonRow } from '@/components/elements/DropdownMenu'; +import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons/faCloudDownloadAlt'; +import { faTrashAlt } from '@fortawesome/free-solid-svg-icons/faTrashAlt'; +import { faLock } from '@fortawesome/free-solid-svg-icons/faLock'; +import getBackupDownloadUrl from '@/api/server/backups/getBackupDownloadUrl'; +import { httpErrorToHuman } from '@/api/http'; +import useFlash from '@/plugins/useFlash'; +import ChecksumModal from '@/components/server/backups/ChecksumModal'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useServer from '@/plugins/useServer'; +import deleteBackup from '@/api/server/backups/deleteBackup'; +import { ServerContext } from '@/state/server'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; + +interface Props { + backup: ServerBackup; +} + +export default ({ backup }: Props) => { + const { uuid } = useServer(); + const [ loading, setLoading ] = useState(false); + const [ visible, setVisible ] = useState(false); + const [ deleteVisible, setDeleteVisible ] = useState(false); + const { addError, clearFlashes } = useFlash(); + const removeBackup = ServerContext.useStoreActions(actions => actions.backups.removeBackup); + + const doDownload = () => { + setLoading(true); + clearFlashes('backups'); + getBackupDownloadUrl(uuid, backup.uuid) + .then(url => { + // @ts-ignore + window.location = url; + }) + .catch(error => { + console.error(error); + addError({ key: 'backups', message: httpErrorToHuman(error) }); + }) + .then(() => setLoading(false)); + }; + + const doDeletion = () => { + setLoading(true); + clearFlashes('backups'); + deleteBackup(uuid, backup.uuid) + .then(() => removeBackup(backup.uuid)) + .catch(error => { + console.error(error); + addError({ key: 'backups', message: httpErrorToHuman(error) }); + setLoading(false); + setDeleteVisible(false); + }); + }; + + return ( + <> + {visible && + setVisible(false)} + checksum={backup.sha256Hash} + /> + } + {deleteVisible && + doDeletion()} + visible={deleteVisible} + onDismissed={() => setDeleteVisible(false)} + > + Are you sure you wish to delete this backup? This is a permanent operation and the backup cannot + be recovered once deleted. + + } + + ( + + )} + > +
+ doDownload()}> + + Download + + setVisible(true)}> + + Checksum + + setDeleteVisible(true)}> + + Delete + +
+
+ + ); +}; diff --git a/resources/scripts/components/server/backups/BackupRow.tsx b/resources/scripts/components/server/backups/BackupRow.tsx index 7159c7075..41183504a 100644 --- a/resources/scripts/components/server/backups/BackupRow.tsx +++ b/resources/scripts/components/server/backups/BackupRow.tsx @@ -5,7 +5,6 @@ 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 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'; @@ -16,30 +15,15 @@ import useFlash from '@/plugins/useFlash'; import { httpErrorToHuman } from '@/api/http'; import useWebsocketEvent from '@/plugins/useWebsocketEvent'; import { ServerContext } from '@/state/server'; +import BackupContextMenu from '@/components/server/backups/BackupContextMenu'; +import { faEllipsisH } from '@fortawesome/free-solid-svg-icons/faEllipsisH'; interface Props { backup: ServerBackup; className?: string; } -const DownloadModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => ( - -

Verify file checksum

-

- The SHA256 checksum of this file is: -

-
-            {checksum}
-        
-
-); - export default ({ backup, className }: Props) => { - const { uuid } = useServer(); - const { addError, clearFlashes } = useFlash(); - const [ loading, setLoading ] = useState(false); - const [ visible, setVisible ] = useState(false); - const appendBackup = ServerContext.useStoreActions(actions => actions.backups.appendBackup); useWebsocketEvent(`backup completed:${backup.uuid}`, data => { @@ -56,33 +40,8 @@ export default ({ backup, className }: Props) => { } }); - 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(false)} - checksum={backup.sha256Hash} - /> - }
{backup.completedAt ? @@ -114,15 +73,10 @@ export default ({ backup, className }: Props) => {
{!backup.completedAt ?
- +
: - + }
diff --git a/resources/scripts/components/server/backups/ChecksumModal.tsx b/resources/scripts/components/server/backups/ChecksumModal.tsx new file mode 100644 index 000000000..f400da75b --- /dev/null +++ b/resources/scripts/components/server/backups/ChecksumModal.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import Modal, { RequiredModalProps } from '@/components/elements/Modal'; + +const ChecksumModal = ({ checksum, ...props }: RequiredModalProps & { checksum: string }) => ( + +

Verify file checksum

+

+ The SHA256 checksum of this file is: +

+
+            {checksum}
+        
+
+); + +export default ChecksumModal; diff --git a/routes/api-client.php b/routes/api-client.php index f68d61d8c..5ad045801 100644 --- a/routes/api-client.php +++ b/routes/api-client.php @@ -92,7 +92,6 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ 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'); });